create-brainerce-store 1.27.6 → 1.28.1

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 +120 -22
  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 +31 -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 +88 -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 +103 -8
  26. package/templates/nextjs/base/tsconfig.tsbuildinfo +1 -0
@@ -1,72 +1,88 @@
1
- import type { Product } from 'brainerce';
2
- import { getProductPriceInfo } from 'brainerce';
3
-
4
- interface ProductJsonLdProps {
5
- product: Product;
6
- url: string;
7
- currency?: string;
8
- }
9
-
10
- export function ProductJsonLd({ product, url, currency = 'USD' }: ProductJsonLdProps) {
11
- const priceInfo = getProductPriceInfo(product);
12
- const imageUrl = product.images?.[0]?.url;
13
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
14
-
15
- const productJsonLd = {
16
- '@context': 'https://schema.org',
17
- '@type': 'Product',
18
- name: product.name,
19
- description: product.description || product.name,
20
- image: imageUrl,
21
- url,
22
- sku: product.sku || product.id,
23
- offers: {
24
- '@type': 'Offer',
25
- price: priceInfo.price,
26
- priceCurrency: currency,
27
- availability:
28
- product.inventory?.canPurchase !== false
29
- ? 'https://schema.org/InStock'
30
- : 'https://schema.org/OutOfStock',
31
- url,
32
- },
33
- };
34
-
35
- const breadcrumbJsonLd = {
36
- '@context': 'https://schema.org',
37
- '@type': 'BreadcrumbList',
38
- itemListElement: [
39
- {
40
- '@type': 'ListItem',
41
- position: 1,
42
- name: 'Home',
43
- item: baseUrl || '/',
44
- },
45
- {
46
- '@type': 'ListItem',
47
- position: 2,
48
- name: 'Products',
49
- item: `${baseUrl}/products`,
50
- },
51
- {
52
- '@type': 'ListItem',
53
- position: 3,
54
- name: product.name,
55
- item: url,
56
- },
57
- ],
58
- };
59
-
60
- return (
61
- <>
62
- <script
63
- type="application/ld+json"
64
- dangerouslySetInnerHTML={{ __html: JSON.stringify(productJsonLd) }}
65
- />
66
- <script
67
- type="application/ld+json"
68
- dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
69
- />
70
- </>
71
- );
72
- }
1
+ import type { Product } from 'brainerce';
2
+ import { getProductPriceInfo } from 'brainerce';
3
+ import { getNonce } from '@/lib/nonce';
4
+
5
+ interface ProductJsonLdProps {
6
+ product: Product;
7
+ url: string;
8
+ currency?: string;
9
+ }
10
+
11
+ export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJsonLdProps) {
12
+ const nonce = await getNonce();
13
+ const priceInfo = getProductPriceInfo(product);
14
+ const imageUrl = product.images?.[0]?.url;
15
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
16
+
17
+ const productJsonLd = {
18
+ '@context': 'https://schema.org',
19
+ '@type': 'Product',
20
+ name: product.name,
21
+ description: product.description || product.name,
22
+ image: imageUrl,
23
+ url,
24
+ sku: product.sku || product.id,
25
+ offers: {
26
+ '@type': 'Offer',
27
+ price: priceInfo.price,
28
+ priceCurrency: currency,
29
+ availability:
30
+ product.inventory?.canPurchase !== false
31
+ ? 'https://schema.org/InStock'
32
+ : 'https://schema.org/OutOfStock',
33
+ url,
34
+ },
35
+ };
36
+
37
+ const breadcrumbJsonLd = {
38
+ '@context': 'https://schema.org',
39
+ '@type': 'BreadcrumbList',
40
+ itemListElement: [
41
+ {
42
+ '@type': 'ListItem',
43
+ position: 1,
44
+ name: 'Home',
45
+ item: baseUrl || '/',
46
+ },
47
+ {
48
+ '@type': 'ListItem',
49
+ position: 2,
50
+ name: 'Products',
51
+ item: `${baseUrl}/products`,
52
+ },
53
+ {
54
+ '@type': 'ListItem',
55
+ position: 3,
56
+ name: product.name,
57
+ item: url,
58
+ },
59
+ ],
60
+ };
61
+
62
+ return (
63
+ <>
64
+ <script
65
+ type="application/ld+json"
66
+ nonce={nonce}
67
+ suppressHydrationWarning
68
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(productJsonLd) }}
69
+ />
70
+ <script
71
+ type="application/ld+json"
72
+ nonce={nonce}
73
+ suppressHydrationWarning
74
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
75
+ />
76
+ </>
77
+ );
78
+ }
79
+
80
+ // Serialize a JSON-LD object for embedding in a <script> tag. Escapes
81
+ // `<`, `>`, and `&` to \uXXXX so seller-controlled product fields cannot
82
+ // break out of the script element with `</script>` or inject HTML.
83
+ function serializeJsonLd(value: unknown): string {
84
+ return JSON.stringify(value)
85
+ .replace(/</g, '\\u003c')
86
+ .replace(/>/g, '\\u003e')
87
+ .replace(/&/g, '\\u0026');
88
+ }
@@ -0,0 +1,11 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+
3
+ export const CSRF_HEADER = 'x-requested-with';
4
+ export const CSRF_VALUE = 'brainerce';
5
+
6
+ export function checkCsrf(request: NextRequest): NextResponse | null {
7
+ if (request.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
8
+ return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
9
+ }
10
+ return null;
11
+ }
@@ -0,0 +1,10 @@
1
+ import { headers } from 'next/headers';
2
+
3
+ /**
4
+ * Reads the per-request CSP nonce set by middleware.
5
+ * Use this in server components that render inline `<script>` tags so
6
+ * they comply with the strict CSP (no `unsafe-inline`).
7
+ */
8
+ export async function getNonce(): Promise<string | undefined> {
9
+ return (await headers()).get('x-nonce') ?? undefined;
10
+ }
@@ -0,0 +1,45 @@
1
+ const ALLOWED_PAYMENT_HOSTS: readonly string[] = [
2
+ 'checkout.stripe.com',
3
+ 'js.stripe.com',
4
+ 'hooks.stripe.com',
5
+ 'www.paypal.com',
6
+ 'www.sandbox.paypal.com',
7
+ 'secure.cardcom.solutions',
8
+ 'meshulam.co.il',
9
+ 'grow.link',
10
+ 'grow.security',
11
+ 'creditguard.co.il',
12
+ ];
13
+
14
+ export function isAllowedPaymentUrl(url: string): boolean {
15
+ if (!url || typeof url !== 'string') return false;
16
+
17
+ let parsed: URL;
18
+ try {
19
+ parsed = new URL(url);
20
+ } catch {
21
+ return false;
22
+ }
23
+
24
+ if (parsed.protocol !== 'https:') return false;
25
+
26
+ const hostname = parsed.hostname.toLowerCase();
27
+ return ALLOWED_PAYMENT_HOSTS.some((host) => hostname === host || hostname.endsWith('.' + host));
28
+ }
29
+
30
+ export function safePaymentRedirect(url: string): void {
31
+ if (!isAllowedPaymentUrl(url)) {
32
+ throw new Error('Payment redirect URL is not in the allowlist');
33
+ }
34
+ if (typeof window !== 'undefined') {
35
+ window.location.href = url;
36
+ }
37
+ }
38
+
39
+ // CUID format used by Prisma for Checkout.id — c + 24 lowercase alphanumeric chars.
40
+ // Allow a small range to tolerate cuid2 (slightly different length).
41
+ const CHECKOUT_ID_RE = /^c[a-z0-9]{20,30}$/;
42
+
43
+ export function isValidCheckoutId(id: unknown): id is string {
44
+ return typeof id === 'string' && CHECKOUT_ID_RE.test(id);
45
+ }
@@ -0,0 +1,93 @@
1
+ import DOMPurify from 'isomorphic-dompurify';
2
+
3
+ const TRUSTED_IFRAME_PREFIXES = [
4
+ 'https://www.youtube.com/embed/',
5
+ 'https://www.youtube-nocookie.com/embed/',
6
+ 'https://player.vimeo.com/video/',
7
+ ];
8
+
9
+ let hooksRegistered = false;
10
+
11
+ function registerHooksOnce() {
12
+ if (hooksRegistered) return;
13
+ hooksRegistered = true;
14
+
15
+ DOMPurify.addHook('uponSanitizeElement', (node, data) => {
16
+ if (data.tagName !== 'iframe') return;
17
+ const el = node as Element;
18
+ const src = el.getAttribute('src') || '';
19
+ const isTrusted = TRUSTED_IFRAME_PREFIXES.some((prefix) => src.startsWith(prefix));
20
+ if (!isTrusted) {
21
+ el.parentNode?.removeChild(el);
22
+ }
23
+ });
24
+
25
+ DOMPurify.addHook('afterSanitizeAttributes', (node) => {
26
+ if (!('tagName' in node)) return;
27
+ const el = node as Element;
28
+ if (el.tagName === 'A' && el.getAttribute('target') === '_blank') {
29
+ el.setAttribute('rel', 'noopener noreferrer nofollow');
30
+ }
31
+ });
32
+ }
33
+
34
+ export function sanitizeProductHtml(html: string): string {
35
+ if (!html) return '';
36
+ registerHooksOnce();
37
+
38
+ return DOMPurify.sanitize(html, {
39
+ ALLOWED_TAGS: [
40
+ 'p',
41
+ 'br',
42
+ 'strong',
43
+ 'b',
44
+ 'em',
45
+ 'i',
46
+ 'u',
47
+ 's',
48
+ 'strike',
49
+ 'h1',
50
+ 'h2',
51
+ 'h3',
52
+ 'h4',
53
+ 'h5',
54
+ 'h6',
55
+ 'ul',
56
+ 'ol',
57
+ 'li',
58
+ 'a',
59
+ 'blockquote',
60
+ 'code',
61
+ 'pre',
62
+ 'div',
63
+ 'span',
64
+ 'img',
65
+ 'table',
66
+ 'thead',
67
+ 'tbody',
68
+ 'tr',
69
+ 'th',
70
+ 'td',
71
+ 'colgroup',
72
+ 'col',
73
+ 'iframe',
74
+ ],
75
+ ALLOWED_ATTR: [
76
+ 'href',
77
+ 'target',
78
+ 'rel',
79
+ 'class',
80
+ 'src',
81
+ 'alt',
82
+ 'width',
83
+ 'height',
84
+ 'colspan',
85
+ 'rowspan',
86
+ 'allow',
87
+ 'allowfullscreen',
88
+ 'frameborder',
89
+ 'loading',
90
+ ],
91
+ ALLOW_DATA_ATTR: false,
92
+ });
93
+ }
@@ -0,0 +1,37 @@
1
+ const EMAIL_REGEX =
2
+ /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
3
+
4
+ export function isValidEmail(email: string): boolean {
5
+ if (!email || typeof email !== 'string') return false;
6
+ if (email.length > 254) return false;
7
+ return EMAIL_REGEX.test(email);
8
+ }
9
+
10
+ export type PasswordErrorCode =
11
+ | 'passwordRequired'
12
+ | 'passwordTooShort'
13
+ | 'passwordNoUppercase'
14
+ | 'passwordNoNumber'
15
+ | 'passwordNoSymbol';
16
+
17
+ export function getPasswordError(password: string): PasswordErrorCode | null {
18
+ if (!password || typeof password !== 'string') return 'passwordRequired';
19
+ if (password.length < 8) return 'passwordTooShort';
20
+ if (!/[A-Z]/.test(password)) return 'passwordNoUppercase';
21
+ if (!/[0-9]/.test(password)) return 'passwordNoNumber';
22
+ if (!/[^A-Za-z0-9]/.test(password)) return 'passwordNoSymbol';
23
+ return null;
24
+ }
25
+
26
+ const PASSWORD_ERROR_MESSAGES: Record<PasswordErrorCode, string> = {
27
+ passwordRequired: 'Password is required',
28
+ passwordTooShort: 'Password must be at least 8 characters',
29
+ passwordNoUppercase: 'Password must contain at least one uppercase letter',
30
+ passwordNoNumber: 'Password must contain at least one number',
31
+ passwordNoSymbol: 'Password must contain at least one special character',
32
+ };
33
+
34
+ export function validatePassword(password: string): string | null {
35
+ const code = getPasswordError(password);
36
+ return code ? PASSWORD_ERROR_MESSAGES[code] : null;
37
+ }
@@ -11,6 +11,44 @@ function getLocaleFromPath(pathname: string): string | null {
11
11
  return supportedLocales.includes(segment) ? segment : null;
12
12
  }
13
13
 
14
+ function generateNonce(): string {
15
+ const bytes = new Uint8Array(16);
16
+ crypto.getRandomValues(bytes);
17
+ let binary = '';
18
+ for (const b of bytes) binary += String.fromCharCode(b);
19
+ return btoa(binary);
20
+ }
21
+
22
+ function buildCsp(nonce: string): string {
23
+ const isDev = process.env.NODE_ENV === 'development';
24
+ // Next dev uses webpack's eval-source-map devtool, which requires 'unsafe-eval'
25
+ // to execute module code. Prod builds never eval, so this only loosens dev.
26
+ const scriptSrc = isDev
27
+ ? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-eval'`
28
+ : `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`;
29
+ return [
30
+ "default-src 'self'",
31
+ scriptSrc,
32
+ "style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
33
+ "img-src 'self' data: blob: https:",
34
+ "font-src 'self' data:",
35
+ "frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com",
36
+ "connect-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://pay.google.com https://*.stripe.com https://*.creditguard.co.il",
37
+ "worker-src 'self' blob:",
38
+ "frame-ancestors 'none'",
39
+ "base-uri 'self'",
40
+ "form-action 'self'",
41
+ "object-src 'none'",
42
+ 'upgrade-insecure-requests',
43
+ ].join('; ');
44
+ }
45
+
46
+ function applyCspHeaders(response: NextResponse, nonce: string): NextResponse {
47
+ response.headers.set('Content-Security-Policy', buildCsp(nonce));
48
+ response.headers.set('x-nonce', nonce);
49
+ return response;
50
+ }
51
+
14
52
  export function middleware(request: NextRequest) {
15
53
  const { pathname } = request.nextUrl;
16
54
 
@@ -23,13 +61,17 @@ export function middleware(request: NextRequest) {
23
61
  return NextResponse.next();
24
62
  }
25
63
 
64
+ const nonce = generateNonce();
65
+ const requestHeaders = new Headers(request.headers);
66
+ requestHeaders.set('x-nonce', nonce);
67
+
26
68
  const pathnameLocale = getLocaleFromPath(pathname);
27
69
 
28
70
  // Redirect to default locale if no locale prefix
29
71
  if (!pathnameLocale) {
30
72
  const url = request.nextUrl.clone();
31
73
  url.pathname = `/${defaultLocale}${pathname}`;
32
- return NextResponse.redirect(url);
74
+ return applyCspHeaders(NextResponse.redirect(url), nonce);
33
75
  }
34
76
 
35
77
  // Auth protection (with locale prefix)
@@ -39,14 +81,14 @@ export function middleware(request: NextRequest) {
39
81
  const token = request.cookies.get(TOKEN_COOKIE);
40
82
  if (!token?.value) {
41
83
  const loginUrl = new URL(`/${pathnameLocale}/login`, request.url);
42
- return NextResponse.redirect(loginUrl);
84
+ return applyCspHeaders(NextResponse.redirect(loginUrl), nonce);
43
85
  }
44
86
  }
45
87
 
46
- // Set locale header for server components
47
- const response = NextResponse.next();
88
+ // Set locale header for server components + forward nonce
89
+ const response = NextResponse.next({ request: { headers: requestHeaders } });
48
90
  response.headers.set('x-locale', pathnameLocale);
49
- return response;
91
+ return applyCspHeaders(response, nonce);
50
92
  }
51
93
 
52
94
  export const config = {
@@ -60,22 +102,75 @@ const TOKEN_COOKIE = 'brainerce_customer_token';
60
102
  /** Routes that require customer authentication */
61
103
  const PROTECTED_PATHS = ['/account'];
62
104
 
105
+ function generateNonce(): string {
106
+ const bytes = new Uint8Array(16);
107
+ crypto.getRandomValues(bytes);
108
+ let binary = '';
109
+ for (const b of bytes) binary += String.fromCharCode(b);
110
+ return btoa(binary);
111
+ }
112
+
113
+ function buildCsp(nonce: string): string {
114
+ const isDev = process.env.NODE_ENV === 'development';
115
+ // Next dev uses webpack's eval-source-map devtool, which requires 'unsafe-eval'
116
+ // to execute module code. Prod builds never eval, so this only loosens dev.
117
+ const scriptSrc = isDev
118
+ ? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-eval'`
119
+ : `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`;
120
+ return [
121
+ "default-src 'self'",
122
+ scriptSrc,
123
+ "style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
124
+ "img-src 'self' data: blob: https:",
125
+ "font-src 'self' data:",
126
+ "frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com",
127
+ "connect-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://pay.google.com https://*.stripe.com https://*.creditguard.co.il",
128
+ "worker-src 'self' blob:",
129
+ "frame-ancestors 'none'",
130
+ "base-uri 'self'",
131
+ "form-action 'self'",
132
+ "object-src 'none'",
133
+ 'upgrade-insecure-requests',
134
+ ].join('; ');
135
+ }
136
+
137
+ function applyCspHeaders(response: NextResponse, nonce: string): NextResponse {
138
+ response.headers.set('Content-Security-Policy', buildCsp(nonce));
139
+ response.headers.set('x-nonce', nonce);
140
+ return response;
141
+ }
142
+
63
143
  export function middleware(request: NextRequest) {
64
144
  const { pathname } = request.nextUrl;
145
+
146
+ // Skip static files and API routes
147
+ if (
148
+ pathname.startsWith('/api/') ||
149
+ pathname.startsWith('/_next/') ||
150
+ pathname.includes('.')
151
+ ) {
152
+ return NextResponse.next();
153
+ }
154
+
155
+ const nonce = generateNonce();
156
+ const requestHeaders = new Headers(request.headers);
157
+ requestHeaders.set('x-nonce', nonce);
158
+
65
159
  const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p));
66
160
 
67
161
  if (isProtected) {
68
162
  const token = request.cookies.get(TOKEN_COOKIE);
69
163
  if (!token?.value) {
70
164
  const loginUrl = new URL('/login', request.url);
71
- return NextResponse.redirect(loginUrl);
165
+ return applyCspHeaders(NextResponse.redirect(loginUrl), nonce);
72
166
  }
73
167
  }
74
168
 
75
- return NextResponse.next();
169
+ const response = NextResponse.next({ request: { headers: requestHeaders } });
170
+ return applyCspHeaders(response, nonce);
76
171
  }
77
172
 
78
173
  export const config = {
79
- matcher: ['/account/:path*'],
174
+ matcher: ['/((?!_next|api|.*\\..*).*)'],
80
175
  };
81
176
  <% } %>