@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 +13 -0
- package/package.json +1 -1
- package/template/AGENTS.md +17 -1
- package/template/README.md +30 -0
- package/template/package.json +1 -1
- package/template/scripts/check-csrf-client-fetch.mjs +113 -0
- package/template/src/__tests__/template-readme-contract.test.ts +21 -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/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
package/template/AGENTS.md
CHANGED
|
@@ -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 #
|
|
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).
|
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');
|
|
@@ -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 {
|
|
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({
|