create-brainerce-store 1.27.6 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/dist/index.js +93 -11
  2. package/messages/en.json +389 -382
  3. package/messages/he.json +389 -382
  4. package/package.json +46 -46
  5. package/templates/nextjs/base/.env.local.ejs +3 -3
  6. package/templates/nextjs/base/next.config.ts +32 -31
  7. package/templates/nextjs/base/package.json.ejs +2 -1
  8. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +15 -14
  9. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +66 -59
  10. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +76 -77
  11. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +229 -198
  12. package/templates/nextjs/base/src/app/checkout/page.tsx +3 -1
  13. package/templates/nextjs/base/src/app/layout.tsx.ejs +29 -13
  14. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +501 -501
  15. package/templates/nextjs/base/src/app/reset-password/page.tsx +138 -131
  16. package/templates/nextjs/base/src/components/auth/register-form.tsx +245 -232
  17. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +416 -415
  18. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +656 -592
  19. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +86 -72
  20. package/templates/nextjs/base/src/lib/csrf.ts +11 -0
  21. package/templates/nextjs/base/src/lib/nonce.ts +10 -0
  22. package/templates/nextjs/base/src/lib/safe-redirect.ts +45 -0
  23. package/templates/nextjs/base/src/lib/sanitize-html.ts +93 -0
  24. package/templates/nextjs/base/src/lib/validation.ts +37 -0
  25. package/templates/nextjs/base/src/middleware.ts.ejs +91 -8
  26. package/templates/nextjs/base/tsconfig.tsbuildinfo +1 -0
@@ -1,198 +1,229 @@
1
- import { NextRequest, NextResponse } from 'next/server';
2
- import { cookies } from 'next/headers';
3
-
4
- const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
5
- /\/$/,
6
- ''
7
- );
8
-
9
- const TOKEN_COOKIE = 'brainerce_customer_token';
10
- const LOGGED_IN_COOKIE = 'brainerce_logged_in';
11
- const CSRF_HEADER = 'x-requested-with';
12
- const CSRF_VALUE = 'brainerce';
13
-
14
- const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
15
-
16
- /** Auth endpoints whose responses contain tokens to intercept */
17
- const AUTH_ENDPOINTS = ['customers/login', 'customers/register', 'customers/verify-email'];
18
-
19
- function isAuthEndpoint(path: string): boolean {
20
- return AUTH_ENDPOINTS.some((ep) => path.endsWith(ep));
21
- }
22
-
23
- function isSecure(): boolean {
24
- return process.env.NODE_ENV === 'production';
25
- }
26
-
27
- function setAuthCookies(response: NextResponse, token: string): void {
28
- response.cookies.set(TOKEN_COOKIE, token, {
29
- httpOnly: true,
30
- secure: isSecure(),
31
- sameSite: 'lax',
32
- path: '/',
33
- maxAge: COOKIE_MAX_AGE,
34
- });
35
- response.cookies.set(LOGGED_IN_COOKIE, '1', {
36
- httpOnly: false,
37
- secure: isSecure(),
38
- sameSite: 'lax',
39
- path: '/',
40
- maxAge: COOKIE_MAX_AGE,
41
- });
42
- }
43
-
44
- function clearAuthCookies(response: NextResponse): void {
45
- response.cookies.delete(TOKEN_COOKIE);
46
- response.cookies.delete(LOGGED_IN_COOKIE);
47
- }
48
-
49
- async function proxyRequest(
50
- request: NextRequest,
51
- params: { path: string[] }
52
- ): Promise<NextResponse> {
53
- const method = request.method;
54
-
55
- // CSRF protection for mutating requests
56
- if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
57
- const csrfHeader = request.headers.get(CSRF_HEADER);
58
- if (csrfHeader !== CSRF_VALUE) {
59
- return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
60
- }
61
- }
62
-
63
- // Build backend URL from path segments
64
- const pathSegments = params.path.join('/');
65
- const backendUrl = new URL(`${BACKEND_URL}/${pathSegments}`);
66
-
67
- // Forward query parameters
68
- request.nextUrl.searchParams.forEach((value, key) => {
69
- backendUrl.searchParams.set(key, value);
70
- });
71
-
72
- // Build headers for backend request
73
- const headers: Record<string, string> = {
74
- 'Content-Type': 'application/json',
75
- };
76
-
77
- // Forward Origin/Referer so backend BrowserOriginGuard accepts proxied requests
78
- // Always send Origin — same-origin GET requests may not include it, but the backend
79
- // uses its presence to distinguish fetch() calls from direct browser navigation
80
- const origin = request.headers.get('origin') || request.nextUrl.origin;
81
- const referer = request.headers.get('referer');
82
- headers['Origin'] = origin;
83
- if (referer) headers['Referer'] = referer;
84
-
85
- // Forward SDK version header if present
86
- const sdkVersion = request.headers.get('x-sdk-version');
87
- if (sdkVersion) {
88
- headers['X-SDK-Version'] = sdkVersion;
89
- }
90
-
91
- // Add auth token from httpOnly cookie
92
- const cookieStore = await cookies();
93
- const tokenCookie = cookieStore.get(TOKEN_COOKIE);
94
- if (tokenCookie?.value) {
95
- headers['Authorization'] = `Bearer ${tokenCookie.value}`;
96
- }
97
-
98
- // Forward request body for non-GET requests
99
- let body: string | undefined;
100
- if (method !== 'GET' && method !== 'HEAD') {
101
- try {
102
- body = await request.text();
103
- } catch {
104
- // No body
105
- }
106
- }
107
-
108
- // Proxy the request to backend
109
- let backendResponse: Response;
110
- try {
111
- backendResponse = await fetch(backendUrl.toString(), {
112
- method,
113
- headers,
114
- body,
115
- });
116
- } catch (error) {
117
- return NextResponse.json({ error: 'Backend service unavailable' }, { status: 502 });
118
- }
119
-
120
- // Read response body
121
- const responseText = await backendResponse.text();
122
-
123
- // For auth endpoints: intercept token, set cookie, strip token from response
124
- if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
125
- try {
126
- const data = JSON.parse(responseText);
127
- if (data.token) {
128
- const token = data.token;
129
-
130
- // Strip token from client response
131
- const { token: _stripped, ...safeData } = data;
132
-
133
- const response = NextResponse.json(safeData, {
134
- status: backendResponse.status,
135
- });
136
- setAuthCookies(response, token);
137
- return response;
138
- }
139
- } catch {
140
- // Not JSON or no token field — pass through
141
- }
142
- }
143
-
144
- // Handle 401 responses: clear auth cookies
145
- if (backendResponse.status === 401 && tokenCookie?.value) {
146
- const response = new NextResponse(responseText, {
147
- status: backendResponse.status,
148
- headers: {
149
- 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
150
- },
151
- });
152
- clearAuthCookies(response);
153
- return response;
154
- }
155
-
156
- // Pass through response as-is
157
- return new NextResponse(responseText, {
158
- status: backendResponse.status,
159
- headers: {
160
- 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
161
- },
162
- });
163
- }
164
-
165
- export async function GET(
166
- request: NextRequest,
167
- { params }: { params: Promise<{ path: string[] }> }
168
- ) {
169
- return proxyRequest(request, await params);
170
- }
171
-
172
- export async function POST(
173
- request: NextRequest,
174
- { params }: { params: Promise<{ path: string[] }> }
175
- ) {
176
- return proxyRequest(request, await params);
177
- }
178
-
179
- export async function PUT(
180
- request: NextRequest,
181
- { params }: { params: Promise<{ path: string[] }> }
182
- ) {
183
- return proxyRequest(request, await params);
184
- }
185
-
186
- export async function PATCH(
187
- request: NextRequest,
188
- { params }: { params: Promise<{ path: string[] }> }
189
- ) {
190
- return proxyRequest(request, await params);
191
- }
192
-
193
- export async function DELETE(
194
- request: NextRequest,
195
- { params }: { params: Promise<{ path: string[] }> }
196
- ) {
197
- return proxyRequest(request, await params);
198
- }
1
+ // SECURITY: This BFF proxy intentionally has no application-level rate limiting.
2
+ // Rate limiting is the deployer's responsibility — configure it at the platform
3
+ // edge (Vercel Firewall, Cloudflare, nginx) or add a Redis-backed limiter
4
+ // (e.g. @upstash/ratelimit) here before going to production. Auth endpoints
5
+ // like customers/login and customers/register are the most important to cover.
6
+ import { NextRequest, NextResponse } from 'next/server';
7
+ import { cookies } from 'next/headers';
8
+ import { checkCsrf } from '@/lib/csrf';
9
+
10
+ const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
11
+ /\/$/,
12
+ ''
13
+ );
14
+
15
+ const TOKEN_COOKIE = 'brainerce_customer_token';
16
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
17
+
18
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
19
+ const BACKEND_TIMEOUT_MS = 15_000;
20
+
21
+ /** Auth endpoints whose responses contain tokens to intercept */
22
+ const AUTH_ENDPOINTS = ['customers/login', 'customers/register', 'customers/verify-email'];
23
+
24
+ function isAuthEndpoint(path: string): boolean {
25
+ return AUTH_ENDPOINTS.some((ep) => path.endsWith(ep));
26
+ }
27
+
28
+ function isSafePathSegment(segment: string): boolean {
29
+ if (!segment) return false;
30
+ if (segment === '.' || segment === '..') return false;
31
+ if (segment.includes('/') || segment.includes('\\')) return false;
32
+ if (segment.includes('\0')) return false;
33
+ return true;
34
+ }
35
+
36
+ function isSecure(): boolean {
37
+ return process.env.NODE_ENV === 'production';
38
+ }
39
+
40
+ function setAuthCookies(response: NextResponse, token: string): void {
41
+ response.cookies.set(TOKEN_COOKIE, token, {
42
+ httpOnly: true,
43
+ secure: isSecure(),
44
+ sameSite: 'lax',
45
+ path: '/',
46
+ maxAge: COOKIE_MAX_AGE,
47
+ });
48
+ response.cookies.set(LOGGED_IN_COOKIE, '1', {
49
+ httpOnly: false,
50
+ secure: isSecure(),
51
+ sameSite: 'lax',
52
+ path: '/',
53
+ maxAge: COOKIE_MAX_AGE,
54
+ });
55
+ }
56
+
57
+ function clearAuthCookies(response: NextResponse): void {
58
+ response.cookies.delete(TOKEN_COOKIE);
59
+ response.cookies.delete(LOGGED_IN_COOKIE);
60
+ }
61
+
62
+ async function proxyRequest(
63
+ request: NextRequest,
64
+ params: { path: string[] }
65
+ ): Promise<NextResponse> {
66
+ const method = request.method;
67
+
68
+ // Reject path-traversal attempts before constructing the backend URL
69
+ if (!params.path.every(isSafePathSegment)) {
70
+ return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
71
+ }
72
+
73
+ // CSRF protection for mutating requests
74
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
75
+ const csrfError = checkCsrf(request);
76
+ if (csrfError) return csrfError;
77
+ }
78
+
79
+ // Build backend URL from path segments
80
+ const pathSegments = params.path.join('/');
81
+ const backendUrl = new URL(`${BACKEND_URL}/${pathSegments}`);
82
+
83
+ // Forward query parameters
84
+ request.nextUrl.searchParams.forEach((value, key) => {
85
+ backendUrl.searchParams.set(key, value);
86
+ });
87
+
88
+ // Build headers for backend request
89
+ const headers: Record<string, string> = {
90
+ 'Content-Type': 'application/json',
91
+ };
92
+
93
+ // Send the proxy's own origin (not the client-supplied Origin header).
94
+ // The backend's BrowserOriginGuard only checks for presence of Origin/Referer,
95
+ // so forwarding a client-controlled value adds spoofing surface for nothing.
96
+ headers['Origin'] = request.nextUrl.origin;
97
+
98
+ // Forward SDK version header if present
99
+ const sdkVersion = request.headers.get('x-sdk-version');
100
+ if (sdkVersion) {
101
+ headers['X-SDK-Version'] = sdkVersion;
102
+ }
103
+
104
+ // Add auth token from httpOnly cookie
105
+ const cookieStore = await cookies();
106
+ const tokenCookie = cookieStore.get(TOKEN_COOKIE);
107
+ if (tokenCookie?.value) {
108
+ headers['Authorization'] = `Bearer ${tokenCookie.value}`;
109
+ }
110
+
111
+ // Forward request body for non-GET requests
112
+ let body: string | undefined;
113
+ if (method !== 'GET' && method !== 'HEAD') {
114
+ try {
115
+ body = await request.text();
116
+ } catch {
117
+ // No body
118
+ }
119
+ }
120
+
121
+ // Proxy the request to backend
122
+ let backendResponse: Response;
123
+ const abortController = new AbortController();
124
+ const timeoutId = setTimeout(() => abortController.abort(), BACKEND_TIMEOUT_MS);
125
+ try {
126
+ backendResponse = await fetch(backendUrl.toString(), {
127
+ method,
128
+ headers,
129
+ body,
130
+ signal: abortController.signal,
131
+ });
132
+ } catch (error) {
133
+ const isTimeout = (error as Error)?.name === 'AbortError';
134
+ return NextResponse.json(
135
+ { error: isTimeout ? 'Backend request timed out' : 'Backend service unavailable' },
136
+ { status: isTimeout ? 504 : 502 }
137
+ );
138
+ } finally {
139
+ clearTimeout(timeoutId);
140
+ }
141
+
142
+ // Read response body
143
+ const responseText = await backendResponse.text();
144
+
145
+ // For auth endpoints: intercept token, set cookie, strip token from response
146
+ if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
147
+ try {
148
+ const data = JSON.parse(responseText);
149
+ if (data.token) {
150
+ const token = data.token;
151
+
152
+ // Strip token from client response
153
+ const { token: _stripped, ...safeData } = data;
154
+
155
+ const response = NextResponse.json(safeData, {
156
+ status: backendResponse.status,
157
+ });
158
+ setAuthCookies(response, token);
159
+ return response;
160
+ }
161
+ } catch {
162
+ // Not JSON or no token field — pass through
163
+ }
164
+ }
165
+
166
+ // Handle 401 responses: clear auth cookies
167
+ if (backendResponse.status === 401 && tokenCookie?.value) {
168
+ const response = new NextResponse(responseText, {
169
+ status: backendResponse.status,
170
+ headers: {
171
+ 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
172
+ },
173
+ });
174
+ clearAuthCookies(response);
175
+ return response;
176
+ }
177
+
178
+ // Sanitize 5xx responses so backend internals don't leak to the client
179
+ if (backendResponse.status >= 500) {
180
+ console.error(`[proxy] backend ${backendResponse.status} on ${pathSegments}:`, responseText);
181
+ return NextResponse.json(
182
+ { error: 'Backend service error' },
183
+ { status: backendResponse.status }
184
+ );
185
+ }
186
+
187
+ // Pass through response as-is
188
+ return new NextResponse(responseText, {
189
+ status: backendResponse.status,
190
+ headers: {
191
+ 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
192
+ },
193
+ });
194
+ }
195
+
196
+ export async function GET(
197
+ request: NextRequest,
198
+ { params }: { params: Promise<{ path: string[] }> }
199
+ ) {
200
+ return proxyRequest(request, await params);
201
+ }
202
+
203
+ export async function POST(
204
+ request: NextRequest,
205
+ { params }: { params: Promise<{ path: string[] }> }
206
+ ) {
207
+ return proxyRequest(request, await params);
208
+ }
209
+
210
+ export async function PUT(
211
+ request: NextRequest,
212
+ { params }: { params: Promise<{ path: string[] }> }
213
+ ) {
214
+ return proxyRequest(request, await params);
215
+ }
216
+
217
+ export async function PATCH(
218
+ request: NextRequest,
219
+ { params }: { params: Promise<{ path: string[] }> }
220
+ ) {
221
+ return proxyRequest(request, await params);
222
+ }
223
+
224
+ export async function DELETE(
225
+ request: NextRequest,
226
+ { params }: { params: Promise<{ path: string[] }> }
227
+ ) {
228
+ return proxyRequest(request, await params);
229
+ }
@@ -29,6 +29,7 @@ import { ReservationCountdown } from '@/components/cart/reservation-countdown';
29
29
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
30
30
  import { useTranslations } from '@/lib/translations';
31
31
  import { cn } from '@/lib/utils';
32
+ import { isValidCheckoutId } from '@/lib/safe-redirect';
32
33
 
33
34
  type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'custom-fields' | 'payment';
34
35
 
@@ -69,7 +70,8 @@ function CheckoutContent() {
69
70
 
70
71
  // Check for returning from canceled payment
71
72
  const canceled = searchParams.get('canceled') === 'true';
72
- const existingCheckoutId = searchParams.get('checkout_id');
73
+ const checkoutIdParam = searchParams.get('checkout_id');
74
+ const existingCheckoutId = isValidCheckoutId(checkoutIdParam) ? checkoutIdParam : null;
73
75
 
74
76
  // Pre-fill address and customer data from profile when logged in
75
77
  useEffect(() => {
@@ -5,6 +5,7 @@ import { StoreProvider } from '@/providers/store-provider';
5
5
  import { Header } from '@/components/layout/header';
6
6
  import { Footer } from '@/components/layout/footer';
7
7
  import { getDirection, supportedLocales } from '@/i18n';
8
+ import { getNonce } from '@/lib/nonce';
8
9
  import '../globals.css';
9
10
 
10
11
  <%- fontVariable %>
@@ -14,15 +15,15 @@ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
14
15
  export const metadata: Metadata = {
15
16
  metadataBase: new URL(baseUrl),
16
17
  title: {
17
- default: '<%= storeName %>',
18
- template: `%s | <%= storeName %>`,
18
+ default: <%- storeNameJs %>,
19
+ template: <%- titleTemplateJs %>,
19
20
  },
20
- description: '<%= storeName %>',
21
+ description: <%- storeNameJs %>,
21
22
  alternates: {
22
23
  canonical: '/',
23
24
  },
24
25
  openGraph: {
25
- siteName: '<%= storeName %>',
26
+ siteName: <%- storeNameJs %>,
26
27
  type: 'website',
27
28
  },
28
29
  robots: {
@@ -34,7 +35,7 @@ export const metadata: Metadata = {
34
35
  const organizationJsonLd = {
35
36
  '@context': 'https://schema.org',
36
37
  '@type': 'Organization',
37
- name: '<%= storeName %>',
38
+ name: <%- storeNameJs %>,
38
39
  url: baseUrl,
39
40
  };
40
41
 
@@ -51,13 +52,20 @@ export default async function RootLayout({
51
52
  }) {
52
53
  const { locale } = await params;
53
54
  const dir = getDirection(locale);
55
+ const nonce = await getNonce();
54
56
 
55
57
  return (
56
58
  <html lang={locale} dir={dir}>
57
59
  <head>
58
60
  <script
59
61
  type="application/ld+json"
60
- dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
62
+ nonce={nonce}
63
+ dangerouslySetInnerHTML={{
64
+ __html: JSON.stringify(organizationJsonLd)
65
+ .replace(/</g, '\\u003c')
66
+ .replace(/>/g, '\\u003e')
67
+ .replace(/&/g, '\\u0026'),
68
+ }}
61
69
  />
62
70
  </head>
63
71
  <body className={font.className}>
@@ -78,6 +86,7 @@ import type { Metadata } from 'next';
78
86
  import { StoreProvider } from '@/providers/store-provider';
79
87
  import { Header } from '@/components/layout/header';
80
88
  import { Footer } from '@/components/layout/footer';
89
+ import { getNonce } from '@/lib/nonce';
81
90
  import './globals.css';
82
91
 
83
92
  <%- fontVariable %>
@@ -87,15 +96,15 @@ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
87
96
  export const metadata: Metadata = {
88
97
  metadataBase: new URL(baseUrl),
89
98
  title: {
90
- default: '<%= storeName %>',
91
- template: `%s | <%= storeName %>`,
99
+ default: <%- storeNameJs %>,
100
+ template: <%- titleTemplateJs %>,
92
101
  },
93
- description: '<%= storeName %>',
102
+ description: <%- storeNameJs %>,
94
103
  alternates: {
95
104
  canonical: '/',
96
105
  },
97
106
  openGraph: {
98
- siteName: '<%= storeName %>',
107
+ siteName: <%- storeNameJs %>,
99
108
  locale: '<%= ogLocale %>',
100
109
  type: 'website',
101
110
  },
@@ -108,21 +117,28 @@ export const metadata: Metadata = {
108
117
  const organizationJsonLd = {
109
118
  '@context': 'https://schema.org',
110
119
  '@type': 'Organization',
111
- name: '<%= storeName %>',
120
+ name: <%- storeNameJs %>,
112
121
  url: baseUrl,
113
122
  };
114
123
 
115
- export default function RootLayout({
124
+ export default async function RootLayout({
116
125
  children,
117
126
  }: {
118
127
  children: React.ReactNode;
119
128
  }) {
129
+ const nonce = await getNonce();
120
130
  return (
121
131
  <html lang="<%= language %>" dir="<%= direction %>">
122
132
  <head>
123
133
  <script
124
134
  type="application/ld+json"
125
- dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
135
+ nonce={nonce}
136
+ dangerouslySetInnerHTML={{
137
+ __html: JSON.stringify(organizationJsonLd)
138
+ .replace(/</g, '\\u003c')
139
+ .replace(/>/g, '\\u003e')
140
+ .replace(/&/g, '\\u0026'),
141
+ }}
126
142
  />
127
143
  </head>
128
144
  <body className={font.className}>