create-brainerce-store 1.87.0 → 1.88.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.
- package/dist/index.js +9 -2
- package/messages/en.json +10 -1
- package/messages/he.json +10 -1
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +296 -296
- package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
- package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
- package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
- package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
- package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -121
- package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
- package/templates/nextjs/base/src/app/products/layout.tsx +0 -18
|
@@ -1,296 +1,296 @@
|
|
|
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 '@/core/lib/csrf';
|
|
9
|
-
import { getRequestOrigin } from '@/core/lib/site-url';
|
|
10
|
-
|
|
11
|
-
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
12
|
-
/\/$/,
|
|
13
|
-
''
|
|
14
|
-
);
|
|
15
|
-
|
|
16
|
-
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
17
|
-
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
18
|
-
|
|
19
|
-
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
20
|
-
const BACKEND_TIMEOUT_MS = 15_000;
|
|
21
|
-
|
|
22
|
-
/** Auth endpoints whose responses contain tokens to intercept */
|
|
23
|
-
const AUTH_ENDPOINTS = ['customers/login', 'customers/register', 'customers/verify-email'];
|
|
24
|
-
|
|
25
|
-
function isAuthEndpoint(path: string): boolean {
|
|
26
|
-
return AUTH_ENDPOINTS.some((ep) => path.endsWith(ep));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function isSafePathSegment(segment: string): boolean {
|
|
30
|
-
if (!segment) return false;
|
|
31
|
-
if (segment === '.' || segment === '..') return false;
|
|
32
|
-
if (segment.includes('/') || segment.includes('\\')) return false;
|
|
33
|
-
if (segment.includes('\0')) return false;
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function isSecure(): boolean {
|
|
38
|
-
return process.env.NODE_ENV === 'production';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Reads a fetch `Response` body as text, self-healing an observed runtime bug:
|
|
43
|
-
* on Next.js 15.5.26, a `fetch()` made from a Route Handler or Server
|
|
44
|
-
* Component can return a Response whose body is still gzip-compressed bytes,
|
|
45
|
-
* even though an identical request (same URL, same headers) made via plain
|
|
46
|
-
* Node `fetch()` outside Next decompresses correctly — confirmed by diffing
|
|
47
|
-
* the raw response bytes both ways. The exact point inside Next's
|
|
48
|
-
* server-side fetch instrumentation that causes this isn't confirmed, only
|
|
49
|
-
* the externally observable symptom is. Calling `.text()` on a Response like
|
|
50
|
-
* that UTF-8-decodes still-gzipped bytes — lossy (replacement characters)
|
|
51
|
-
* and unparseable as JSON.
|
|
52
|
-
*
|
|
53
|
-
* A correctly-behaving fetch (browsers; plain Node; this bug's absence) never
|
|
54
|
-
* hands back bytes that still start with gzip's magic number (`1f 8b`) — real
|
|
55
|
-
* compression has already been undone by the time a body reaches here — so
|
|
56
|
-
* checking for it before ever decoding as text can't misfire into
|
|
57
|
-
* double-decompressing an already-plain body.
|
|
58
|
-
*
|
|
59
|
-
* This only works because the backend request below pins `Accept-Encoding:
|
|
60
|
-
* gzip` instead of the runtime default (which also offers Brotli) — Brotli
|
|
61
|
-
* has no reliable magic-number signature and no Web Streams API decoder, so
|
|
62
|
-
* this self-healing has no fallback for it. That header is
|
|
63
|
-
* correctness-critical here, not a tuning choice: reverting it to the
|
|
64
|
-
* runtime default reintroduces this exact corruption, just for
|
|
65
|
-
* Brotli-compressed responses instead of gzip ones.
|
|
66
|
-
*/
|
|
67
|
-
async function readBackendBody(response: Response): Promise<string> {
|
|
68
|
-
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
69
|
-
if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
|
70
|
-
const decompressed = new Blob([buffer])
|
|
71
|
-
.stream()
|
|
72
|
-
.pipeThrough(new DecompressionStream('gzip'));
|
|
73
|
-
return new TextDecoder('utf-8').decode(await new Response(decompressed).arrayBuffer());
|
|
74
|
-
}
|
|
75
|
-
return new TextDecoder('utf-8').decode(buffer);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function setAuthCookies(response: NextResponse, token: string): void {
|
|
79
|
-
response.cookies.set(TOKEN_COOKIE, token, {
|
|
80
|
-
httpOnly: true,
|
|
81
|
-
secure: isSecure(),
|
|
82
|
-
sameSite: 'lax',
|
|
83
|
-
path: '/',
|
|
84
|
-
maxAge: COOKIE_MAX_AGE,
|
|
85
|
-
});
|
|
86
|
-
response.cookies.set(LOGGED_IN_COOKIE, '1', {
|
|
87
|
-
httpOnly: false,
|
|
88
|
-
secure: isSecure(),
|
|
89
|
-
sameSite: 'lax',
|
|
90
|
-
path: '/',
|
|
91
|
-
maxAge: COOKIE_MAX_AGE,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function clearAuthCookies(response: NextResponse): void {
|
|
96
|
-
response.cookies.delete(TOKEN_COOKIE);
|
|
97
|
-
response.cookies.delete(LOGGED_IN_COOKIE);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function proxyRequest(
|
|
101
|
-
request: NextRequest,
|
|
102
|
-
params: { path: string[] }
|
|
103
|
-
): Promise<NextResponse> {
|
|
104
|
-
const method = request.method;
|
|
105
|
-
|
|
106
|
-
// Reject path-traversal attempts before constructing the backend URL
|
|
107
|
-
if (!params.path.every(isSafePathSegment)) {
|
|
108
|
-
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// CSRF protection for mutating requests
|
|
112
|
-
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
113
|
-
const csrfError = checkCsrf(request);
|
|
114
|
-
if (csrfError) return csrfError;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Build backend URL from path segments
|
|
118
|
-
const pathSegments = params.path.join('/');
|
|
119
|
-
const backendUrl = new URL(`${BACKEND_URL}/${pathSegments}`);
|
|
120
|
-
|
|
121
|
-
// Forward query parameters
|
|
122
|
-
request.nextUrl.searchParams.forEach((value, key) => {
|
|
123
|
-
backendUrl.searchParams.set(key, value);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// Build headers for backend request. Preserve the incoming Content-Type
|
|
127
|
-
// (including the multipart boundary for file uploads); default to JSON
|
|
128
|
-
// when absent. Overriding the Content-Type would strip the boundary and
|
|
129
|
-
// corrupt multipart uploads on the backend.
|
|
130
|
-
const incomingContentType = request.headers.get('content-type');
|
|
131
|
-
const headers: Record<string, string> = {
|
|
132
|
-
'Content-Type': incomingContentType || 'application/json',
|
|
133
|
-
// Pinned to gzip (not the runtime default `gzip, deflate, br`) so
|
|
134
|
-
// readBackendBody()'s self-healing only has to reason about one wire
|
|
135
|
-
// format — see the comment there.
|
|
136
|
-
'Accept-Encoding': 'gzip',
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
// Send the proxy's own origin, never the client-supplied Origin header —
|
|
140
|
-
// forwarding a client-controlled value would just hand a caller the ability
|
|
141
|
-
// to pick which storefront the backend thinks it is talking to.
|
|
142
|
-
//
|
|
143
|
-
// ⛔ The backend READS this value: a LIVE sales channel accepts only the
|
|
144
|
-
// domain configured on the connection (exact host or a subdomain of it) plus
|
|
145
|
-
// any additional allowed origins. It is not a presence check. So this header
|
|
146
|
-
// has to carry the storefront's PUBLIC origin.
|
|
147
|
-
//
|
|
148
|
-
// `request.nextUrl.origin` alone is not that origin: behind a reverse proxy
|
|
149
|
-
// it can be an internal address (`http://localhost:3000`, a container host),
|
|
150
|
-
// and every storefront call then fails on a Live connection. A TEST
|
|
151
|
-
// connection with no domain configured accepts anything, which is why this
|
|
152
|
-
// used to surface only at go-live. `getRequestOrigin` prefers the forwarded
|
|
153
|
-
// host the browser actually used and falls back to configuration.
|
|
154
|
-
headers['Origin'] = await getRequestOrigin(request.headers);
|
|
155
|
-
|
|
156
|
-
// Forward SDK version header if present
|
|
157
|
-
const sdkVersion = request.headers.get('x-sdk-version');
|
|
158
|
-
if (sdkVersion) {
|
|
159
|
-
headers['X-SDK-Version'] = sdkVersion;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Forward Accept-Language so the backend locale middleware can resolve translations
|
|
163
|
-
const acceptLanguage = request.headers.get('accept-language');
|
|
164
|
-
if (acceptLanguage) {
|
|
165
|
-
headers['Accept-Language'] = acceptLanguage;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Add auth token from httpOnly cookie
|
|
169
|
-
const cookieStore = await cookies();
|
|
170
|
-
const tokenCookie = cookieStore.get(TOKEN_COOKIE);
|
|
171
|
-
if (tokenCookie?.value) {
|
|
172
|
-
headers['Authorization'] = `Bearer ${tokenCookie.value}`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Forward request body for non-GET requests. Use ArrayBuffer so multipart
|
|
176
|
-
// bodies (file uploads) pass through unchanged — `request.text()` works for
|
|
177
|
-
// JSON but would break binary content.
|
|
178
|
-
const isMultipart = incomingContentType?.includes('multipart/form-data') ?? false;
|
|
179
|
-
let body: ArrayBuffer | string | undefined;
|
|
180
|
-
if (method !== 'GET' && method !== 'HEAD') {
|
|
181
|
-
try {
|
|
182
|
-
body = isMultipart ? await request.arrayBuffer() : await request.text();
|
|
183
|
-
} catch {
|
|
184
|
-
// No body
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Proxy the request to backend
|
|
189
|
-
let backendResponse: Response;
|
|
190
|
-
const abortController = new AbortController();
|
|
191
|
-
const timeoutId = setTimeout(() => abortController.abort(), BACKEND_TIMEOUT_MS);
|
|
192
|
-
try {
|
|
193
|
-
backendResponse = await fetch(backendUrl.toString(), {
|
|
194
|
-
method,
|
|
195
|
-
headers,
|
|
196
|
-
body,
|
|
197
|
-
signal: abortController.signal,
|
|
198
|
-
});
|
|
199
|
-
} catch (error) {
|
|
200
|
-
const isTimeout = (error as Error)?.name === 'AbortError';
|
|
201
|
-
return NextResponse.json(
|
|
202
|
-
{ error: isTimeout ? 'Backend request timed out' : 'Backend service unavailable' },
|
|
203
|
-
{ status: isTimeout ? 504 : 502 }
|
|
204
|
-
);
|
|
205
|
-
} finally {
|
|
206
|
-
clearTimeout(timeoutId);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Read response body
|
|
210
|
-
const responseText = await readBackendBody(backendResponse);
|
|
211
|
-
|
|
212
|
-
// For auth endpoints: intercept token, set cookie, strip token from response
|
|
213
|
-
if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
|
|
214
|
-
try {
|
|
215
|
-
const data = JSON.parse(responseText);
|
|
216
|
-
if (data.token) {
|
|
217
|
-
const token = data.token;
|
|
218
|
-
|
|
219
|
-
// Strip token from client response
|
|
220
|
-
const { token: _stripped, ...safeData } = data;
|
|
221
|
-
|
|
222
|
-
const response = NextResponse.json(safeData, {
|
|
223
|
-
status: backendResponse.status,
|
|
224
|
-
});
|
|
225
|
-
setAuthCookies(response, token);
|
|
226
|
-
return response;
|
|
227
|
-
}
|
|
228
|
-
} catch {
|
|
229
|
-
// Not JSON or no token field — pass through
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Handle 401 responses: clear auth cookies
|
|
234
|
-
if (backendResponse.status === 401 && tokenCookie?.value) {
|
|
235
|
-
const response = new NextResponse(responseText, {
|
|
236
|
-
status: backendResponse.status,
|
|
237
|
-
headers: {
|
|
238
|
-
'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
|
|
239
|
-
},
|
|
240
|
-
});
|
|
241
|
-
clearAuthCookies(response);
|
|
242
|
-
return response;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Sanitize 5xx responses so backend internals don't leak to the client
|
|
246
|
-
if (backendResponse.status >= 500) {
|
|
247
|
-
console.error(`[proxy] backend ${backendResponse.status} on ${pathSegments}:`, responseText);
|
|
248
|
-
return NextResponse.json(
|
|
249
|
-
{ error: 'Backend service error' },
|
|
250
|
-
{ status: backendResponse.status }
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Pass through response as-is
|
|
255
|
-
return new NextResponse(responseText, {
|
|
256
|
-
status: backendResponse.status,
|
|
257
|
-
headers: {
|
|
258
|
-
'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
|
|
259
|
-
},
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export async function GET(
|
|
264
|
-
request: NextRequest,
|
|
265
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
266
|
-
) {
|
|
267
|
-
return proxyRequest(request, await params);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export async function POST(
|
|
271
|
-
request: NextRequest,
|
|
272
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
273
|
-
) {
|
|
274
|
-
return proxyRequest(request, await params);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export async function PUT(
|
|
278
|
-
request: NextRequest,
|
|
279
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
280
|
-
) {
|
|
281
|
-
return proxyRequest(request, await params);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export async function PATCH(
|
|
285
|
-
request: NextRequest,
|
|
286
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
287
|
-
) {
|
|
288
|
-
return proxyRequest(request, await params);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export async function DELETE(
|
|
292
|
-
request: NextRequest,
|
|
293
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
294
|
-
) {
|
|
295
|
-
return proxyRequest(request, await params);
|
|
296
|
-
}
|
|
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 '@/core/lib/csrf';
|
|
9
|
+
import { getRequestOrigin } from '@/core/lib/site-url';
|
|
10
|
+
|
|
11
|
+
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
12
|
+
/\/$/,
|
|
13
|
+
''
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
17
|
+
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
18
|
+
|
|
19
|
+
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
20
|
+
const BACKEND_TIMEOUT_MS = 15_000;
|
|
21
|
+
|
|
22
|
+
/** Auth endpoints whose responses contain tokens to intercept */
|
|
23
|
+
const AUTH_ENDPOINTS = ['customers/login', 'customers/register', 'customers/verify-email'];
|
|
24
|
+
|
|
25
|
+
function isAuthEndpoint(path: string): boolean {
|
|
26
|
+
return AUTH_ENDPOINTS.some((ep) => path.endsWith(ep));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isSafePathSegment(segment: string): boolean {
|
|
30
|
+
if (!segment) return false;
|
|
31
|
+
if (segment === '.' || segment === '..') return false;
|
|
32
|
+
if (segment.includes('/') || segment.includes('\\')) return false;
|
|
33
|
+
if (segment.includes('\0')) return false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isSecure(): boolean {
|
|
38
|
+
return process.env.NODE_ENV === 'production';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reads a fetch `Response` body as text, self-healing an observed runtime bug:
|
|
43
|
+
* on Next.js 15.5.26, a `fetch()` made from a Route Handler or Server
|
|
44
|
+
* Component can return a Response whose body is still gzip-compressed bytes,
|
|
45
|
+
* even though an identical request (same URL, same headers) made via plain
|
|
46
|
+
* Node `fetch()` outside Next decompresses correctly — confirmed by diffing
|
|
47
|
+
* the raw response bytes both ways. The exact point inside Next's
|
|
48
|
+
* server-side fetch instrumentation that causes this isn't confirmed, only
|
|
49
|
+
* the externally observable symptom is. Calling `.text()` on a Response like
|
|
50
|
+
* that UTF-8-decodes still-gzipped bytes — lossy (replacement characters)
|
|
51
|
+
* and unparseable as JSON.
|
|
52
|
+
*
|
|
53
|
+
* A correctly-behaving fetch (browsers; plain Node; this bug's absence) never
|
|
54
|
+
* hands back bytes that still start with gzip's magic number (`1f 8b`) — real
|
|
55
|
+
* compression has already been undone by the time a body reaches here — so
|
|
56
|
+
* checking for it before ever decoding as text can't misfire into
|
|
57
|
+
* double-decompressing an already-plain body.
|
|
58
|
+
*
|
|
59
|
+
* This only works because the backend request below pins `Accept-Encoding:
|
|
60
|
+
* gzip` instead of the runtime default (which also offers Brotli) — Brotli
|
|
61
|
+
* has no reliable magic-number signature and no Web Streams API decoder, so
|
|
62
|
+
* this self-healing has no fallback for it. That header is
|
|
63
|
+
* correctness-critical here, not a tuning choice: reverting it to the
|
|
64
|
+
* runtime default reintroduces this exact corruption, just for
|
|
65
|
+
* Brotli-compressed responses instead of gzip ones.
|
|
66
|
+
*/
|
|
67
|
+
async function readBackendBody(response: Response): Promise<string> {
|
|
68
|
+
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
69
|
+
if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
|
70
|
+
const decompressed = new Blob([buffer])
|
|
71
|
+
.stream()
|
|
72
|
+
.pipeThrough(new DecompressionStream('gzip'));
|
|
73
|
+
return new TextDecoder('utf-8').decode(await new Response(decompressed).arrayBuffer());
|
|
74
|
+
}
|
|
75
|
+
return new TextDecoder('utf-8').decode(buffer);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setAuthCookies(response: NextResponse, token: string): void {
|
|
79
|
+
response.cookies.set(TOKEN_COOKIE, token, {
|
|
80
|
+
httpOnly: true,
|
|
81
|
+
secure: isSecure(),
|
|
82
|
+
sameSite: 'lax',
|
|
83
|
+
path: '/',
|
|
84
|
+
maxAge: COOKIE_MAX_AGE,
|
|
85
|
+
});
|
|
86
|
+
response.cookies.set(LOGGED_IN_COOKIE, '1', {
|
|
87
|
+
httpOnly: false,
|
|
88
|
+
secure: isSecure(),
|
|
89
|
+
sameSite: 'lax',
|
|
90
|
+
path: '/',
|
|
91
|
+
maxAge: COOKIE_MAX_AGE,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clearAuthCookies(response: NextResponse): void {
|
|
96
|
+
response.cookies.delete(TOKEN_COOKIE);
|
|
97
|
+
response.cookies.delete(LOGGED_IN_COOKIE);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function proxyRequest(
|
|
101
|
+
request: NextRequest,
|
|
102
|
+
params: { path: string[] }
|
|
103
|
+
): Promise<NextResponse> {
|
|
104
|
+
const method = request.method;
|
|
105
|
+
|
|
106
|
+
// Reject path-traversal attempts before constructing the backend URL
|
|
107
|
+
if (!params.path.every(isSafePathSegment)) {
|
|
108
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// CSRF protection for mutating requests
|
|
112
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
113
|
+
const csrfError = checkCsrf(request);
|
|
114
|
+
if (csrfError) return csrfError;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Build backend URL from path segments
|
|
118
|
+
const pathSegments = params.path.join('/');
|
|
119
|
+
const backendUrl = new URL(`${BACKEND_URL}/${pathSegments}`);
|
|
120
|
+
|
|
121
|
+
// Forward query parameters
|
|
122
|
+
request.nextUrl.searchParams.forEach((value, key) => {
|
|
123
|
+
backendUrl.searchParams.set(key, value);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Build headers for backend request. Preserve the incoming Content-Type
|
|
127
|
+
// (including the multipart boundary for file uploads); default to JSON
|
|
128
|
+
// when absent. Overriding the Content-Type would strip the boundary and
|
|
129
|
+
// corrupt multipart uploads on the backend.
|
|
130
|
+
const incomingContentType = request.headers.get('content-type');
|
|
131
|
+
const headers: Record<string, string> = {
|
|
132
|
+
'Content-Type': incomingContentType || 'application/json',
|
|
133
|
+
// Pinned to gzip (not the runtime default `gzip, deflate, br`) so
|
|
134
|
+
// readBackendBody()'s self-healing only has to reason about one wire
|
|
135
|
+
// format — see the comment there.
|
|
136
|
+
'Accept-Encoding': 'gzip',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Send the proxy's own origin, never the client-supplied Origin header —
|
|
140
|
+
// forwarding a client-controlled value would just hand a caller the ability
|
|
141
|
+
// to pick which storefront the backend thinks it is talking to.
|
|
142
|
+
//
|
|
143
|
+
// ⛔ The backend READS this value: a LIVE sales channel accepts only the
|
|
144
|
+
// domain configured on the connection (exact host or a subdomain of it) plus
|
|
145
|
+
// any additional allowed origins. It is not a presence check. So this header
|
|
146
|
+
// has to carry the storefront's PUBLIC origin.
|
|
147
|
+
//
|
|
148
|
+
// `request.nextUrl.origin` alone is not that origin: behind a reverse proxy
|
|
149
|
+
// it can be an internal address (`http://localhost:3000`, a container host),
|
|
150
|
+
// and every storefront call then fails on a Live connection. A TEST
|
|
151
|
+
// connection with no domain configured accepts anything, which is why this
|
|
152
|
+
// used to surface only at go-live. `getRequestOrigin` prefers the forwarded
|
|
153
|
+
// host the browser actually used and falls back to configuration.
|
|
154
|
+
headers['Origin'] = await getRequestOrigin(request.headers);
|
|
155
|
+
|
|
156
|
+
// Forward SDK version header if present
|
|
157
|
+
const sdkVersion = request.headers.get('x-sdk-version');
|
|
158
|
+
if (sdkVersion) {
|
|
159
|
+
headers['X-SDK-Version'] = sdkVersion;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Forward Accept-Language so the backend locale middleware can resolve translations
|
|
163
|
+
const acceptLanguage = request.headers.get('accept-language');
|
|
164
|
+
if (acceptLanguage) {
|
|
165
|
+
headers['Accept-Language'] = acceptLanguage;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Add auth token from httpOnly cookie
|
|
169
|
+
const cookieStore = await cookies();
|
|
170
|
+
const tokenCookie = cookieStore.get(TOKEN_COOKIE);
|
|
171
|
+
if (tokenCookie?.value) {
|
|
172
|
+
headers['Authorization'] = `Bearer ${tokenCookie.value}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Forward request body for non-GET requests. Use ArrayBuffer so multipart
|
|
176
|
+
// bodies (file uploads) pass through unchanged — `request.text()` works for
|
|
177
|
+
// JSON but would break binary content.
|
|
178
|
+
const isMultipart = incomingContentType?.includes('multipart/form-data') ?? false;
|
|
179
|
+
let body: ArrayBuffer | string | undefined;
|
|
180
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
181
|
+
try {
|
|
182
|
+
body = isMultipart ? await request.arrayBuffer() : await request.text();
|
|
183
|
+
} catch {
|
|
184
|
+
// No body
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Proxy the request to backend
|
|
189
|
+
let backendResponse: Response;
|
|
190
|
+
const abortController = new AbortController();
|
|
191
|
+
const timeoutId = setTimeout(() => abortController.abort(), BACKEND_TIMEOUT_MS);
|
|
192
|
+
try {
|
|
193
|
+
backendResponse = await fetch(backendUrl.toString(), {
|
|
194
|
+
method,
|
|
195
|
+
headers,
|
|
196
|
+
body,
|
|
197
|
+
signal: abortController.signal,
|
|
198
|
+
});
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const isTimeout = (error as Error)?.name === 'AbortError';
|
|
201
|
+
return NextResponse.json(
|
|
202
|
+
{ error: isTimeout ? 'Backend request timed out' : 'Backend service unavailable' },
|
|
203
|
+
{ status: isTimeout ? 504 : 502 }
|
|
204
|
+
);
|
|
205
|
+
} finally {
|
|
206
|
+
clearTimeout(timeoutId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Read response body
|
|
210
|
+
const responseText = await readBackendBody(backendResponse);
|
|
211
|
+
|
|
212
|
+
// For auth endpoints: intercept token, set cookie, strip token from response
|
|
213
|
+
if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
|
|
214
|
+
try {
|
|
215
|
+
const data = JSON.parse(responseText);
|
|
216
|
+
if (data.token) {
|
|
217
|
+
const token = data.token;
|
|
218
|
+
|
|
219
|
+
// Strip token from client response
|
|
220
|
+
const { token: _stripped, ...safeData } = data;
|
|
221
|
+
|
|
222
|
+
const response = NextResponse.json(safeData, {
|
|
223
|
+
status: backendResponse.status,
|
|
224
|
+
});
|
|
225
|
+
setAuthCookies(response, token);
|
|
226
|
+
return response;
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Not JSON or no token field — pass through
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Handle 401 responses: clear auth cookies
|
|
234
|
+
if (backendResponse.status === 401 && tokenCookie?.value) {
|
|
235
|
+
const response = new NextResponse(responseText, {
|
|
236
|
+
status: backendResponse.status,
|
|
237
|
+
headers: {
|
|
238
|
+
'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
clearAuthCookies(response);
|
|
242
|
+
return response;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Sanitize 5xx responses so backend internals don't leak to the client
|
|
246
|
+
if (backendResponse.status >= 500) {
|
|
247
|
+
console.error(`[proxy] backend ${backendResponse.status} on ${pathSegments}:`, responseText);
|
|
248
|
+
return NextResponse.json(
|
|
249
|
+
{ error: 'Backend service error' },
|
|
250
|
+
{ status: backendResponse.status }
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Pass through response as-is
|
|
255
|
+
return new NextResponse(responseText, {
|
|
256
|
+
status: backendResponse.status,
|
|
257
|
+
headers: {
|
|
258
|
+
'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function GET(
|
|
264
|
+
request: NextRequest,
|
|
265
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
266
|
+
) {
|
|
267
|
+
return proxyRequest(request, await params);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function POST(
|
|
271
|
+
request: NextRequest,
|
|
272
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
273
|
+
) {
|
|
274
|
+
return proxyRequest(request, await params);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function PUT(
|
|
278
|
+
request: NextRequest,
|
|
279
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
280
|
+
) {
|
|
281
|
+
return proxyRequest(request, await params);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function PATCH(
|
|
285
|
+
request: NextRequest,
|
|
286
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
287
|
+
) {
|
|
288
|
+
return proxyRequest(request, await params);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function DELETE(
|
|
292
|
+
request: NextRequest,
|
|
293
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
294
|
+
) {
|
|
295
|
+
return proxyRequest(request, await params);
|
|
296
|
+
}
|