create-brainerce-store 1.6.1 → 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.6.1",
3
+ "version": "1.7.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"
@@ -1,3 +1,5 @@
1
1
  # Brainerce Connection
2
2
  NEXT_PUBLIC_BRAINERCE_CONNECTION_ID=<%= connectionId %>
3
- NEXT_PUBLIC_BRAINERCE_API_URL=<%= apiBaseUrl %>
3
+
4
+ # Backend API URL (server-side only — used by BFF proxy, never exposed to browser)
5
+ BRAINERCE_API_URL=<%= apiBaseUrl %>
@@ -0,0 +1,14 @@
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
+ }
@@ -0,0 +1,49 @@
1
+ import { 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 CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
10
+
11
+ const TOKEN_COOKIE = 'brainerce_customer_token';
12
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
13
+
14
+ /**
15
+ * Auth status check endpoint.
16
+ * Reads the httpOnly cookie, validates against backend, returns auth state.
17
+ */
18
+ export async function GET() {
19
+ const cookieStore = await cookies();
20
+ const tokenCookie = cookieStore.get(TOKEN_COOKIE);
21
+
22
+ if (!tokenCookie?.value) {
23
+ return NextResponse.json({ isLoggedIn: false });
24
+ }
25
+
26
+ try {
27
+ // Validate token by calling backend profile endpoint
28
+ const response = await fetch(`${BACKEND_URL}/api/vc/${CONNECTION_ID}/customers/me`, {
29
+ headers: {
30
+ Authorization: `Bearer ${tokenCookie.value}`,
31
+ 'Content-Type': 'application/json',
32
+ },
33
+ });
34
+
35
+ if (!response.ok) {
36
+ // Token is invalid or expired — clear cookies
37
+ const res = NextResponse.json({ isLoggedIn: false });
38
+ res.cookies.delete(TOKEN_COOKIE);
39
+ res.cookies.delete(LOGGED_IN_COOKIE);
40
+ return res;
41
+ }
42
+
43
+ const customer = await response.json();
44
+ return NextResponse.json({ isLoggedIn: true, customer });
45
+ } catch {
46
+ // Backend unreachable — don't clear cookies, might be temporary
47
+ return NextResponse.json({ isLoggedIn: false, error: 'Service unavailable' }, { status: 503 });
48
+ }
49
+ }
@@ -0,0 +1,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
+ 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
+ }
@@ -0,0 +1,41 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+
3
+ const RESET_TOKEN_COOKIE = 'brainerce_reset_token';
4
+ const RESET_TOKEN_MAX_AGE = 10 * 60; // 10 minutes
5
+
6
+ function isSecure(): boolean {
7
+ return process.env.NODE_ENV === 'production';
8
+ }
9
+
10
+ /**
11
+ * Password-reset callback handler.
12
+ * The email link redirects here with ?token=... from the backend.
13
+ * We store the token in an httpOnly cookie and redirect to /reset-password (clean URL).
14
+ * This mirrors the OAuth callback pattern — the token never reaches client JS.
15
+ */
16
+ export async function GET(request: NextRequest) {
17
+ const { searchParams } = request.nextUrl;
18
+ const token = searchParams.get('token');
19
+
20
+ if (!token) {
21
+ const redirectUrl = new URL('/forgot-password', request.url);
22
+ return NextResponse.redirect(redirectUrl);
23
+ }
24
+
25
+ const redirectUrl = new URL('/reset-password', request.url);
26
+ const response = NextResponse.redirect(redirectUrl);
27
+
28
+ // Set httpOnly cookie with the reset token (short-lived)
29
+ response.cookies.set(RESET_TOKEN_COOKIE, token, {
30
+ httpOnly: true,
31
+ secure: isSecure(),
32
+ sameSite: 'lax',
33
+ path: '/',
34
+ maxAge: RESET_TOKEN_MAX_AGE,
35
+ });
36
+
37
+ // Prevent token leaking via Referer header
38
+ response.headers.set('Referrer-Policy', 'no-referrer');
39
+
40
+ return response;
41
+ }
@@ -0,0 +1,68 @@
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 CONNECTION_ID = process.env.BRAINERCE_CONNECTION_ID || '';
10
+
11
+ const RESET_TOKEN_COOKIE = 'brainerce_reset_token';
12
+ const CSRF_HEADER = 'x-requested-with';
13
+ const CSRF_VALUE = 'brainerce';
14
+
15
+ /**
16
+ * BFF endpoint for password reset.
17
+ * Reads the reset token from the httpOnly cookie (set by /api/auth/reset-callback)
18
+ * and proxies the request to the backend. The token never touches client JS.
19
+ */
20
+ export async function POST(request: NextRequest) {
21
+ // CSRF check
22
+ const csrfHeader = request.headers.get(CSRF_HEADER);
23
+ if (csrfHeader !== CSRF_VALUE) {
24
+ return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
25
+ }
26
+
27
+ // Read reset token from httpOnly cookie
28
+ const cookieStore = await cookies();
29
+ const resetTokenCookie = cookieStore.get(RESET_TOKEN_COOKIE);
30
+
31
+ if (!resetTokenCookie?.value) {
32
+ return NextResponse.json(
33
+ { error: 'No reset token found. Please request a new password reset link.' },
34
+ { status: 400 }
35
+ );
36
+ }
37
+
38
+ // Parse request body
39
+ const body = await request.json();
40
+ const { newPassword } = body;
41
+
42
+ if (!newPassword) {
43
+ return NextResponse.json({ error: 'New password is required' }, { status: 400 });
44
+ }
45
+
46
+ // Proxy to backend
47
+ const backendUrl = `${BACKEND_URL}/api/vc/${CONNECTION_ID}/customers/reset-password`;
48
+
49
+ const backendResponse = await fetch(backendUrl, {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({
53
+ token: resetTokenCookie.value,
54
+ newPassword,
55
+ }),
56
+ });
57
+
58
+ const data = await backendResponse.json();
59
+
60
+ const response = NextResponse.json(data, {
61
+ status: backendResponse.status,
62
+ });
63
+
64
+ // Always clear the reset token cookie after use (success or failure)
65
+ response.cookies.delete(RESET_TOKEN_COOKIE);
66
+
67
+ return response;
68
+ }
@@ -0,0 +1,190 @@
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 SDK version header if present
78
+ const sdkVersion = request.headers.get('x-sdk-version');
79
+ if (sdkVersion) {
80
+ headers['X-SDK-Version'] = sdkVersion;
81
+ }
82
+
83
+ // Add auth token from httpOnly cookie
84
+ const cookieStore = await cookies();
85
+ const tokenCookie = cookieStore.get(TOKEN_COOKIE);
86
+ if (tokenCookie?.value) {
87
+ headers['Authorization'] = `Bearer ${tokenCookie.value}`;
88
+ }
89
+
90
+ // Forward request body for non-GET requests
91
+ let body: string | undefined;
92
+ if (method !== 'GET' && method !== 'HEAD') {
93
+ try {
94
+ body = await request.text();
95
+ } catch {
96
+ // No body
97
+ }
98
+ }
99
+
100
+ // Proxy the request to backend
101
+ let backendResponse: Response;
102
+ try {
103
+ backendResponse = await fetch(backendUrl.toString(), {
104
+ method,
105
+ headers,
106
+ body,
107
+ });
108
+ } catch (error) {
109
+ return NextResponse.json({ error: 'Backend service unavailable' }, { status: 502 });
110
+ }
111
+
112
+ // Read response body
113
+ const responseText = await backendResponse.text();
114
+
115
+ // For auth endpoints: intercept token, set cookie, strip token from response
116
+ if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
117
+ try {
118
+ const data = JSON.parse(responseText);
119
+ if (data.token) {
120
+ const token = data.token;
121
+
122
+ // Strip token from client response
123
+ const { token: _stripped, ...safeData } = data;
124
+
125
+ const response = NextResponse.json(safeData, {
126
+ status: backendResponse.status,
127
+ });
128
+ setAuthCookies(response, token);
129
+ return response;
130
+ }
131
+ } catch {
132
+ // Not JSON or no token field — pass through
133
+ }
134
+ }
135
+
136
+ // Handle 401 responses: clear auth cookies
137
+ if (backendResponse.status === 401 && tokenCookie?.value) {
138
+ const response = new NextResponse(responseText, {
139
+ status: backendResponse.status,
140
+ headers: {
141
+ 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
142
+ },
143
+ });
144
+ clearAuthCookies(response);
145
+ return response;
146
+ }
147
+
148
+ // Pass through response as-is
149
+ return new NextResponse(responseText, {
150
+ status: backendResponse.status,
151
+ headers: {
152
+ 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
153
+ },
154
+ });
155
+ }
156
+
157
+ export async function GET(
158
+ request: NextRequest,
159
+ { params }: { params: Promise<{ path: string[] }> }
160
+ ) {
161
+ return proxyRequest(request, await params);
162
+ }
163
+
164
+ export async function POST(
165
+ request: NextRequest,
166
+ { params }: { params: Promise<{ path: string[] }> }
167
+ ) {
168
+ return proxyRequest(request, await params);
169
+ }
170
+
171
+ export async function PUT(
172
+ request: NextRequest,
173
+ { params }: { params: Promise<{ path: string[] }> }
174
+ ) {
175
+ return proxyRequest(request, await params);
176
+ }
177
+
178
+ export async function PATCH(
179
+ request: NextRequest,
180
+ { params }: { params: Promise<{ path: string[] }> }
181
+ ) {
182
+ return proxyRequest(request, await params);
183
+ }
184
+
185
+ export async function DELETE(
186
+ request: NextRequest,
187
+ { params }: { params: Promise<{ path: string[] }> }
188
+ ) {
189
+ return proxyRequest(request, await params);
190
+ }
@@ -1,90 +1,92 @@
1
- 'use client';
2
-
3
- import { Suspense, useEffect, useState, useRef } from 'react';
4
- import { useRouter, useSearchParams } from 'next/navigation';
5
- import { useAuth } from '@/providers/store-provider';
6
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
7
- import { useTranslations } from '@/lib/translations';
8
-
9
- function OAuthCallbackContent() {
10
- const router = useRouter();
11
- const searchParams = useSearchParams();
12
- const auth = useAuth();
13
- const [error, setError] = useState<string | null>(null);
14
- const processedRef = useRef(false);
15
- const t = useTranslations('auth');
16
-
17
- const oauthSuccess = searchParams.get('oauth_success');
18
- const token = searchParams.get('token');
19
- const oauthError = searchParams.get('oauth_error');
20
-
21
- useEffect(() => {
22
- // Prevent double-processing in React StrictMode
23
- if (processedRef.current) return;
24
- processedRef.current = true;
25
-
26
- if (oauthError) {
27
- setError(oauthError);
28
- return;
29
- }
30
-
31
- if (oauthSuccess === 'true' && token) {
32
- auth.login(token);
33
- router.push('/');
34
- } else {
35
- setError(t('authFailedDesc'));
36
- }
37
- }, [oauthSuccess, token, oauthError, auth, router]);
38
-
39
- if (error) {
40
- return (
41
- <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
42
- <div className="w-full max-w-md space-y-4 text-center">
43
- <svg
44
- className="text-destructive mx-auto h-12 w-12"
45
- fill="none"
46
- viewBox="0 0 24 24"
47
- stroke="currentColor"
48
- >
49
- <path
50
- strokeLinecap="round"
51
- strokeLinejoin="round"
52
- strokeWidth={1.5}
53
- d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.834-2.694-.834-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
54
- />
55
- </svg>
56
- <h1 className="text-foreground text-2xl font-bold">{t('authFailed')}</h1>
57
- <p className="text-muted-foreground text-sm">{error}</p>
58
- <button
59
- type="button"
60
- onClick={() => router.push('/login')}
61
- className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
62
- >
63
- {t('backToLogin')}
64
- </button>
65
- </div>
66
- </div>
67
- );
68
- }
69
-
70
- return (
71
- <div className="flex min-h-[60vh] flex-col items-center justify-center px-4 py-12">
72
- <LoadingSpinner size="lg" />
73
- <p className="text-muted-foreground mt-4">{t('completingSignIn')}</p>
74
- </div>
75
- );
76
- }
77
-
78
- export default function OAuthCallbackPage() {
79
- return (
80
- <Suspense
81
- fallback={
82
- <div className="flex min-h-[60vh] items-center justify-center">
83
- <LoadingSpinner size="lg" />
84
- </div>
85
- }
86
- >
87
- <OAuthCallbackContent />
88
- </Suspense>
89
- );
90
- }
1
+ 'use client';
2
+
3
+ import { Suspense, useEffect, useState, useRef } from 'react';
4
+ import { useRouter, useSearchParams } from 'next/navigation';
5
+ import { useAuth } from '@/providers/store-provider';
6
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
7
+ import { useTranslations } from '@/lib/translations';
8
+
9
+ function OAuthCallbackContent() {
10
+ const router = useRouter();
11
+ const searchParams = useSearchParams();
12
+ const auth = useAuth();
13
+ const [error, setError] = useState<string | null>(null);
14
+ const processedRef = useRef(false);
15
+ const t = useTranslations('auth');
16
+
17
+ const oauthSuccess = searchParams.get('oauth_success');
18
+ const oauthError = searchParams.get('oauth_error');
19
+ // Token is no longer in URL — it was set as httpOnly cookie by /api/auth/oauth-callback
20
+
21
+ useEffect(() => {
22
+ // Prevent double-processing in React StrictMode
23
+ if (processedRef.current) return;
24
+ processedRef.current = true;
25
+
26
+ if (oauthError) {
27
+ setError(oauthError);
28
+ return;
29
+ }
30
+
31
+ if (oauthSuccess === 'true') {
32
+ // Cookie was already set by the API route; refresh auth state
33
+ auth.login().then(() => {
34
+ router.push('/');
35
+ });
36
+ } else {
37
+ setError(t('authFailedDesc'));
38
+ }
39
+ }, [oauthSuccess, oauthError, auth, router, t]);
40
+
41
+ if (error) {
42
+ return (
43
+ <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
44
+ <div className="w-full max-w-md space-y-4 text-center">
45
+ <svg
46
+ className="text-destructive mx-auto h-12 w-12"
47
+ fill="none"
48
+ viewBox="0 0 24 24"
49
+ stroke="currentColor"
50
+ >
51
+ <path
52
+ strokeLinecap="round"
53
+ strokeLinejoin="round"
54
+ strokeWidth={1.5}
55
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.834-2.694-.834-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
56
+ />
57
+ </svg>
58
+ <h1 className="text-foreground text-2xl font-bold">{t('authFailed')}</h1>
59
+ <p className="text-muted-foreground text-sm">{error}</p>
60
+ <button
61
+ type="button"
62
+ onClick={() => router.push('/login')}
63
+ className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
64
+ >
65
+ {t('backToLogin')}
66
+ </button>
67
+ </div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ return (
73
+ <div className="flex min-h-[60vh] flex-col items-center justify-center px-4 py-12">
74
+ <LoadingSpinner size="lg" />
75
+ <p className="text-muted-foreground mt-4">{t('completingSignIn')}</p>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ export default function OAuthCallbackPage() {
81
+ return (
82
+ <Suspense
83
+ fallback={
84
+ <div className="flex min-h-[60vh] items-center justify-center">
85
+ <LoadingSpinner size="lg" />
86
+ </div>
87
+ }
88
+ >
89
+ <OAuthCallbackContent />
90
+ </Suspense>
91
+ );
92
+ }