create-brainerce-store 1.27.5 → 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 (35) hide show
  1. package/dist/index.js +95 -22
  2. package/messages/en.json +12 -1
  3. package/messages/he.json +12 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.env.local.ejs +3 -3
  6. package/templates/nextjs/base/next.config.ts +13 -12
  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 +975 -972
  13. package/templates/nextjs/base/src/app/layout.tsx.ejs +29 -13
  14. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +271 -271
  15. package/templates/nextjs/base/src/app/payment-complete/page.tsx +59 -59
  16. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +501 -486
  17. package/templates/nextjs/base/src/app/products/page.tsx +475 -475
  18. package/templates/nextjs/base/src/app/reset-password/page.tsx +138 -131
  19. package/templates/nextjs/base/src/components/auth/register-form.tsx +245 -232
  20. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +416 -415
  21. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +258 -184
  22. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +84 -20
  23. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +86 -72
  24. package/templates/nextjs/base/src/lib/csrf.ts +11 -0
  25. package/templates/nextjs/base/src/lib/navigation.tsx.ejs +60 -60
  26. package/templates/nextjs/base/src/lib/nonce.ts +10 -0
  27. package/templates/nextjs/base/src/lib/safe-redirect.ts +45 -0
  28. package/templates/nextjs/base/src/lib/sanitize-html.ts +93 -0
  29. package/templates/nextjs/base/src/lib/validation.ts +37 -0
  30. package/templates/nextjs/base/src/middleware.ts.ejs +91 -8
  31. package/templates/nextjs/base/tsconfig.tsbuildinfo +1 -0
  32. package/templates/nextjs/themes/luxury/globals.css +399 -399
  33. package/templates/nextjs/themes/luxury/theme.json +23 -23
  34. package/templates/nextjs/themes/playful/globals.css +400 -400
  35. package/templates/nextjs/themes/playful/theme.json +23 -23
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.27.5",
34
+ version: "1.28.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  bin: {
37
37
  "create-brainerce-store": "dist/index.js"
@@ -96,6 +96,9 @@ function validateConnectionId(id) {
96
96
  if (id.length < 6) {
97
97
  return "Connection ID is too short";
98
98
  }
99
+ if (id.length > 100) {
100
+ return "Connection ID is too long (max 100 characters)";
101
+ }
99
102
  if (!/^vc_[a-zA-Z0-9]+$/.test(id)) {
100
103
  return "Connection ID contains invalid characters";
101
104
  }
@@ -119,6 +122,23 @@ function validateProjectName(name) {
119
122
  }
120
123
  return null;
121
124
  }
125
+ function validateApiUrl(url) {
126
+ if (!url) return null;
127
+ let parsed;
128
+ try {
129
+ parsed = new URL(url);
130
+ } catch {
131
+ return `Invalid --api-url "${url}": not a valid URL`;
132
+ }
133
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
134
+ return `Invalid --api-url "${url}": protocol must be http(s)`;
135
+ }
136
+ const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname.endsWith(".localhost");
137
+ if (parsed.protocol === "http:" && !isLocal) {
138
+ return `Invalid --api-url "${url}": http:// is only allowed for localhost`;
139
+ }
140
+ return null;
141
+ }
122
142
 
123
143
  // src/utils/package-manager.ts
124
144
  var import_child_process = require("child_process");
@@ -138,14 +158,17 @@ function detectPackageManager() {
138
158
  }
139
159
  return "npm";
140
160
  }
161
+ var ALLOWED_PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
141
162
  async function installDependencies(projectDir, pkgManager) {
163
+ if (!ALLOWED_PACKAGE_MANAGERS.includes(pkgManager)) {
164
+ throw new Error(`Unsupported package manager: ${pkgManager}`);
165
+ }
142
166
  return new Promise((resolve, reject) => {
143
- const { spawn } = require("child_process");
167
+ const cmd = process.platform === "win32" ? `${pkgManager}.cmd` : pkgManager;
144
168
  const args = pkgManager === "yarn" ? [] : ["install"];
145
- const child = spawn(pkgManager, args, {
169
+ const child = (0, import_child_process.spawn)(cmd, args, {
146
170
  cwd: projectDir,
147
- stdio: "ignore",
148
- shell: true
171
+ stdio: "ignore"
149
172
  });
150
173
  child.on("close", (code) => {
151
174
  if (code === 0) {
@@ -267,6 +290,19 @@ var import_ejs = __toESM(require("ejs"));
267
290
  function getDirection(language) {
268
291
  return language === "he" ? "rtl" : "ltr";
269
292
  }
293
+ function stripControlChars(value) {
294
+ return value.replace(/\p{Cc}/gu, "");
295
+ }
296
+ function toJsStringLiteral(value) {
297
+ return JSON.stringify(value);
298
+ }
299
+ function toEnvLiteral(value) {
300
+ const clean = stripControlChars(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$");
301
+ return `"${clean}"`;
302
+ }
303
+ function isValidLocale(locale) {
304
+ return typeof locale === "string" && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$/.test(locale);
305
+ }
270
306
  function getFontConfig(language, theme) {
271
307
  const isHebrew = language === "he";
272
308
  if (theme === "luxury") {
@@ -312,20 +348,30 @@ async function scaffold(options) {
312
348
  const direction = getDirection(options.language);
313
349
  const fontConfig = getFontConfig(options.language, theme);
314
350
  const ogLocale = options.language === "he" ? "he_IL" : "en_US";
315
- const isMultiLocale = options.i18n?.enabled === true && options.i18n.supportedLocales.length > 1;
316
- const supportedLocales = options.i18n?.supportedLocales || [options.language];
317
- const defaultLocale = options.i18n?.defaultLocale || options.language;
351
+ const rawSupportedLocales = options.i18n?.supportedLocales || [options.language];
352
+ const supportedLocales = rawSupportedLocales.filter(isValidLocale);
353
+ if (supportedLocales.length === 0) {
354
+ throw new Error("No valid locales provided");
355
+ }
356
+ const rawDefaultLocale = options.i18n?.defaultLocale || options.language;
357
+ const defaultLocale = isValidLocale(rawDefaultLocale) ? rawDefaultLocale : options.language;
358
+ const isMultiLocale = options.i18n?.enabled === true && supportedLocales.length > 1;
359
+ const cleanStoreName = stripControlChars(options.storeName);
360
+ const cleanCurrency = stripControlChars(options.currency);
361
+ const cleanApiBaseUrl = stripControlChars(options.apiBaseUrl || "https://api.brainerce.com");
318
362
  const templateVars = {
319
363
  projectName: options.projectName,
320
364
  connectionId: options.connectionId,
321
- storeName: options.storeName,
322
- currency: options.currency,
365
+ storeNameJs: toJsStringLiteral(cleanStoreName),
366
+ titleTemplateJs: toJsStringLiteral(`%s | ${cleanStoreName}`),
367
+ storeNameEnv: toEnvLiteral(cleanStoreName),
368
+ currencyEnv: toEnvLiteral(cleanCurrency),
369
+ apiBaseUrlEnv: toEnvLiteral(cleanApiBaseUrl),
323
370
  language: options.language,
324
371
  direction,
325
372
  fontImport: fontConfig.fontImport,
326
373
  fontVariable: fontConfig.fontVariable,
327
374
  ogLocale,
328
- apiBaseUrl: options.apiBaseUrl || "https://api.brainerce.com",
329
375
  i18nEnabled: isMultiLocale,
330
376
  supportedLocales: JSON.stringify(supportedLocales),
331
377
  defaultLocale
@@ -355,20 +401,11 @@ async function scaffold(options) {
355
401
  const appDir = import_path.default.join(targetDir, "src", "app");
356
402
  const localeDir = import_path.default.join(appDir, "[locale]");
357
403
  await import_fs_extra.default.ensureDir(localeDir);
358
- const keepInAppRoot = /* @__PURE__ */ new Set([
359
- "globals.css",
360
- "robots.ts",
361
- "sitemap.ts",
362
- "api",
363
- ".well-known"
364
- ]);
404
+ const keepInAppRoot = /* @__PURE__ */ new Set(["globals.css", "robots.ts", "sitemap.ts", "api", ".well-known"]);
365
405
  const appEntries = await import_fs_extra.default.readdir(appDir, { withFileTypes: true });
366
406
  for (const entry of appEntries) {
367
407
  if (keepInAppRoot.has(entry.name) || entry.name === "[locale]") continue;
368
- await import_fs_extra.default.move(
369
- import_path.default.join(appDir, entry.name),
370
- import_path.default.join(localeDir, entry.name)
371
- );
408
+ await import_fs_extra.default.move(import_path.default.join(appDir, entry.name), import_path.default.join(localeDir, entry.name));
372
409
  }
373
410
  }
374
411
  if (await import_fs_extra.default.pathExists(themeDir)) {
@@ -597,11 +634,47 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
597
634
  }
598
635
  let connectionId = options.connectionId;
599
636
  const explicitApiUrl = (options.apiUrl || process.env.BRAINERCE_API_URL || "").replace(/\/$/, "");
637
+ const apiUrlError = validateApiUrl(explicitApiUrl);
638
+ if (apiUrlError) {
639
+ logger.error(apiUrlError);
640
+ process.exit(1);
641
+ }
642
+ if (explicitApiUrl && explicitApiUrl !== KNOWN_API_URLS.production && explicitApiUrl !== KNOWN_API_URLS.staging) {
643
+ logger.warn(
644
+ `Using non-standard API URL: ${explicitApiUrl} \u2014 make sure you trust this endpoint.`
645
+ );
646
+ }
600
647
  const candidateApiUrls = explicitApiUrl ? [explicitApiUrl] : [KNOWN_API_URLS.production, KNOWN_API_URLS.staging];
648
+ const VALID_LANGUAGES = ["en", "he"];
649
+ const VALID_PKG_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
650
+ const VALID_FRAMEWORKS = ["nextjs"];
651
+ const VALID_THEMES = ["minimal", "luxury", "playful"];
601
652
  let language = options.language;
653
+ if (language && !VALID_LANGUAGES.includes(language)) {
654
+ logger.error(
655
+ `Invalid --language "${language}". Expected one of: ${VALID_LANGUAGES.join(", ")}`
656
+ );
657
+ process.exit(1);
658
+ }
602
659
  let framework = options.framework;
660
+ if (!VALID_FRAMEWORKS.includes(framework)) {
661
+ logger.error(
662
+ `Invalid --framework "${framework}". Expected one of: ${VALID_FRAMEWORKS.join(", ")}`
663
+ );
664
+ process.exit(1);
665
+ }
603
666
  let theme = options.theme;
667
+ if (!VALID_THEMES.includes(theme)) {
668
+ logger.error(`Invalid --theme "${theme}". Expected one of: ${VALID_THEMES.join(", ")}`);
669
+ process.exit(1);
670
+ }
604
671
  let pkgManager = options.pkgManager;
672
+ if (pkgManager && !VALID_PKG_MANAGERS.includes(pkgManager)) {
673
+ logger.error(
674
+ `Invalid --pkg-manager "${pkgManager}". Expected one of: ${VALID_PKG_MANAGERS.join(", ")}`
675
+ );
676
+ process.exit(1);
677
+ }
605
678
  const skipGit = options.git === false;
606
679
  const skipInstall = options.install === false;
607
680
  let storeInfo = null;
package/messages/en.json CHANGED
@@ -154,11 +154,13 @@
154
154
  "changePickup": "Change pickup",
155
155
  "changeShipping": "Change shipping",
156
156
  "payment": "Payment",
157
+ "securePayment": "Secure payment",
157
158
  "preparingPayment": "Preparing payment...",
158
159
  "loadingPaymentOptions": "Loading payment options...",
159
160
  "paymentNotConfigured": "Payment Not Configured",
160
161
  "paymentNotConfiguredDesc": "Payment has not been set up for this store yet. Please contact the store owner.",
161
162
  "paymentError": "Payment Error",
163
+ "paymentRedirectBlocked": "Payment redirect blocked for security reasons. Please contact support.",
162
164
  "sandboxTitle": "Sandbox Mode",
163
165
  "sandboxDescription": "This is a test order. No real payment will be processed.",
164
166
  "completeTestOrder": "Complete Test Order",
@@ -187,6 +189,10 @@
187
189
  "customFieldsFailed": "Failed to save selections",
188
190
  "customFieldsRequired": "Required",
189
191
  "customFieldsSelectPlaceholder": "— Select —",
192
+ "customFieldsImageUpload": "Click to upload an image",
193
+ "customFieldsImageRemove": "Remove",
194
+ "customFieldsImageUploading": "Uploading...",
195
+ "customFieldsImageTooLarge": "File must be under 5MB",
190
196
  "changeOptions": "Change options",
191
197
  "surcharges": "Additional charges"
192
198
  },
@@ -243,13 +249,18 @@
243
249
  "passwordPlaceholder": "Enter your password",
244
250
  "firstNamePlaceholder": "Jane",
245
251
  "lastNamePlaceholder": "Doe",
246
- "atLeastChars": "At least 6 characters",
252
+ "atLeastChars": "At least 8 characters",
247
253
  "orContinueWith": "or continue with",
248
254
  "tooShort": "Too short",
249
255
  "weak": "Weak",
250
256
  "fair": "Fair",
251
257
  "good": "Good",
252
258
  "strong": "Strong",
259
+ "passwordRequired": "Password is required",
260
+ "passwordTooShort": "Password must be at least 8 characters",
261
+ "passwordNoUppercase": "Password must contain at least one uppercase letter",
262
+ "passwordNoNumber": "Password must contain at least one number",
263
+ "passwordNoSymbol": "Password must contain at least one special character",
253
264
  "google": "Google",
254
265
  "facebook": "Facebook",
255
266
  "github": "GitHub",
package/messages/he.json CHANGED
@@ -154,11 +154,13 @@
154
154
  "changePickup": "שנה איסוף",
155
155
  "changeShipping": "שנה משלוח",
156
156
  "payment": "תשלום",
157
+ "securePayment": "תשלום מאובטח",
157
158
  "preparingPayment": "מכין תשלום...",
158
159
  "loadingPaymentOptions": "טוען אפשרויות תשלום...",
159
160
  "paymentNotConfigured": "תשלום לא מוגדר",
160
161
  "paymentNotConfiguredDesc": "התשלום עדיין לא הוגדר לחנות זו. אנא פנו לבעל החנות.",
161
162
  "paymentError": "שגיאת תשלום",
163
+ "paymentRedirectBlocked": "הפניית התשלום נחסמה מטעמי אבטחה. אנא פנו לתמיכה.",
162
164
  "sandboxTitle": "מצב בדיקה",
163
165
  "sandboxDescription": "זוהי הזמנת בדיקה. לא יבוצע תשלום אמיתי.",
164
166
  "completeTestOrder": "השלם הזמנת בדיקה",
@@ -187,6 +189,10 @@
187
189
  "customFieldsFailed": "שגיאה בשמירת הבחירות",
188
190
  "customFieldsRequired": "חובה",
189
191
  "customFieldsSelectPlaceholder": "— בחר —",
192
+ "customFieldsImageUpload": "לחץ להעלאת תמונה",
193
+ "customFieldsImageRemove": "הסר",
194
+ "customFieldsImageUploading": "...מעלה",
195
+ "customFieldsImageTooLarge": "הקובץ חייב להיות מתחת ל-5MB",
190
196
  "changeOptions": "שנה אפשרויות",
191
197
  "surcharges": "תוספות"
192
198
  },
@@ -243,13 +249,18 @@
243
249
  "passwordPlaceholder": "הזינו סיסמה",
244
250
  "firstNamePlaceholder": "ישראל",
245
251
  "lastNamePlaceholder": "ישראלי",
246
- "atLeastChars": "לפחות 6 תווים",
252
+ "atLeastChars": "לפחות 8 תווים",
247
253
  "orContinueWith": "או המשיכו עם",
248
254
  "tooShort": "קצרה מדי",
249
255
  "weak": "חלשה",
250
256
  "fair": "סבירה",
251
257
  "good": "טובה",
252
258
  "strong": "חזקה",
259
+ "passwordRequired": "סיסמה נדרשת",
260
+ "passwordTooShort": "הסיסמה חייבת להכיל לפחות 8 תווים",
261
+ "passwordNoUppercase": "הסיסמה חייבת להכיל לפחות אות גדולה אחת",
262
+ "passwordNoNumber": "הסיסמה חייבת להכיל לפחות ספרה אחת",
263
+ "passwordNoSymbol": "הסיסמה חייבת להכיל לפחות סימן מיוחד אחד",
253
264
  "google": "Google",
254
265
  "facebook": "Facebook",
255
266
  "github": "GitHub",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.27.5",
3
+ "version": "1.28.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -2,11 +2,11 @@
2
2
  NEXT_PUBLIC_BRAINERCE_CONNECTION_ID=<%= connectionId %>
3
3
 
4
4
  # Store info (pre-fetched during setup to avoid flash on first load)
5
- NEXT_PUBLIC_STORE_NAME=<%= storeName %>
6
- NEXT_PUBLIC_STORE_CURRENCY=<%= currency %>
5
+ NEXT_PUBLIC_STORE_NAME=<%- storeNameEnv %>
6
+ NEXT_PUBLIC_STORE_CURRENCY=<%- currencyEnv %>
7
7
 
8
8
  # Backend API URL (server-side only — used by BFF proxy and SSR, never exposed to browser)
9
- BRAINERCE_API_URL=<%= apiBaseUrl %>
9
+ BRAINERCE_API_URL=<%- apiBaseUrlEnv %>
10
10
 
11
11
  # Public site URL — used for sitemap, robots.txt, and SEO metadata
12
12
  NEXT_PUBLIC_SITE_URL=http://localhost:3000
@@ -2,7 +2,10 @@ import type { NextConfig } from 'next';
2
2
 
3
3
  const nextConfig: NextConfig = {
4
4
  images: {
5
- remotePatterns: [{ protocol: 'https', hostname: '**' }],
5
+ remotePatterns: [
6
+ { protocol: 'https', hostname: 'cdn.brainerce.com' },
7
+ { protocol: 'https', hostname: '*.brainerce.com' },
8
+ ],
6
9
  },
7
10
  async headers() {
8
11
  return [
@@ -10,17 +13,15 @@ const nextConfig: NextConfig = {
10
13
  source: '/(.*)',
11
14
  headers: [
12
15
  {
13
- key: 'Content-Security-Policy',
14
- value: [
15
- "default-src 'self'",
16
- "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.meshulam.co.il https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://js.stripe.com https://pay.google.com",
17
- "style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
18
- "img-src 'self' data: blob: https:",
19
- "font-src 'self' data:",
20
- "frame-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions",
21
- "connect-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://google.com https://pay.google.com https://*.stripe.com https://*.creditguard.co.il",
22
- "worker-src 'self' blob:",
23
- ].join('; '),
16
+ key: 'Strict-Transport-Security',
17
+ value: 'max-age=63072000; includeSubDomains; preload',
18
+ },
19
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
20
+ { key: 'X-Frame-Options', value: 'DENY' },
21
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
22
+ {
23
+ key: 'Permissions-Policy',
24
+ value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
24
25
  },
25
26
  ],
26
27
  },
@@ -15,7 +15,8 @@
15
15
  "react": "^19.0.0",
16
16
  "react-dom": "^19.0.0",
17
17
  "clsx": "^2.1.0",
18
- "tailwind-merge": "^2.2.0"
18
+ "tailwind-merge": "^2.2.0",
19
+ "isomorphic-dompurify": "^3.8.0"
19
20
  },
20
21
  "devDependencies": {
21
22
  "@types/node": "^20.0.0",
@@ -1,14 +1,15 @@
1
- import { NextResponse } from 'next/server';
2
-
3
- const TOKEN_COOKIE = 'brainerce_customer_token';
4
- const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5
-
6
- /**
7
- * Logout endpoint. Clears auth cookies.
8
- */
9
- export async function POST() {
10
- const response = NextResponse.json({ success: true });
11
- response.cookies.delete(TOKEN_COOKIE);
12
- response.cookies.delete(LOGGED_IN_COOKIE);
13
- return response;
14
- }
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { checkCsrf } from '@/lib/csrf';
3
+
4
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
6
+
7
+ export async function POST(request: NextRequest) {
8
+ const csrfError = checkCsrf(request);
9
+ if (csrfError) return csrfError;
10
+
11
+ const response = NextResponse.json({ success: true });
12
+ response.cookies.delete(TOKEN_COOKIE);
13
+ response.cookies.delete(LOGGED_IN_COOKIE);
14
+ return response;
15
+ }
@@ -1,59 +1,66 @@
1
- import { NextRequest, NextResponse } from 'next/server';
2
-
3
- const TOKEN_COOKIE = 'brainerce_customer_token';
4
- const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5
- const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
6
-
7
- function isSecure(): boolean {
8
- return process.env.NODE_ENV === 'production';
9
- }
10
-
11
- /**
12
- * OAuth callback handler.
13
- * The backend redirects here with ?token=jwt&oauth_success=true after OAuth code exchange.
14
- * We set the httpOnly cookie and redirect to the client-side callback page (without the token).
15
- */
16
- export async function GET(request: NextRequest) {
17
- const { searchParams } = request.nextUrl;
18
- const token = searchParams.get('token');
19
- const oauthSuccess = searchParams.get('oauth_success');
20
- const oauthError = searchParams.get('oauth_error');
21
-
22
- // Build redirect URL to client-side callback page
23
- const redirectUrl = new URL('/auth/callback', request.url);
24
-
25
- if (oauthError) {
26
- redirectUrl.searchParams.set('oauth_error', oauthError);
27
- return NextResponse.redirect(redirectUrl);
28
- }
29
-
30
- if (oauthSuccess === 'true' && token) {
31
- redirectUrl.searchParams.set('oauth_success', 'true');
32
-
33
- const response = NextResponse.redirect(redirectUrl);
34
-
35
- // Set httpOnly cookie with the token
36
- response.cookies.set(TOKEN_COOKIE, token, {
37
- httpOnly: true,
38
- secure: isSecure(),
39
- sameSite: 'lax',
40
- path: '/',
41
- maxAge: COOKIE_MAX_AGE,
42
- });
43
-
44
- // Set indicator cookie (readable by client JS)
45
- response.cookies.set(LOGGED_IN_COOKIE, '1', {
46
- httpOnly: false,
47
- secure: isSecure(),
48
- sameSite: 'lax',
49
- path: '/',
50
- maxAge: COOKIE_MAX_AGE,
51
- });
52
-
53
- return response;
54
- }
55
-
56
- // Fallback: no token or success flag
57
- redirectUrl.searchParams.set('oauth_error', 'Authentication failed');
58
- return NextResponse.redirect(redirectUrl);
59
- }
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+
3
+ const TOKEN_COOKIE = 'brainerce_customer_token';
4
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
6
+
7
+ function isSecure(): boolean {
8
+ return process.env.NODE_ENV === 'production';
9
+ }
10
+
11
+ /**
12
+ * OAuth callback handler.
13
+ * The backend redirects here with ?token=jwt&oauth_success=true after OAuth code exchange.
14
+ * We set the httpOnly cookie and redirect to the client-side callback page (without the token).
15
+ */
16
+ export async function GET(request: NextRequest) {
17
+ const { searchParams } = request.nextUrl;
18
+ const token = searchParams.get('token');
19
+ const oauthSuccess = searchParams.get('oauth_success');
20
+ const oauthError = searchParams.get('oauth_error');
21
+
22
+ // Build redirect URL to client-side callback page
23
+ const redirectUrl = new URL('/auth/callback', request.url);
24
+
25
+ if (oauthError) {
26
+ redirectUrl.searchParams.set('oauth_error', oauthError);
27
+ const response = NextResponse.redirect(redirectUrl);
28
+ response.headers.set('Referrer-Policy', 'no-referrer');
29
+ return response;
30
+ }
31
+
32
+ if (oauthSuccess === 'true' && token) {
33
+ redirectUrl.searchParams.set('oauth_success', 'true');
34
+
35
+ const response = NextResponse.redirect(redirectUrl);
36
+
37
+ // Set httpOnly cookie with the token
38
+ response.cookies.set(TOKEN_COOKIE, token, {
39
+ httpOnly: true,
40
+ secure: isSecure(),
41
+ sameSite: 'lax',
42
+ path: '/',
43
+ maxAge: COOKIE_MAX_AGE,
44
+ });
45
+
46
+ // Set indicator cookie (readable by client JS)
47
+ response.cookies.set(LOGGED_IN_COOKIE, '1', {
48
+ httpOnly: false,
49
+ secure: isSecure(),
50
+ sameSite: 'lax',
51
+ path: '/',
52
+ maxAge: COOKIE_MAX_AGE,
53
+ });
54
+
55
+ // Prevent token leaking via Referer header on the downstream navigation
56
+ response.headers.set('Referrer-Policy', 'no-referrer');
57
+
58
+ return response;
59
+ }
60
+
61
+ // Fallback: no token or success flag
62
+ redirectUrl.searchParams.set('oauth_error', 'Authentication failed');
63
+ const response = NextResponse.redirect(redirectUrl);
64
+ response.headers.set('Referrer-Policy', 'no-referrer');
65
+ return response;
66
+ }