@mars-stack/cli 7.0.3 → 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/README.md CHANGED
@@ -36,6 +36,7 @@ mars create my-app
36
36
  ### `mars add feature <name>`
37
37
 
38
38
  Generate a feature module at `src/features/<name>/` with:
39
+
39
40
  - `server/index.ts` -- typed CRUD operations with `import 'server-only'`
40
41
  - `types.ts` -- Zod validation schemas and TypeScript types
41
42
 
@@ -121,6 +122,18 @@ mars upgrade # upgrade to latest
121
122
  mars upgrade --dry-run # preview what would change
122
123
  ```
123
124
 
125
+ ### `mars template sync`
126
+
127
+ Copy upstream **template plumbing** (scripts, auth shell files, and other manifest-listed paths) from the CLI-bundled template into an **existing** Mars project. Requires `.mars/scaffold.json` at the project root (from `mars create`); exits with an error if missing.
128
+
129
+ ```bash
130
+ mars template sync --dry-run # list planned changes, no writes
131
+ mars template sync # default: plumbing category only
132
+ mars template sync --only user-owned --force # overwrite user-owned paths too (backups under .mars/backups/)
133
+ ```
134
+
135
+ **vs `mars upgrade`:** `mars upgrade` bumps Mars npm packages only — it does **not** re-copy template source files. Use `mars template sync` when a CLI release includes fixes to `ensure-db.mjs`, proxy, or other synced paths. See the Mars monorepo [scaffold-template-sync-upgrade](https://github.com/greaveselliott/mars/blob/main/docs/exec-plans/active/scaffold-template-sync-upgrade.md) exec plan for the safety model and path inventory.
136
+
124
137
  ### `mars telemetry <enable|disable>`
125
138
 
126
139
  Opt in or out of anonymous usage analytics. Stored in `~/.marsrc`. Tracks command usage and feature selections only -- never project names, file contents, or env vars.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mars-stack/cli",
3
- "version": "7.0.3",
3
+ "version": "7.0.5",
4
4
  "description": "MARS CLI: scaffold, configure, and maintain SaaS apps",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,10 +64,12 @@ 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
 
70
71
  `src/config/app.config.ts` is the single source of truth for:
72
+
71
73
  - App identity (name, URL, support email)
72
74
  - Feature flags (auth, admin, billing, notifications, etc.)
73
75
  - Service providers (email, storage, database, payments, etc.)
@@ -78,7 +80,7 @@ src/
78
80
  ```bash
79
81
  yarn dev # ensure-db (embedded Postgres) + Next.js — use this, not raw next dev
80
82
  yarn build # Production build
81
- yarn test # Run Vitest unit tests
83
+ yarn test # Vitest + CSRF client fetch guard (`scripts/check-csrf-client-fetch.mjs`)
82
84
  yarn test:e2e # Run Playwright e2e tests
83
85
  yarn lint # ESLint
84
86
  yarn db:push # Push Prisma schema to database
@@ -91,6 +93,19 @@ yarn db:studio # Open Prisma Studio
91
93
 
92
94
  **Email verification (local):** With console email, after register you land on `/verify` and use **“Click here to verify your email”** (no inbox needed). Set `AUTO_VERIFY_EMAIL=true` to skip that for **new** signups instead. `AUTO_VERIFY_EMAIL` applies when `yarn dev` runs or `APP_URL` points at `localhost` / `127.0.0.1`; ignored on Vercel production. Older accounts stay unverified until you use the link or Prisma Studio.
93
95
 
96
+ ## Updating scaffold plumbing (`mars template sync`)
97
+
98
+ When you install a newer **`@mars-stack/cli`**, pull upstream template fixes into this repo with **`mars template sync`** from the project root (requires `.mars/scaffold.json` from `mars create`). If that file is missing, the CLI reports that the directory is not a Mars scaffold — you cannot sync a non-Mars project.
99
+
100
+ - **`mars template sync --dry-run`** — print the planned file operations; no writes.
101
+ - **Default** — sync **plumbing** paths from the bundled template manifest (scripts, shared shell, etc.).
102
+ - **`--force`** — also apply **user-owned** manifest entries; previous files are copied to **`.mars/backups/<timestamp>/`** before overwrite. Use when you deliberately want upstream versions of those paths.
103
+ - **`--only <category>`** — e.g. `plumbing` or `user-owned` (latter typically with `--force`).
104
+
105
+ Deep context: [scaffold-template-sync-upgrade](https://github.com/greaveselliott/mars/blob/main/docs/exec-plans/active/scaffold-template-sync-upgrade.md) in the Mars monorepo (path inventory and safety model). Skill for agents: [.cursor/skills/mars-upgrade-scaffold/](.cursor/skills/mars-upgrade-scaffold/).
106
+
107
+ **vs `mars upgrade`:** `mars upgrade` only bumps Mars **npm** packages and reinstalls — it does **not** re-copy template files. Use template sync when plumbing (e.g. `ensure-db.mjs`) changed in a CLI release.
108
+
94
109
  ## Working Discipline
95
110
 
96
111
  1. **Commit after every step.** When executing a plan, commit after each completed task — not at the end. Version control is the progress log. Each commit message should reference the plan and step (e.g., `feat(billing): add Subscription model (billing-plan step 1.1)`).
@@ -103,6 +118,7 @@ After **generator changes**, **template sync**, or before a **release**, run the
103
118
 
104
119
  ## Pointers
105
120
 
121
+ - **Updating your app:** [README.md](README.md) — entry point; this section and `mars template sync` above.
106
122
  - **Architecture details:** [docs/design-docs/](docs/design-docs/)
107
123
  - **Core beliefs:** [docs/design-docs/core-beliefs.md](docs/design-docs/core-beliefs.md)
108
124
  - **Conversation feedback:** [docs/design-docs/conversation-as-system-record.md](docs/design-docs/conversation-as-system-record.md)
@@ -0,0 +1,30 @@
1
+ # Mars application template
2
+
3
+ This directory is the **Next.js app template** copied by `mars create` into a new project. In the Mars monorepo it is developed and tested here; published **`@mars-stack/cli`** bundles the same tree at release time.
4
+
5
+ ## Updating your Mars app
6
+
7
+ After you upgrade **`@mars-stack/cli`** or want upstream fixes for scaffold **plumbing** (scripts, auth shell files, shared wiring listed in the sync manifest), run from your **project root**:
8
+
9
+ ```bash
10
+ mars template sync --dry-run # preview planned copies
11
+ mars template sync # apply (default: plumbing only)
12
+ ```
13
+
14
+ **Requirements:** the project must be a Mars scaffold with **`.mars/scaffold.json`**. If that file is missing, the CLI exits with an error — this command does not apply to arbitrary Next.js repos.
15
+
16
+ **Flags:**
17
+
18
+ | Flag | Meaning |
19
+ | ------------------- | -------------------------------------------------------------------------------------------------------------- |
20
+ | `--dry-run` | Show what would change; no files written |
21
+ | `--force` | Also sync **user-owned** manifest paths; always backs up replaced files under **`.mars/backups/<timestamp>/`** |
22
+ | `--only <category>` | Limit to `plumbing` (default) or `user-owned` (usually with `--force`) |
23
+
24
+ **Safety:** Default sync overwrites **plumbing** files that the CLI owns (infrastructure). **User-owned** paths are skipped unless you pass **`--force`**, and overwrites are preceded by backups. Do not use `--force` on a dirty tree without reviewing `--dry-run` output.
25
+
26
+ **Not `mars upgrade`:** `mars upgrade` bumps **`@mars-stack/core`** and **`@mars-stack/ui`** in `package.json` and reinstalls — it does **not** copy template source files. Use **both** commands when you need new package versions **and** refreshed plumbing (e.g. `scripts/ensure-db.mjs`).
27
+
28
+ **Further reading (Mars monorepo):** [scaffold-template-sync-upgrade](https://github.com/greaveselliott/mars/blob/main/docs/exec-plans/active/scaffold-template-sync-upgrade.md) — full manifest, path inventory, and task history.
29
+
30
+ **Agents:** see [.cursor/skills/mars-upgrade-scaffold/](.cursor/skills/mars-upgrade-scaffold/) and [AGENTS.md](AGENTS.md).
@@ -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');
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const readmePath = join(__dirname, '../../README.md');
8
+
9
+ /** MARS-046: user-facing template sync docs stay discoverable in copied scaffolds */
10
+ describe('template README (mars template sync)', () => {
11
+ const content = readFileSync(readmePath, 'utf8');
12
+
13
+ it('documents dry-run, force, scaffold fingerprint, backups, and upgrade distinction', () => {
14
+ expect(content).toMatch(/`--dry-run`/);
15
+ expect(content).toMatch(/`--force`/);
16
+ expect(content).toMatch(/\.mars\/scaffold\.json/);
17
+ expect(content).toMatch(/\.mars\/backups/);
18
+ expect(content).toMatch(/`mars upgrade`/);
19
+ expect(content).toMatch(/mars template sync/);
20
+ });
21
+ });
@@ -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({