create-ailk 0.11.1 → 0.11.2

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.
Files changed (34) hide show
  1. package/dist/content-payloads.js +2 -2
  2. package/dist/module-architecture.js +11 -0
  3. package/package.json +1 -1
  4. package/templates/.env.example +13 -3
  5. package/templates/apps/api/.env.example +5 -0
  6. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  7. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  8. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  9. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  10. package/templates/apps/api/src/config/index.ts +136 -0
  11. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  12. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  13. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  14. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  15. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  16. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  17. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  18. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  19. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  20. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  21. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  22. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  23. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/auth.ts +19 -21
  25. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  26. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  27. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  28. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  29. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  30. package/templates/apps/api/src/server.ts +21 -16
  31. package/templates/apps/web/app/[locale]/layout.tsx +57 -56
  32. package/templates/apps/web/auth.ts +73 -13
  33. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  34. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
@@ -17,6 +17,16 @@ export interface Config {
17
17
  PORT: number;
18
18
  FRONTEND_URL: string;
19
19
 
20
+ // The browser origins this API trusts (#7395) — the one list CORS, the
21
+ // cookie-forwarded write check (middleware/auth.ts) and the checkout
22
+ // redirect lock (routes/checkout/sessions.ts) all read. Resolved once, at
23
+ // startup, by resolveTrustedOrigins() below from FRONTEND_URL's origin plus
24
+ // the opt-in AILK_TRUSTED_ORIGINS (a comma-separated list of exact origins,
25
+ // for a hub that serves its Admin app and spoke sites). Frozen; no request
26
+ // input can add to it, and no entry can be `*` or a wildcard host.
27
+ TRUSTED_ORIGINS: readonly string[];
28
+ AILK_TRUSTED_ORIGINS?: string;
29
+
20
30
  // Local Database (for auth: User, Session, ApiToken, etc.). Required in
21
31
  // production; unset elsewhere starts the API database-less (#6482).
22
32
  DATABASE_URL?: string;
@@ -94,6 +104,103 @@ export interface Config {
94
104
  CAL_WEBHOOK_SECRET?: string;
95
105
  }
96
106
 
107
+ /** Why an AILK_TRUSTED_ORIGINS entry was refused (#7395 D2). */
108
+ export type TrustedOriginRefusal =
109
+ | 'wildcard'
110
+ | 'unparseable'
111
+ | 'scheme'
112
+ | 'not-canonical'
113
+ | 'trailing-dot';
114
+
115
+ /** The zero-config development origin (`apps/web`'s `pnpm dev`). */
116
+ const DEV_FRONTEND_ORIGIN = 'http://localhost:3000';
117
+
118
+ /** `FRONTEND_URL`'s origin, or null when it is unusable as one (#7395 D2). */
119
+ function primaryOrigin(raw: string): string | null {
120
+ // A comma means a list was put in the wrong variable:
121
+ // new URL('https://a.example,https://b.example').origin is 'https://a.example,https'.
122
+ if (raw.includes('*') || raw.includes(',')) return null;
123
+ let parsed: URL;
124
+ try {
125
+ parsed = new URL(raw);
126
+ } catch {
127
+ return null;
128
+ }
129
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
130
+ // WHATWG decodes a percent-encoded host: `https://%2A.example.com` has origin
131
+ // `https://*.example.com`, which the raw-string check above cannot see.
132
+ if (parsed.origin.includes('*')) return null;
133
+ return parsed.origin;
134
+ }
135
+
136
+ /** Why one AILK_TRUSTED_ORIGINS entry is refused, or null when it is an exact origin. */
137
+ function refuseExtra(entry: string, nodeEnv: Config['NODE_ENV']): TrustedOriginRefusal | null {
138
+ // WHATWG parses `https://*.example.com` and keeps it as its own origin, so
139
+ // the wildcard is refused by name, before the parse (spec § 1.3).
140
+ if (entry.includes('*')) return 'wildcard';
141
+ let parsed: URL;
142
+ try {
143
+ parsed = new URL(entry);
144
+ } catch {
145
+ return 'unparseable';
146
+ }
147
+ if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && nodeEnv !== 'production')) {
148
+ return 'scheme';
149
+ }
150
+ // Written exactly as a browser serialises an Origin header: lower-case
151
+ // punycode host, no default port, path, trailing slash or userinfo.
152
+ if (entry !== parsed.origin) return 'not-canonical';
153
+ // `https://a.example.` is a different origin from `https://a.example`.
154
+ if (parsed.hostname.endsWith('.')) return 'trailing-dot';
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * The trusted browser-origin allowlist (#7395 D2). Pure; `validateConfig()`
160
+ * calls it once.
161
+ *
162
+ * - `FRONTEND_URL` is normalised to its origin, as the checkout lock always
163
+ * did; unset outside production it is the dev default. Unset in production,
164
+ * or unusable (unparseable, not http(s), or containing `*` or `,`), it
165
+ * contributes nothing, and `primaryUnusable` reports the latter.
166
+ * - `AILK_TRUSTED_ORIGINS` entries must already be exact origins, https-only in
167
+ * production. A refused entry is reported by 1-based position and reason —
168
+ * never its text, which may carry userinfo or a misplaced secret.
169
+ *
170
+ * The result is de-duplicated in first-seen order and frozen.
171
+ */
172
+ export function resolveTrustedOrigins(env: {
173
+ FRONTEND_URL?: string | undefined;
174
+ AILK_TRUSTED_ORIGINS?: string | undefined;
175
+ NODE_ENV: Config['NODE_ENV'];
176
+ }): {
177
+ origins: readonly string[];
178
+ refused: { position: number; reason: TrustedOriginRefusal }[];
179
+ primaryUnusable: boolean;
180
+ } {
181
+ const origins: string[] = [];
182
+ const refused: { position: number; reason: TrustedOriginRefusal }[] = [];
183
+ let primaryUnusable = false;
184
+
185
+ if (env.FRONTEND_URL) {
186
+ const primary = primaryOrigin(env.FRONTEND_URL);
187
+ if (primary === null) primaryUnusable = true;
188
+ else origins.push(primary);
189
+ } else if (env.NODE_ENV !== 'production') {
190
+ origins.push(DEV_FRONTEND_ORIGIN);
191
+ }
192
+
193
+ (env.AILK_TRUSTED_ORIGINS ?? '').split(',').forEach((piece, index) => {
194
+ const entry = piece.trim();
195
+ if (!entry) return;
196
+ const reason = refuseExtra(entry, env.NODE_ENV);
197
+ if (reason !== null) refused.push({ position: index + 1, reason });
198
+ else origins.push(entry);
199
+ });
200
+
201
+ return { origins: Object.freeze([...new Set(origins)]), refused, primaryUnusable };
202
+ }
203
+
97
204
  /** Enabled modules whose state lives in the database (see validateConfig). */
98
205
  const DATABASE_MODULES = [
99
206
  'commerce',
@@ -135,10 +242,39 @@ function validateConfig(): Config {
135
242
  );
136
243
  }
137
244
 
245
+ // #7395 D3 — a refused AILK_TRUSTED_ORIGINS entry stops the API in every
246
+ // environment: the variable is opt-in, and an entry dropped silently would
247
+ // resurface as the very 401 it was added to fix. Position and reason only,
248
+ // never the entry's text. console.error too, because pino-pretty's worker
249
+ // transport can drop the last line written before exit.
250
+ const trusted = resolveTrustedOrigins({
251
+ FRONTEND_URL: process.env.FRONTEND_URL,
252
+ AILK_TRUSTED_ORIGINS: process.env.AILK_TRUSTED_ORIGINS,
253
+ NODE_ENV: nodeEnv,
254
+ });
255
+ if (trusted.refused.length > 0) {
256
+ const message =
257
+ '❌ AILK_TRUSTED_ORIGINS has entries that are not exact http(s) origins (https only in production; no `*`, path, trailing slash or userinfo)';
258
+ logger.error({ refused: trusted.refused }, message);
259
+ console.error(message, JSON.stringify({ refused: trusted.refused }));
260
+ process.exit(1);
261
+ }
262
+ // #7395 D5 — a set-but-unusable FRONTEND_URL predates this list, so it is
263
+ // logged rather than fatal. The value is never echoed.
264
+ if (trusted.primaryUnusable) {
265
+ logger.error(
266
+ "FRONTEND_URL is set but is not an absolute http(s) URL without '*' or ',' — it is not a trusted origin",
267
+ );
268
+ }
269
+
138
270
  return {
139
271
  NODE_ENV: nodeEnv,
140
272
  PORT: parseInt(process.env.PORT || '3002', 10),
141
273
  FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:3000',
274
+ TRUSTED_ORIGINS: trusted.origins,
275
+ ...(process.env.AILK_TRUSTED_ORIGINS !== undefined && {
276
+ AILK_TRUSTED_ORIGINS: process.env.AILK_TRUSTED_ORIGINS,
277
+ }),
142
278
 
143
279
  ...(process.env.DATABASE_URL !== undefined && {
144
280
  DATABASE_URL: process.env.DATABASE_URL,
@@ -37,7 +37,7 @@ import { authPreHandlerPlugin } from "../../auth.js";
37
37
  function buildApp(): FastifyInstance {
38
38
  const app = Fastify();
39
39
  app.register(authPreHandlerPlugin, {
40
- frontendOrigin: "https://app.example.com",
40
+ trustedOrigins: ["https://app.example.com"],
41
41
  });
42
42
  app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
43
43
  return app;
@@ -38,7 +38,7 @@ const BEARER = "tok_live_xyz";
38
38
 
39
39
  function buildApp(): FastifyInstance {
40
40
  const app = Fastify();
41
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
41
+ app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
42
42
  app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
43
43
  app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
44
44
  return app;
@@ -33,7 +33,7 @@ const BEARER = "tok_live_abc";
33
33
 
34
34
  function buildApp(): FastifyInstance {
35
35
  const app = Fastify();
36
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
36
+ app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
37
37
  app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
38
38
  return app;
39
39
  }
@@ -35,7 +35,7 @@ const COOKIE_NAME = "better-auth.session_token";
35
35
 
36
36
  function buildApp(): FastifyInstance {
37
37
  const app = Fastify();
38
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
38
+ app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
39
39
  app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
40
40
  app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
41
41
  // HEAD is auto-registered by Fastify when GET is declared; no explicit handler needed.
@@ -72,7 +72,7 @@ describe("S13 adversarial — CSRF on cookie path", () => {
72
72
  expect(JSON.parse(res.body)).toEqual({ error: "unauthorized" });
73
73
  });
74
74
 
75
- it("rejects POST when Origin scheme differs from the configured frontendOrigin", async () => {
75
+ it("rejects POST when Origin scheme differs from the configured trusted origin", async () => {
76
76
  app = buildApp();
77
77
  await app.ready();
78
78
 
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @file csrf-trusted-origins.test.ts (#7395 AC-3)
3
+ *
4
+ * The cookie path's CSRF check against a multi-origin allowlist. A spoke site
5
+ * forwards the visitor's cookie and the browser's real `Origin` to the hub
6
+ * API, so a write is accepted iff that `Origin` is EXACTLY one of the
7
+ * configured trusted origins — never a prefix, a suffix or the first entry
8
+ * alone — and an empty list refuses every cookie write in every environment
9
+ * (the dev fail-open is gone, spec D4/D6.2). The non-rejecting resolver the
10
+ * checkout route uses applies the same check (#6866 D2).
11
+ */
12
+
13
+ import Fastify from "fastify";
14
+ import type { FastifyInstance } from "fastify";
15
+
16
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
17
+ jest.mock("../../../lib/prisma.js", () => ({
18
+ prisma: {
19
+ session: { findUnique: jest.fn() },
20
+ apiToken: {
21
+ findUnique: jest.fn(),
22
+ update: jest.fn().mockResolvedValue(undefined),
23
+ },
24
+ },
25
+ }));
26
+
27
+ import { prisma } from "../../../lib/prisma.js";
28
+ import { authPreHandlerPlugin, resolveOptionalCookieUser } from "../../auth.js";
29
+
30
+ const HUB = "https://hub.example";
31
+ const SPOKE_A = "https://spoke-a.example";
32
+ const TRUSTED = [HUB, SPOKE_A];
33
+ const COOKIE = "better-auth.session_token=sess_tok_abc";
34
+
35
+ function buildApp(trustedOrigins: readonly string[]): FastifyInstance {
36
+ const app = Fastify();
37
+ app.register(authPreHandlerPlugin, { trustedOrigins });
38
+ app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
39
+ return app;
40
+ }
41
+
42
+ function buildResolverApp(trustedOrigins: readonly string[]): FastifyInstance {
43
+ const app = Fastify();
44
+ app.post("/resolve", async (req) =>
45
+ resolveOptionalCookieUser(req, { trustedOrigins }),
46
+ );
47
+ return app;
48
+ }
49
+
50
+ function post(app: FastifyInstance, url: string, origin?: string) {
51
+ return app.inject({
52
+ method: "POST",
53
+ url,
54
+ headers: { cookie: COOKIE, ...(origin !== undefined && { origin }) },
55
+ });
56
+ }
57
+
58
+ describe("#7395 — cookie-path CSRF against the trusted-origin allowlist", () => {
59
+ const originalNodeEnv = process.env.NODE_ENV;
60
+ const originalDatabaseUrl = process.env.DATABASE_URL;
61
+ let app: FastifyInstance;
62
+
63
+ beforeEach(() => {
64
+ jest.clearAllMocks();
65
+ process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
66
+ (prisma.session.findUnique as jest.Mock).mockResolvedValue({
67
+ id: "ses_1",
68
+ userId: "usr_42",
69
+ expiresAt: new Date(Date.now() + 60_000),
70
+ activeOrganizationId: null,
71
+ activeWorkspaceId: null,
72
+ });
73
+ });
74
+
75
+ afterEach(async () => {
76
+ await app.close();
77
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
78
+ else process.env.NODE_ENV = originalNodeEnv;
79
+ if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
80
+ else process.env.DATABASE_URL = originalDatabaseUrl;
81
+ });
82
+
83
+ it.each([HUB, SPOKE_A])(
84
+ "accepts a cookie POST from the listed origin %s",
85
+ async (origin) => {
86
+ app = buildApp(TRUSTED);
87
+ await app.ready();
88
+
89
+ const res = await post(app, "/echo", origin);
90
+
91
+ expect(res.statusCode).toBe(200);
92
+ expect(res.json()).toEqual({ userId: "usr_42" });
93
+ },
94
+ );
95
+
96
+ it.each([
97
+ ["an unlisted spoke", "https://spoke-b.example"],
98
+ ["a listed origin as a host prefix", "https://spoke-a.example.evil.test"],
99
+ ["an opaque origin", "null"],
100
+ ["a case variant of a listed origin", "HTTPS://HUB.EXAMPLE"],
101
+ ["a listed origin with a trailing slash", "https://hub.example/"],
102
+ ["a listed origin with its default port", "https://hub.example:443"],
103
+ ["a missing Origin", undefined],
104
+ ])("refuses a cookie POST from %s with 401", async (_label, origin) => {
105
+ app = buildApp(TRUSTED);
106
+ await app.ready();
107
+
108
+ const res = await post(app, "/echo", origin);
109
+
110
+ expect(res.statusCode).toBe(401);
111
+ expect(res.json()).toEqual({ error: "unauthorized" });
112
+ });
113
+
114
+ it("refuses every cookie POST with an empty list, even in development", async () => {
115
+ process.env.NODE_ENV = "development";
116
+ app = buildApp([]);
117
+ await app.ready();
118
+
119
+ const res = await post(app, "/echo", HUB);
120
+
121
+ expect(res.statusCode).toBe(401);
122
+ expect(res.json()).toEqual({ error: "unauthorized" });
123
+ });
124
+
125
+ it("resolveOptionalCookieUser returns the user for a listed spoke", async () => {
126
+ app = buildResolverApp(TRUSTED);
127
+ await app.ready();
128
+
129
+ const res = await post(app, "/resolve", SPOKE_A);
130
+
131
+ expect(res.json()).toEqual({ kind: "user", userId: "usr_42" });
132
+ });
133
+
134
+ it("resolveOptionalCookieUser returns none/cross_origin for an unlisted spoke", async () => {
135
+ app = buildResolverApp(TRUSTED);
136
+ await app.ready();
137
+
138
+ const res = await post(app, "/resolve", "https://spoke-b.example");
139
+
140
+ expect(res.json()).toEqual({ kind: "none", reason: "cross_origin" });
141
+ });
142
+ });
@@ -40,7 +40,7 @@ const COOKIE_NAME = "better-auth.session_token";
40
40
  function buildApp(): FastifyInstance {
41
41
  const app = Fastify();
42
42
  app.register(authPreHandlerPlugin, {
43
- frontendOrigin: "https://app.example.com",
43
+ trustedOrigins: ["https://app.example.com"],
44
44
  });
45
45
  app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
46
46
  return app;
@@ -46,7 +46,7 @@ let CURRENT_TIME = 1_000_000_000_000;
46
46
  function buildApp(): FastifyInstance {
47
47
  const app = Fastify();
48
48
  app.register(authPreHandlerPlugin, {
49
- frontendOrigin: "https://app.example.com",
49
+ trustedOrigins: ["https://app.example.com"],
50
50
  });
51
51
  app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
52
52
  return app;
@@ -37,7 +37,7 @@ const BEARER_HASH = createHash("sha256").update(BEARER).digest("hex");
37
37
 
38
38
  function buildApp(): FastifyInstance {
39
39
  const app = Fastify();
40
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
40
+ app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
41
41
  app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
42
42
  app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
43
43
  return app;
@@ -27,7 +27,7 @@ import { authPreHandlerPlugin } from "../auth.js";
27
27
  function buildApp(): FastifyInstance {
28
28
  const app = Fastify();
29
29
  app.register(authPreHandlerPlugin, {
30
- frontendOrigin: "https://app.example.com",
30
+ trustedOrigins: ["https://app.example.com"],
31
31
  });
32
32
  app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
33
33
  return app;
@@ -33,7 +33,7 @@ const FRONTEND = "https://app.example.com";
33
33
 
34
34
  function buildApp(): FastifyInstance {
35
35
  const app = Fastify();
36
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
36
+ app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
37
37
  app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
38
38
  app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
39
39
  return app;
@@ -31,7 +31,7 @@ const echoHandler = jest.fn(async () => ({ ok: true }));
31
31
  function buildApp(): FastifyInstance {
32
32
  const app = Fastify();
33
33
  app.register(authPreHandlerPlugin, {
34
- frontendOrigin: "https://app.example.com",
34
+ trustedOrigins: ["https://app.example.com"],
35
35
  });
36
36
  app.get("/echo", echoHandler);
37
37
  app.post("/echo", echoHandler);
@@ -27,7 +27,7 @@ import { authPreHandlerPlugin } from "../auth.js";
27
27
  function buildApp(): FastifyInstance {
28
28
  const app = Fastify();
29
29
  app.register(authPreHandlerPlugin, {
30
- frontendOrigin: "https://app.example.com",
30
+ trustedOrigins: ["https://app.example.com"],
31
31
  });
32
32
  app.get("/echo", async () => ({ ok: true }));
33
33
  return app;
@@ -28,7 +28,7 @@ const COOKIE = "better-auth.session_token=sess_tok_abc.signature";
28
28
  function buildApp(): FastifyInstance {
29
29
  const app = Fastify();
30
30
  app.post("/resolve", async (req) =>
31
- resolveOptionalCookieUser(req, { frontendOrigin: FRONTEND }),
31
+ resolveOptionalCookieUser(req, { trustedOrigins: [FRONTEND] }),
32
32
  );
33
33
  return app;
34
34
  }
@@ -8,9 +8,10 @@
8
8
  * Looked up directly against the `Session` table (the row Better Auth's
9
9
  * database adapter creates on sign-in). Cookie wins when both modes are
10
10
  * present (locked by brief decision OQ-D / spec §3 "Both present").
11
- * Non-GET requests with a cookie are required to carry a same-origin
12
- * Origin header — the lightweight CSRF mechanism agreed in OQ-D. Bearer-
13
- * authenticated callers skip the origin check (non-browser flow).
11
+ * Non-GET requests with a cookie are required to carry an Origin header
12
+ * that is exactly one of the configured trusted origins (#7395) — the
13
+ * lightweight CSRF mechanism agreed in OQ-D. Bearer-authenticated callers
14
+ * skip the origin check (non-browser flow).
14
15
  *
15
16
  * 2. Bearer path — Authorization: Bearer <token>. The token is sha256-hashed
16
17
  * and matched against `ApiToken.token` (stored as hash). Revoked / expired
@@ -120,27 +121,21 @@ function readBearer(request: FastifyRequest): string | undefined {
120
121
  }
121
122
 
122
123
  /**
123
- * Same-origin CSRF check for cookie-authenticated mutating requests.
124
+ * CSRF check for cookie-authenticated mutating requests (#7395).
124
125
  * Returns true if the request is safe (GET/HEAD/OPTIONS) or the Origin header
125
- * matches the configured frontend origin. False otherwise.
126
+ * is exactly one of the trusted origins. False otherwise — including for any
127
+ * write when the list is empty, in every environment.
126
128
  */
127
129
  function isOriginAllowed(
128
130
  request: FastifyRequest,
129
- frontendOrigin: string | undefined,
131
+ trustedOrigins: readonly string[],
130
132
  ): boolean {
131
133
  const method = request.method.toUpperCase();
132
134
  if (method === "GET" || method === "HEAD" || method === "OPTIONS")
133
135
  return true;
134
- if (!frontendOrigin) {
135
- // No FRONTEND_URL configured: fail OPEN in dev/test (zero-config OSS
136
- // boot), fail CLOSED in production (security review M1 — with the
137
- // multi-tenant module's state-changing routes, an unset origin must not
138
- // silently disable the CSRF check in a real deployment).
139
- return process.env.NODE_ENV !== "production";
140
- }
141
136
  const origin = request.headers.origin;
142
137
  if (!origin) return false;
143
- return origin === frontendOrigin;
138
+ return trustedOrigins.includes(origin);
144
139
  }
145
140
 
146
141
  type SessionRow = {
@@ -187,8 +182,11 @@ async function resolveBearerToken(
187
182
  }
188
183
 
189
184
  export type AuthPreHandlerOptions = {
190
- /** Configured frontend origin used for the CSRF same-origin check. */
191
- frontendOrigin?: string | undefined;
185
+ /**
186
+ * The browser origins a cookie-authenticated write may come from — exact
187
+ * matches only. server.ts passes `config.TRUSTED_ORIGINS` (#7395).
188
+ */
189
+ trustedOrigins: readonly string[];
192
190
  };
193
191
 
194
192
  /**
@@ -214,7 +212,7 @@ export type OptionalCookieUser =
214
212
  * route's buyer stamp).
215
213
  *
216
214
  * Applies the cookie path's own rules — the same private cookie reader,
217
- * Session lookup, expiry comparison and same-origin check as
215
+ * Session lookup, expiry comparison and trusted-origin check as
218
216
  * `makeAuthPreHandler` — so the two paths cannot drift. Reads the COOKIE only:
219
217
  * a bearer `ApiToken` never identifies a user here. Never writes a reply and
220
218
  * never throws; every failure is a `none` with its reason. The cookie is
@@ -238,7 +236,7 @@ export async function resolveOptionalCookieUser(
238
236
  if (session.expiresAt.getTime() <= getClock().now()) {
239
237
  return { kind: "none", reason: "expired" };
240
238
  }
241
- if (!isOriginAllowed(request, opts.frontendOrigin)) {
239
+ if (!isOriginAllowed(request, opts.trustedOrigins)) {
242
240
  return { kind: "none", reason: "cross_origin" };
243
241
  }
244
242
  // Brand assertion at the Prisma read edge (ADR-0031 rule 5), as above.
@@ -251,8 +249,8 @@ export async function resolveOptionalCookieUser(
251
249
  * Exported standalone so route-test suites can register it directly without
252
250
  * spinning up the full server module.
253
251
  */
254
- export function makeAuthPreHandler(opts: AuthPreHandlerOptions = {}) {
255
- const { frontendOrigin } = opts;
252
+ export function makeAuthPreHandler(opts: AuthPreHandlerOptions) {
253
+ const { trustedOrigins } = opts;
256
254
  return async function authPreHandler(
257
255
  request: FastifyRequest,
258
256
  reply: FastifyReply,
@@ -272,7 +270,7 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions = {}) {
272
270
  () => undefined,
273
271
  );
274
272
  if (session && session.expiresAt.getTime() > now) {
275
- if (!isOriginAllowed(request, frontendOrigin)) {
273
+ if (!isOriginAllowed(request, trustedOrigins)) {
276
274
  reply.code(401).send(UNAUTHORIZED);
277
275
  return;
278
276
  }
@@ -42,7 +42,7 @@ const FRONTEND = "https://app.example.com";
42
42
 
43
43
  async function buildApp(): Promise<FastifyInstance> {
44
44
  const app = Fastify();
45
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
45
+ app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
46
46
  app.register(tokenRoutes);
47
47
  await app.ready();
48
48
  return app;
@@ -37,7 +37,7 @@ const FRONTEND = "https://app.example.com";
37
37
 
38
38
  async function buildApp(): Promise<FastifyInstance> {
39
39
  const app = Fastify();
40
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
40
+ app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
41
41
  app.register(userRoutes);
42
42
  await app.ready();
43
43
  return app;