@mars-stack/cli 7.0.4 → 7.0.5
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 +1 -1
- package/template/AGENTS.md +2 -1
- package/template/package.json +1 -1
- package/template/scripts/check-csrf-client-fetch.mjs +113 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +6 -5
- package/template/src/app/(auth)/register/page.tsx +8 -5
- package/template/src/app/(auth)/reset-password/page.tsx +6 -5
- package/template/src/app/(auth)/sign-in/page.tsx +7 -6
- package/template/src/app/(auth)/verify/[token]/page.tsx +8 -5
- package/template/src/app/(protected)/settings/billing/page.tsx +6 -2
- package/template/src/app/(protected)/settings/page.tsx +16 -12
- package/template/src/app/pricing/page.tsx +6 -3
- package/template/src/app/providers.tsx +6 -1
- package/template/src/features/auth/context/AuthContext.tsx +14 -1
- package/template/src/features/uploads/components/FileList.tsx +7 -1
- package/template/src/lib/csrf-context.tsx +21 -0
- package/template/src/lib/read-api-error.test.ts +53 -0
- package/template/src/lib/read-api-error.ts +59 -0
- package/template/src/proxy.ts +1 -0
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -64,6 +64,7 @@ src/
|
|
|
64
64
|
5. **Parse data at the boundary** — Zod validation on every API route input.
|
|
65
65
|
6. **User-scoped queries use session userId** — Never accept userId from request params for authorization.
|
|
66
66
|
7. **Constant-time comparison for secrets** — Use `constantTimeEqual` from `@mars-stack/core`, never `===`.
|
|
67
|
+
8. **CSRF on client mutations** — Spread `getCSRFHeaders()` from `useCSRFContext()` into mutating `fetch` calls to `/api/*` (see `src/proxy.ts` for exemptions). On error responses, use `readApiError` from `@/lib/read-api-error` instead of blind `response.json()` so plain-text 403 bodies do not throw `SyntaxError`.
|
|
67
68
|
|
|
68
69
|
## Config
|
|
69
70
|
|
|
@@ -79,7 +80,7 @@ src/
|
|
|
79
80
|
```bash
|
|
80
81
|
yarn dev # ensure-db (embedded Postgres) + Next.js — use this, not raw next dev
|
|
81
82
|
yarn build # Production build
|
|
82
|
-
yarn test #
|
|
83
|
+
yarn test # Vitest + CSRF client fetch guard (`scripts/check-csrf-client-fetch.mjs`)
|
|
83
84
|
yarn test:e2e # Run Playwright e2e tests
|
|
84
85
|
yarn lint # ESLint
|
|
85
86
|
yarn db:push # Push Prisma schema to database
|
package/template/package.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"build": "prisma generate && next build",
|
|
13
13
|
"start": "next start",
|
|
14
14
|
"lint": "eslint .",
|
|
15
|
-
"test": "vitest run",
|
|
15
|
+
"test": "vitest run && node scripts/check-csrf-client-fetch.mjs",
|
|
16
16
|
"test:watch": "vitest",
|
|
17
17
|
"test:coverage": "vitest run --coverage",
|
|
18
18
|
"test:e2e": "playwright test",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Guard rail: client `fetch` to /api/ with mutating methods should include getCSRFHeaders()
|
|
4
|
+
* or a documented exemption (see template/src/proxy.ts csrfExemptRoutes).
|
|
5
|
+
*
|
|
6
|
+
* Run: node scripts/check-csrf-client-fetch.mjs (from template/)
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const srcRoot = join(__dirname, '..', 'src');
|
|
14
|
+
|
|
15
|
+
const MUTATING = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
|
|
16
|
+
|
|
17
|
+
/** Paths exempt from CSRF in proxy — keep in sync with template/src/proxy.ts */
|
|
18
|
+
const EXEMPT_PATH_PREFIXES = [
|
|
19
|
+
'/api/csrf',
|
|
20
|
+
'/api/webhooks',
|
|
21
|
+
'/api/auth/verify',
|
|
22
|
+
'/api/auth/reset',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const IGNORED_DIRS = new Set(['node_modules', '.next']);
|
|
26
|
+
|
|
27
|
+
function walk(dir, out) {
|
|
28
|
+
for (const name of readdirSync(dir)) {
|
|
29
|
+
if (IGNORED_DIRS.has(name)) continue;
|
|
30
|
+
const full = join(dir, name);
|
|
31
|
+
const st = statSync(full);
|
|
32
|
+
if (st.isDirectory()) walk(full, out);
|
|
33
|
+
else if (/\.(tsx|ts)$/.test(name) && !name.endsWith('.test.ts')) out.push(full);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isExemptApiPath(urlExpr) {
|
|
38
|
+
const s = urlExpr.replace(/\s+/g, '');
|
|
39
|
+
for (const prefix of EXEMPT_PATH_PREFIXES) {
|
|
40
|
+
if (s.includes(`'${prefix}`) || s.includes(`\`${prefix}`) || s.includes(`"${prefix}`)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const files = [];
|
|
48
|
+
walk(srcRoot, files);
|
|
49
|
+
|
|
50
|
+
let failures = 0;
|
|
51
|
+
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const content = readFileSync(file, 'utf8');
|
|
54
|
+
const lines = content.split('\n');
|
|
55
|
+
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < lines.length) {
|
|
58
|
+
const line = lines[i];
|
|
59
|
+
if (!line.includes('fetch(')) {
|
|
60
|
+
i += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const start = i;
|
|
65
|
+
let depth = 0;
|
|
66
|
+
let j = i;
|
|
67
|
+
let block = '';
|
|
68
|
+
while (j < lines.length) {
|
|
69
|
+
const l = lines[j];
|
|
70
|
+
block += `${l}\n`;
|
|
71
|
+
for (const ch of l) {
|
|
72
|
+
if (ch === '(') depth += 1;
|
|
73
|
+
if (ch === ')') depth -= 1;
|
|
74
|
+
}
|
|
75
|
+
j += 1;
|
|
76
|
+
if (depth <= 0 && l.includes(')')) break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fetchBlock = block;
|
|
80
|
+
if (!fetchBlock.includes("'/api/") && !fetchBlock.includes('"/api/') && !fetchBlock.includes('`/api/')) {
|
|
81
|
+
i = j;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const methodMatch = fetchBlock.match(/method:\s*['"]([A-Z]+)['"]/);
|
|
86
|
+
const method = methodMatch?.[1];
|
|
87
|
+
if (!method || !MUTATING.has(method)) {
|
|
88
|
+
i = j;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const urlMatch = fetchBlock.match(/fetch\(\s*([^,)]+)/s);
|
|
93
|
+
const urlExpr = urlMatch?.[1]?.trim() ?? '';
|
|
94
|
+
if (isExemptApiPath(urlExpr)) {
|
|
95
|
+
i = j;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!fetchBlock.includes('getCSRFHeaders')) {
|
|
100
|
+
console.error(`${file}:${start + 1}: mutating fetch to /api/ without getCSRFHeaders()`);
|
|
101
|
+
failures += 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
i = j;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (failures > 0) {
|
|
109
|
+
console.error(`\ncheck-csrf-client-fetch: ${failures} issue(s). Add headers: { ...getCSRFHeaders() } or document proxy exemption.`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log('check-csrf-client-fetch: OK');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { readApiError } from '@/lib/read-api-error';
|
|
4
|
+
import { useCSRFContext } from '@/lib/csrf-context';
|
|
4
5
|
import { formSchemas } from '@mars-stack/core/auth/validation';
|
|
5
6
|
import { useZodForm } from '@mars-stack/ui/hooks';
|
|
6
7
|
import { Button, Input, Text } from '@mars-stack/ui';
|
|
@@ -9,7 +10,7 @@ import { routes } from '@/config/routes';
|
|
|
9
10
|
import { useState } from 'react';
|
|
10
11
|
|
|
11
12
|
export default function ForgottenPassword() {
|
|
12
|
-
const { getCSRFHeaders } =
|
|
13
|
+
const { getCSRFHeaders } = useCSRFContext();
|
|
13
14
|
const [serverError, setServerError] = useState('');
|
|
14
15
|
const [success, setSuccess] = useState(false);
|
|
15
16
|
const [devLink, setDevLink] = useState<string | null>(null);
|
|
@@ -30,12 +31,12 @@ export default function ForgottenPassword() {
|
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
if (!response.ok) {
|
|
33
|
-
const
|
|
34
|
-
setServerError(
|
|
34
|
+
const { message } = await readApiError(response);
|
|
35
|
+
setServerError(message);
|
|
35
36
|
return;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
const data = await response.json();
|
|
39
|
+
const data = (await response.json()) as { devLink?: string };
|
|
39
40
|
if (data.devLink) {
|
|
40
41
|
setDevLink(data.devLink);
|
|
41
42
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useAuth } from '@/features/auth/context/AuthContext';
|
|
4
|
-
import {
|
|
4
|
+
import { useCSRFContext } from '@/lib/csrf-context';
|
|
5
|
+
import { readApiError } from '@/lib/read-api-error';
|
|
6
|
+
import { usePasswordStrength } from '@mars-stack/core/auth/hooks';
|
|
5
7
|
import { formSchemas, type SignupFormData } from '@mars-stack/core/auth/validation';
|
|
6
8
|
import { routes } from '@/config/routes';
|
|
7
9
|
import { useZodForm } from '@mars-stack/ui/hooks';
|
|
@@ -13,7 +15,7 @@ import { Suspense, useEffect, useState } from 'react';
|
|
|
13
15
|
function SignUpForm() {
|
|
14
16
|
const router = useRouter();
|
|
15
17
|
const { isAuthenticated, isLoading } = useAuth();
|
|
16
|
-
const { getCSRFHeaders, getCSRFFormData } =
|
|
18
|
+
const { getCSRFHeaders, getCSRFFormData } = useCSRFContext();
|
|
17
19
|
const [serverError, setServerError] = useState('');
|
|
18
20
|
|
|
19
21
|
useEffect(() => {
|
|
@@ -44,13 +46,14 @@ function SignUpForm() {
|
|
|
44
46
|
body: JSON.stringify({ ...values, ...getCSRFFormData() }),
|
|
45
47
|
});
|
|
46
48
|
|
|
47
|
-
const data = await response.json();
|
|
48
|
-
|
|
49
49
|
if (!response.ok) {
|
|
50
|
-
|
|
50
|
+
const { message } = await readApiError(response);
|
|
51
|
+
setServerError(message);
|
|
51
52
|
return;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
const data = (await response.json()) as { devLink?: string };
|
|
56
|
+
|
|
54
57
|
sessionStorage.setItem('verify-email', values.email);
|
|
55
58
|
if (data.devLink) {
|
|
56
59
|
sessionStorage.setItem('verify-dev-link', data.devLink);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { readApiError } from '@/lib/read-api-error';
|
|
4
|
+
import { useCSRFContext } from '@/lib/csrf-context';
|
|
5
|
+
import { usePasswordStrength } from '@mars-stack/core/auth/hooks';
|
|
4
6
|
import { formSchemas } from '@mars-stack/core/auth/validation';
|
|
5
7
|
import { routes } from '@/config/routes';
|
|
6
8
|
import { useZodForm } from '@mars-stack/ui/hooks';
|
|
@@ -12,7 +14,7 @@ import { Suspense, useState } from 'react';
|
|
|
12
14
|
function ResetPasswordForm() {
|
|
13
15
|
const searchParams = useSearchParams();
|
|
14
16
|
const router = useRouter();
|
|
15
|
-
const { getCSRFHeaders } =
|
|
17
|
+
const { getCSRFHeaders } = useCSRFContext();
|
|
16
18
|
const [serverError, setServerError] = useState('');
|
|
17
19
|
|
|
18
20
|
const token = searchParams?.get('token') || '';
|
|
@@ -35,10 +37,9 @@ function ResetPasswordForm() {
|
|
|
35
37
|
body: JSON.stringify(values),
|
|
36
38
|
});
|
|
37
39
|
|
|
38
|
-
const data = await response.json();
|
|
39
|
-
|
|
40
40
|
if (!response.ok) {
|
|
41
|
-
|
|
41
|
+
const { message } = await readApiError(response);
|
|
42
|
+
setServerError(message);
|
|
42
43
|
return;
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useAuth } from '@/features/auth/context/AuthContext';
|
|
4
|
-
import {
|
|
3
|
+
import { useAuth, type User } from '@/features/auth/context/AuthContext';
|
|
4
|
+
import { readApiError } from '@/lib/read-api-error';
|
|
5
|
+
import { useCSRFContext } from '@/lib/csrf-context';
|
|
5
6
|
import { formSchemas, type LoginFormData } from '@mars-stack/core/auth/validation';
|
|
6
7
|
import { routes } from '@/config/routes';
|
|
7
8
|
import { useZodForm } from '@mars-stack/ui/hooks';
|
|
@@ -16,7 +17,7 @@ function SignInForm() {
|
|
|
16
17
|
const { login, isAuthenticated, isLoading } = useAuth();
|
|
17
18
|
const callbackUrl = searchParams?.get('callbackUrl') || routes.dashboard;
|
|
18
19
|
const resetMessage = searchParams?.get('message');
|
|
19
|
-
const { getCSRFHeaders, getCSRFFormData } =
|
|
20
|
+
const { getCSRFHeaders, getCSRFFormData } = useCSRFContext();
|
|
20
21
|
const [serverError, setServerError] = useState('');
|
|
21
22
|
|
|
22
23
|
useEffect(() => {
|
|
@@ -38,13 +39,13 @@ function SignInForm() {
|
|
|
38
39
|
body: JSON.stringify({ ...values, ...getCSRFFormData() }),
|
|
39
40
|
});
|
|
40
41
|
|
|
41
|
-
const data = await response.json();
|
|
42
|
-
|
|
43
42
|
if (!response.ok) {
|
|
44
|
-
|
|
43
|
+
const { message } = await readApiError(response);
|
|
44
|
+
setServerError(message);
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const data = (await response.json()) as { user?: User };
|
|
48
49
|
if (data.user) login(data.user);
|
|
49
50
|
router.push(callbackUrl);
|
|
50
51
|
} catch {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { readApiError } from '@/lib/read-api-error';
|
|
4
|
+
import { useCSRFContext } from '@/lib/csrf-context';
|
|
3
5
|
import { routes } from '@/config/routes';
|
|
4
6
|
import { LinkButton, Spinner, Text } from '@mars-stack/ui';
|
|
5
7
|
import Link from 'next/link';
|
|
@@ -7,6 +9,7 @@ import { useParams } from 'next/navigation';
|
|
|
7
9
|
import { Suspense, useEffect, useState } from 'react';
|
|
8
10
|
|
|
9
11
|
function VerifyTokenContent() {
|
|
12
|
+
const { getCSRFHeaders } = useCSRFContext();
|
|
10
13
|
const params = useParams();
|
|
11
14
|
const token = params?.token as string;
|
|
12
15
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
|
@@ -23,18 +26,18 @@ function VerifyTokenContent() {
|
|
|
23
26
|
try {
|
|
24
27
|
const response = await fetch('/api/auth/verify', {
|
|
25
28
|
method: 'POST',
|
|
26
|
-
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
headers: { 'Content-Type': 'application/json', ...getCSRFHeaders() },
|
|
27
30
|
body: JSON.stringify({ token }),
|
|
28
31
|
});
|
|
29
32
|
|
|
30
|
-
const data = await response.json();
|
|
31
|
-
|
|
32
33
|
if (response.ok) {
|
|
34
|
+
const data = (await response.json()) as { message?: string };
|
|
33
35
|
setStatus('success');
|
|
34
36
|
setMessage(data.message || 'Email verified successfully');
|
|
35
37
|
} else {
|
|
38
|
+
const { message } = await readApiError(response);
|
|
36
39
|
setStatus('error');
|
|
37
|
-
setMessage(
|
|
40
|
+
setMessage(message);
|
|
38
41
|
}
|
|
39
42
|
} catch {
|
|
40
43
|
setStatus('error');
|
|
@@ -43,7 +46,7 @@ function VerifyTokenContent() {
|
|
|
43
46
|
};
|
|
44
47
|
|
|
45
48
|
verify();
|
|
46
|
-
}, [token]);
|
|
49
|
+
}, [token, getCSRFHeaders]);
|
|
47
50
|
|
|
48
51
|
if (status === 'loading') {
|
|
49
52
|
return (
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useCSRFContext } from '@/lib/csrf-context';
|
|
4
|
+
import { readApiError } from '@/lib/read-api-error';
|
|
3
5
|
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
4
6
|
import { useSearchParams } from 'next/navigation';
|
|
5
7
|
import { Card, Badge, Button, LinkButton, Spinner } from '@mars-stack/ui';
|
|
@@ -132,6 +134,7 @@ function StripeNotConfigured() {
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
function BillingSettingsContent() {
|
|
137
|
+
const { getCSRFHeaders } = useCSRFContext();
|
|
135
138
|
const searchParams = useSearchParams();
|
|
136
139
|
const [subscription, setSubscription] = useState<SubscriptionRecord | null>(null);
|
|
137
140
|
const [loading, setLoading] = useState(true);
|
|
@@ -182,11 +185,12 @@ function BillingSettingsContent() {
|
|
|
182
185
|
const response = await fetch('/api/protected/billing/portal', {
|
|
183
186
|
method: 'POST',
|
|
184
187
|
credentials: 'include',
|
|
188
|
+
headers: { ...getCSRFHeaders() },
|
|
185
189
|
});
|
|
186
190
|
|
|
187
191
|
if (!response.ok) {
|
|
188
|
-
const
|
|
189
|
-
throw new Error(
|
|
192
|
+
const { message } = await readApiError(response);
|
|
193
|
+
throw new Error(message);
|
|
190
194
|
}
|
|
191
195
|
|
|
192
196
|
const { url } = (await response.json()) as { url: string };
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, type FormEvent } from 'react';
|
|
4
4
|
import { useAuth } from '@/features/auth/context/AuthContext';
|
|
5
|
+
import { useCSRFContext } from '@/lib/csrf-context';
|
|
6
|
+
import { readApiError } from '@/lib/read-api-error';
|
|
5
7
|
import { Card } from '@mars-stack/ui';
|
|
6
8
|
import { FormField, Input, Button, Spinner } from '@mars-stack/ui';
|
|
7
9
|
|
|
@@ -33,6 +35,7 @@ function parseUserAgent(ua: string | null): string {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export default function Settings() {
|
|
38
|
+
const { getCSRFHeaders } = useCSRFContext();
|
|
36
39
|
const { user, isLoading: authLoading, updateUser } = useAuth();
|
|
37
40
|
|
|
38
41
|
const [name, setName] = useState('');
|
|
@@ -99,18 +102,18 @@ export default function Settings() {
|
|
|
99
102
|
try {
|
|
100
103
|
const response = await fetch('/api/protected/user/profile', {
|
|
101
104
|
method: 'PATCH',
|
|
102
|
-
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
headers: { 'Content-Type': 'application/json', ...getCSRFHeaders() },
|
|
103
106
|
credentials: 'include',
|
|
104
107
|
body: JSON.stringify({ name: name.trim() }),
|
|
105
108
|
});
|
|
106
109
|
|
|
107
|
-
const data = await response.json();
|
|
108
|
-
|
|
109
110
|
if (!response.ok) {
|
|
110
|
-
|
|
111
|
+
const { message } = await readApiError(response);
|
|
112
|
+
setNameStatus({ type: 'error', message });
|
|
111
113
|
return;
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
const data = (await response.json()) as { user: { name: string } };
|
|
114
117
|
updateUser({ name: data.user.name });
|
|
115
118
|
setNameStatus({ type: 'success', message: 'Display name updated.' });
|
|
116
119
|
} catch {
|
|
@@ -127,10 +130,11 @@ export default function Settings() {
|
|
|
127
130
|
const response = await fetch(`/api/protected/user/sessions/${sessionId}`, {
|
|
128
131
|
method: 'DELETE',
|
|
129
132
|
credentials: 'include',
|
|
133
|
+
headers: { ...getCSRFHeaders() },
|
|
130
134
|
});
|
|
131
135
|
if (!response.ok) {
|
|
132
|
-
const
|
|
133
|
-
setSessionsStatus({ type: 'error', message
|
|
136
|
+
const { message } = await readApiError(response);
|
|
137
|
+
setSessionsStatus({ type: 'error', message });
|
|
134
138
|
return;
|
|
135
139
|
}
|
|
136
140
|
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
|
@@ -149,10 +153,11 @@ export default function Settings() {
|
|
|
149
153
|
const response = await fetch('/api/protected/user/sessions', {
|
|
150
154
|
method: 'DELETE',
|
|
151
155
|
credentials: 'include',
|
|
156
|
+
headers: { ...getCSRFHeaders() },
|
|
152
157
|
});
|
|
153
158
|
if (!response.ok) {
|
|
154
|
-
const
|
|
155
|
-
setSessionsStatus({ type: 'error', message
|
|
159
|
+
const { message } = await readApiError(response);
|
|
160
|
+
setSessionsStatus({ type: 'error', message });
|
|
156
161
|
return;
|
|
157
162
|
}
|
|
158
163
|
setSessions([]);
|
|
@@ -178,15 +183,14 @@ export default function Settings() {
|
|
|
178
183
|
try {
|
|
179
184
|
const response = await fetch('/api/protected/user/password', {
|
|
180
185
|
method: 'POST',
|
|
181
|
-
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
headers: { 'Content-Type': 'application/json', ...getCSRFHeaders() },
|
|
182
187
|
credentials: 'include',
|
|
183
188
|
body: JSON.stringify({ currentPassword, newPassword }),
|
|
184
189
|
});
|
|
185
190
|
|
|
186
|
-
const data = await response.json();
|
|
187
|
-
|
|
188
191
|
if (!response.ok) {
|
|
189
|
-
|
|
192
|
+
const { message } = await readApiError(response);
|
|
193
|
+
setPasswordStatus({ type: 'error', message });
|
|
190
194
|
return;
|
|
191
195
|
}
|
|
192
196
|
|
|
@@ -5,6 +5,8 @@ import { Card, Badge, Button, LinkButton } from '@mars-stack/ui';
|
|
|
5
5
|
import { appConfig } from '@/config/app.config';
|
|
6
6
|
import { routes } from '@/config/routes';
|
|
7
7
|
import { useAuth } from '@/features/auth/context/AuthContext';
|
|
8
|
+
import { useCSRFContext } from '@/lib/csrf-context';
|
|
9
|
+
import { readApiError } from '@/lib/read-api-error';
|
|
8
10
|
|
|
9
11
|
interface PlanTier {
|
|
10
12
|
name: string;
|
|
@@ -177,6 +179,7 @@ function PlanCard({
|
|
|
177
179
|
export default function PricingPage() {
|
|
178
180
|
const [annual, setAnnual] = useState(false);
|
|
179
181
|
const [loadingPriceId, setLoadingPriceId] = useState<string | null>(null);
|
|
182
|
+
const { getCSRFHeaders } = useCSRFContext();
|
|
180
183
|
const { user } = useAuth();
|
|
181
184
|
const isAuthenticated = !!user;
|
|
182
185
|
const hasStripe = (appConfig.services.payments.provider as string) === 'stripe';
|
|
@@ -192,14 +195,14 @@ export default function PricingPage() {
|
|
|
192
195
|
try {
|
|
193
196
|
const response = await fetch('/api/protected/billing/checkout', {
|
|
194
197
|
method: 'POST',
|
|
195
|
-
headers: { 'Content-Type': 'application/json' },
|
|
198
|
+
headers: { 'Content-Type': 'application/json', ...getCSRFHeaders() },
|
|
196
199
|
credentials: 'include',
|
|
197
200
|
body: JSON.stringify({ priceId }),
|
|
198
201
|
});
|
|
199
202
|
|
|
200
203
|
if (!response.ok) {
|
|
201
|
-
const
|
|
202
|
-
throw new Error(
|
|
204
|
+
const { message } = await readApiError(response);
|
|
205
|
+
throw new Error(message);
|
|
203
206
|
}
|
|
204
207
|
|
|
205
208
|
const { url } = (await response.json()) as { url: string };
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { AuthProvider } from '@/features/auth/context/AuthContext';
|
|
4
|
+
import { CSRFProvider } from '@/lib/csrf-context';
|
|
4
5
|
import type { ReactNode } from 'react';
|
|
5
6
|
|
|
6
7
|
export function Providers({ children }: { children: ReactNode }) {
|
|
7
|
-
return
|
|
8
|
+
return (
|
|
9
|
+
<CSRFProvider>
|
|
10
|
+
<AuthProvider>{children}</AuthProvider>
|
|
11
|
+
</CSRFProvider>
|
|
12
|
+
);
|
|
8
13
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { readApiError } from '@/lib/read-api-error';
|
|
4
|
+
import { useCSRFContext } from '@/lib/csrf-context';
|
|
3
5
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
4
6
|
|
|
5
7
|
export interface User {
|
|
@@ -37,6 +39,7 @@ interface AuthProviderProps {
|
|
|
37
39
|
export function AuthProvider({ children, initialUser = null }: AuthProviderProps) {
|
|
38
40
|
const [user, setUser] = useState<User | null>(initialUser);
|
|
39
41
|
const [isLoading, setIsLoading] = useState(!initialUser);
|
|
42
|
+
const { getCSRFHeaders } = useCSRFContext();
|
|
40
43
|
|
|
41
44
|
const refreshAuth = async () => {
|
|
42
45
|
try {
|
|
@@ -44,6 +47,8 @@ export function AuthProvider({ children, initialUser = null }: AuthProviderProps
|
|
|
44
47
|
const response = await fetch('/api/auth/me', { credentials: 'include' });
|
|
45
48
|
|
|
46
49
|
if (!response.ok) {
|
|
50
|
+
const { message } = await readApiError(response);
|
|
51
|
+
console.warn('Session refresh failed:', message);
|
|
47
52
|
setUser(null);
|
|
48
53
|
return;
|
|
49
54
|
}
|
|
@@ -64,7 +69,15 @@ export function AuthProvider({ children, initialUser = null }: AuthProviderProps
|
|
|
64
69
|
|
|
65
70
|
const logout = async () => {
|
|
66
71
|
try {
|
|
67
|
-
await fetch('/api/auth/logout', {
|
|
72
|
+
const response = await fetch('/api/auth/logout', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
credentials: 'include',
|
|
75
|
+
headers: { ...getCSRFHeaders() },
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
const { message } = await readApiError(response);
|
|
79
|
+
console.warn('Logout response:', message);
|
|
80
|
+
}
|
|
68
81
|
} catch (error) {
|
|
69
82
|
console.error('Logout error:', error);
|
|
70
83
|
} finally {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useCSRFContext } from '@/lib/csrf-context';
|
|
4
|
+
import { readApiError } from '@/lib/read-api-error';
|
|
3
5
|
import { useCallback, useEffect, useState } from 'react';
|
|
4
6
|
import { Button, Badge, Spinner } from '@mars-stack/ui';
|
|
5
7
|
import type { FileRecord } from '../types';
|
|
@@ -49,6 +51,7 @@ interface FileListProps {
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export function FileList({ refreshKey }: FileListProps) {
|
|
54
|
+
const { getCSRFHeaders } = useCSRFContext();
|
|
52
55
|
const [files, setFiles] = useState<FileRecord[]>([]);
|
|
53
56
|
const [loading, setLoading] = useState(true);
|
|
54
57
|
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
|
|
@@ -82,9 +85,12 @@ export function FileList({ refreshKey }: FileListProps) {
|
|
|
82
85
|
const response = await fetch(`/api/protected/files/${fileId}`, {
|
|
83
86
|
method: 'DELETE',
|
|
84
87
|
credentials: 'include',
|
|
88
|
+
headers: { ...getCSRFHeaders() },
|
|
85
89
|
});
|
|
86
90
|
|
|
87
91
|
if (!response.ok) {
|
|
92
|
+
const { message } = await readApiError(response);
|
|
93
|
+
console.warn('Delete file failed:', message);
|
|
88
94
|
const restored = files.find((f) => f.id === fileId);
|
|
89
95
|
if (restored) {
|
|
90
96
|
setFiles((prev) => [...prev, restored]);
|
|
@@ -102,7 +108,7 @@ export function FileList({ refreshKey }: FileListProps) {
|
|
|
102
108
|
return next;
|
|
103
109
|
});
|
|
104
110
|
}
|
|
105
|
-
}, [files]);
|
|
111
|
+
}, [files, getCSRFHeaders]);
|
|
106
112
|
|
|
107
113
|
if (loading) {
|
|
108
114
|
return (
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCSRF } from '@mars-stack/core/auth/hooks';
|
|
4
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
type CSRFContextValue = ReturnType<typeof useCSRF>;
|
|
7
|
+
|
|
8
|
+
const CSRFContext = createContext<CSRFContextValue | null>(null);
|
|
9
|
+
|
|
10
|
+
export function CSRFProvider({ children }: { children: ReactNode }) {
|
|
11
|
+
const value = useCSRF();
|
|
12
|
+
return <CSRFContext.Provider value={value}>{children}</CSRFContext.Provider>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useCSRFContext(): CSRFContextValue {
|
|
16
|
+
const ctx = useContext(CSRFContext);
|
|
17
|
+
if (!ctx) {
|
|
18
|
+
throw new Error('useCSRFContext must be used within CSRFProvider');
|
|
19
|
+
}
|
|
20
|
+
return ctx;
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { readApiError } from './read-api-error';
|
|
3
|
+
|
|
4
|
+
function responseFrom(
|
|
5
|
+
body: string,
|
|
6
|
+
init: { status?: number; contentType?: string } = {},
|
|
7
|
+
): Response {
|
|
8
|
+
const { status = 400, contentType = 'text/plain; charset=utf-8' } = init;
|
|
9
|
+
return new Response(body, {
|
|
10
|
+
status,
|
|
11
|
+
headers: { 'Content-Type': contentType },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('readApiError', () => {
|
|
16
|
+
it('returns JSON error and optional code', async () => {
|
|
17
|
+
const res = responseFrom(JSON.stringify({ error: 'Bad input', code: 'VALIDATION' }), {
|
|
18
|
+
contentType: 'application/json',
|
|
19
|
+
});
|
|
20
|
+
const out = await readApiError(res);
|
|
21
|
+
expect(out.message).toBe('Bad input');
|
|
22
|
+
expect(out.code).toBe('VALIDATION');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('does not throw on 403 plain-text CSRF body', async () => {
|
|
26
|
+
const res = responseFrom('CSRF token validation failed', {
|
|
27
|
+
status: 403,
|
|
28
|
+
contentType: 'text/plain; charset=utf-8',
|
|
29
|
+
});
|
|
30
|
+
const out = await readApiError(res);
|
|
31
|
+
expect(out.message).toBe('CSRF token validation failed');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('uses fallback when body is empty and status is 403', async () => {
|
|
35
|
+
const res = responseFrom('', { status: 403 });
|
|
36
|
+
const out = await readApiError(res);
|
|
37
|
+
expect(out.message).toContain('refreshing');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns plain text when JSON Content-Type but body is not valid JSON', async () => {
|
|
41
|
+
const res = responseFrom('not json', { contentType: 'application/json' });
|
|
42
|
+
const out = await readApiError(res);
|
|
43
|
+
expect(out.message).toBe('not json');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('prefers message field when error is absent', async () => {
|
|
47
|
+
const res = responseFrom(JSON.stringify({ message: 'Rate limited' }), {
|
|
48
|
+
contentType: 'application/json',
|
|
49
|
+
});
|
|
50
|
+
const out = await readApiError(res);
|
|
51
|
+
expect(out.message).toBe('Rate limited');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe parsing for API error responses. Proxies may return **403** with a **plain-text** body;
|
|
3
|
+
* calling `response.json()` on that body throws `SyntaxError` and masks the real failure.
|
|
4
|
+
*
|
|
5
|
+
* @see template/src/proxy.ts — CSRF exemptions must stay aligned with client calls.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ReadApiErrorResult {
|
|
9
|
+
/** User-visible or developer-actionable message */
|
|
10
|
+
message: string;
|
|
11
|
+
/** Optional machine-readable code when the server returned JSON with `code` */
|
|
12
|
+
code?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pickString(value: unknown): string | undefined {
|
|
16
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fallbackMessage(status: number): string {
|
|
21
|
+
if (status === 403) {
|
|
22
|
+
return 'Request blocked. Try refreshing the page, then sign in again if the problem continues.';
|
|
23
|
+
}
|
|
24
|
+
if (status === 401) {
|
|
25
|
+
return 'Sign in required or session expired.';
|
|
26
|
+
}
|
|
27
|
+
return `Request failed (${status}).`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reads a failed `Response` and returns a safe message without throwing on non-JSON bodies.
|
|
32
|
+
* Consumes the response body (do not call `response.json()` afterward).
|
|
33
|
+
*/
|
|
34
|
+
export async function readApiError(response: Response): Promise<ReadApiErrorResult> {
|
|
35
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
36
|
+
const raw = await response.text();
|
|
37
|
+
const trimmed = raw.trim();
|
|
38
|
+
|
|
39
|
+
if (!trimmed) {
|
|
40
|
+
return { message: fallbackMessage(response.status) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (contentType.includes('application/json')) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
46
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
47
|
+
const obj = parsed as Record<string, unknown>;
|
|
48
|
+
const message =
|
|
49
|
+
pickString(obj.error) ?? pickString(obj.message) ?? pickString(obj.title) ?? trimmed;
|
|
50
|
+
const code = pickString(obj.code);
|
|
51
|
+
return code ? { message, code } : { message };
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Malformed JSON — fall through to plain text
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { message: trimmed };
|
|
59
|
+
}
|
package/template/src/proxy.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createCSRFProtection } from '@mars-stack/core/auth/csrf';
|
|
|
3
3
|
import { routes } from '@/config/routes';
|
|
4
4
|
import { NextRequest, NextResponse } from 'next/server';
|
|
5
5
|
|
|
6
|
+
/** CSRF validation is skipped for these path prefixes — keep aligned with `scripts/check-csrf-client-fetch.mjs` and client `fetch` calls. */
|
|
6
7
|
const csrfExemptRoutes = ['/api/csrf', '/api/webhooks', '/api/auth/verify', '/api/auth/reset'];
|
|
7
8
|
|
|
8
9
|
const csrf = createCSRFProtection({
|