@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mars-stack/cli",
3
- "version": "7.0.4",
3
+ "version": "7.0.5",
4
4
  "description": "MARS CLI: scaffold, configure, and maintain SaaS apps",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 # Run Vitest unit tests
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
@@ -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 { useCSRF } from '@mars-stack/core/auth/hooks';
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 } = useCSRF();
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 data = await response.json();
34
- setServerError(data.error || 'Failed to send reset email');
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 { useCSRF, usePasswordStrength } from '@mars-stack/core/auth/hooks';
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 } = useCSRF();
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
- setServerError(data.error || 'Failed to create account');
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 { useCSRF, usePasswordStrength } from '@mars-stack/core/auth/hooks';
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 } = useCSRF();
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
- setServerError(data.error || 'Failed to reset password');
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 { useCSRF } from '@mars-stack/core/auth/hooks';
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 } = useCSRF();
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
- setServerError(data.error || 'Failed to sign in');
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(data.error || 'Verification failed');
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 data: { error?: string } = await response.json();
189
- throw new Error(data.error ?? 'Failed to create portal session');
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
- setNameStatus({ type: 'error', message: data.error || 'Failed to update name' });
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 data = await response.json();
133
- setSessionsStatus({ type: 'error', message: data.error || 'Failed to revoke session.' });
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 data = await response.json();
155
- setSessionsStatus({ type: 'error', message: data.error || 'Failed to revoke sessions.' });
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
- setPasswordStatus({ type: 'error', message: data.error || 'Failed to change password' });
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 data: { error?: string } = await response.json();
202
- throw new Error(data.error ?? 'Failed to create checkout session');
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 <AuthProvider>{children}</AuthProvider>;
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', { method: 'POST', credentials: 'include' });
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
+ }
@@ -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({