create-ailk 0.11.0 → 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 (69) hide show
  1. package/dist/content-payloads.js +2 -2
  2. package/dist/lib/scaffold-render-sweep.d.ts +16 -0
  3. package/dist/lib/scaffold-render-sweep.js +52 -3
  4. package/dist/module-architecture.js +11 -0
  5. package/package.json +1 -1
  6. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +8 -8
  7. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +8 -8
  8. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +8 -8
  9. package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +8 -8
  10. package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +8 -8
  11. package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +8 -8
  12. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +8 -8
  13. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +8 -8
  14. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +8 -8
  15. package/templates/.env.example +13 -7
  16. package/templates/apps/api/.env.example +5 -0
  17. package/templates/apps/api/package.json +1 -1
  18. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  19. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  20. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  21. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  22. package/templates/apps/api/src/config/index.ts +136 -0
  23. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  25. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  26. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  27. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  28. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  29. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  30. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  31. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  32. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  33. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  34. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  35. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  36. package/templates/apps/api/src/middleware/auth.ts +19 -21
  37. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  38. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  39. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  40. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  41. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  42. package/templates/apps/api/src/server.ts +21 -16
  43. package/templates/apps/mcp/package.json +1 -1
  44. package/templates/apps/web/CLAUDE.md +1 -1
  45. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +0 -10
  46. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +0 -10
  47. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +1 -14
  48. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +0 -10
  49. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +0 -10
  50. package/templates/apps/web/app/[locale]/for/[slug]/page.tsx +0 -10
  51. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +1 -14
  52. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +0 -10
  53. package/templates/apps/web/app/[locale]/layout.tsx +57 -56
  54. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +0 -10
  55. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +0 -10
  56. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +0 -10
  57. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +1 -15
  58. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +0 -10
  59. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +0 -10
  60. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +0 -10
  61. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +1 -14
  62. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +4 -16
  63. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +3 -15
  64. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +0 -10
  65. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +1 -14
  66. package/templates/apps/web/auth.ts +73 -13
  67. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  68. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
  69. package/versions.json +0 -1
@@ -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;
@@ -91,7 +91,10 @@ import { checkoutRoutes } from "../index";
91
91
  // #6877 — pin the suite's site origin to the fixtures' own origin, so every
92
92
  // anonymous case below sends same-origin redirects. Runs before any
93
93
  // `describe` is collected, so the #6866 block's SAME_ORIGIN reads it too.
94
+ // #7395 — the redirect lock and the buyer's cookie check read the trusted
95
+ // allowlist, not FRONTEND_URL, so the suite pins both.
94
96
  config.FRONTEND_URL = "https://example.com";
97
+ config.TRUSTED_ORIGINS = ["https://example.com"];
95
98
 
96
99
  // ── Helpers ─────────────────────────────────────────────────────────────────
97
100
 
@@ -1298,6 +1301,7 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
1298
1301
  beforeEach(async () => {
1299
1302
  jest.clearAllMocks();
1300
1303
  config.FRONTEND_URL = SITE_ORIGIN;
1304
+ config.TRUSTED_ORIGINS = [SITE_ORIGIN];
1301
1305
  process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
1302
1306
  delete process.env["OSS_API_INTERNAL_SECRET"];
1303
1307
  mockAssertSandboxKey.mockImplementation(() => undefined);
@@ -1328,6 +1332,7 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
1328
1332
  delete process.env["STRIPE_SECRET_KEY"];
1329
1333
  delete process.env["OSS_API_INTERNAL_SECRET"];
1330
1334
  config.FRONTEND_URL = SITE_ORIGIN;
1335
+ config.TRUSTED_ORIGINS = [SITE_ORIGIN];
1331
1336
  if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
1332
1337
  else process.env.DATABASE_URL = originalDatabaseUrl;
1333
1338
  });
@@ -1453,45 +1458,24 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
1453
1458
  expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1454
1459
  });
1455
1460
 
1456
- it.each([
1457
- [
1458
- "an unparseable FRONTEND_URL",
1459
- () => {
1460
- config.FRONTEND_URL = "not a url";
1461
- },
1462
- ],
1463
- [
1464
- "a non-http(s) FRONTEND_URL",
1465
- () => {
1466
- config.FRONTEND_URL = "mailto:ops@example.com";
1467
- },
1468
- ],
1469
- [
1470
- "an unset FRONTEND_URL in production",
1471
- () => {
1472
- delete process.env.FRONTEND_URL;
1473
- config.NODE_ENV = "production";
1474
- },
1475
- ],
1476
- ])(
1477
- "#6877 R8: %s answers 500 config-error with no Stripe call",
1478
- async (_label, arrange) => {
1479
- const envFrontendUrl = process.env.FRONTEND_URL;
1480
- const nodeEnv = config.NODE_ENV;
1481
- try {
1482
- arrange();
1483
- const res = await post(VALID_BODY);
1484
-
1485
- expect(res.statusCode).toBe(500);
1486
- expect(res.json()).toEqual({ error: "config-error" });
1487
- expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1488
- } finally {
1489
- config.FRONTEND_URL = SITE_ORIGIN;
1490
- config.NODE_ENV = nodeEnv;
1491
- process.env.FRONTEND_URL = envFrontendUrl;
1492
- }
1493
- },
1494
- );
1461
+ // #7395 D4 — what an unusable or unset FRONTEND_URL did here before
1462
+ // (#6877 R8) is now an empty trusted list: config resolves both to `[]`
1463
+ // (config/__tests__/trusted-origins.test.ts), and the route answers the
1464
+ // same 500.
1465
+ it("#6877 R8: an empty trusted-origin list answers 500 config-error with no Stripe call", async () => {
1466
+ config.TRUSTED_ORIGINS = [];
1467
+
1468
+ const res = await post(VALID_BODY);
1469
+
1470
+ expect(res.statusCode).toBe(500);
1471
+ expect(res.json()).toEqual({ error: "config-error" });
1472
+ expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1473
+ expect(
1474
+ logLines.some(
1475
+ (l) => l["msg"] === "[checkout] no trusted browser origin is configured",
1476
+ ),
1477
+ ).toBe(true);
1478
+ });
1495
1479
 
1496
1480
  it("#6877 R9: a refused request never reads the session cookie's row", async () => {
1497
1481
  process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
@@ -1514,3 +1498,148 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
1514
1498
  expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1515
1499
  });
1516
1500
  });
1501
+
1502
+ // ── Trusted-origin allowlist (#7395) ──────────────────────────────────────────
1503
+
1504
+ describe("POST /v1/checkout/sessions — trusted-origin allowlist (#7395)", () => {
1505
+ const SITE_ORIGIN = "https://example.com";
1506
+ const SPOKE_A = "https://spoke-a.example";
1507
+ const SESSION_TOKEN = "sess_tok_7395";
1508
+ const COOKIE = `better-auth.session_token=${SESSION_TOKEN}.signature`;
1509
+ const originalDatabaseUrl = process.env.DATABASE_URL;
1510
+
1511
+ let app: FastifyInstance;
1512
+
1513
+ function post(
1514
+ payload: Record<string, unknown>,
1515
+ headers: Record<string, string> = {},
1516
+ ) {
1517
+ return app.inject({
1518
+ method: "POST",
1519
+ url: "/v1/checkout/sessions",
1520
+ headers,
1521
+ payload,
1522
+ });
1523
+ }
1524
+
1525
+ beforeEach(async () => {
1526
+ jest.clearAllMocks();
1527
+ config.TRUSTED_ORIGINS = [SITE_ORIGIN, SPOKE_A];
1528
+ process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
1529
+ delete process.env["OSS_API_INTERNAL_SECRET"];
1530
+ mockAssertSandboxKey.mockImplementation(() => undefined);
1531
+ mockCreateCheckoutSession.mockResolvedValue({
1532
+ sessionId: "cs_test_allow7395",
1533
+ url: "https://checkout.stripe.com/c/pay/cs_test_allow7395",
1534
+ });
1535
+
1536
+ app = buildApp();
1537
+ await app.ready();
1538
+ });
1539
+
1540
+ afterEach(async () => {
1541
+ await app.close();
1542
+ delete process.env["STRIPE_SECRET_KEY"];
1543
+ config.TRUSTED_ORIGINS = [SITE_ORIGIN];
1544
+ (prisma.session.findUnique as jest.Mock).mockReset();
1545
+ (prisma.user.findUnique as jest.Mock).mockReset();
1546
+ if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
1547
+ else process.env.DATABASE_URL = originalDatabaseUrl;
1548
+ });
1549
+
1550
+ it("accepts an anonymous checkout with both redirects on a listed spoke", async () => {
1551
+ const successUrl = `${SPOKE_A}/purchase/success`;
1552
+ const cancelUrl = `${SPOKE_A}/purchase/cancel`;
1553
+
1554
+ const res = await post({ productCode: "ailk-pro", successUrl, cancelUrl });
1555
+
1556
+ expect(res.statusCode).toBe(201);
1557
+ expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
1558
+ expect.objectContaining({ successUrl, cancelUrl }),
1559
+ );
1560
+ });
1561
+
1562
+ it("accepts redirects split across two listed origins", async () => {
1563
+ const successUrl = `${SPOKE_A}/purchase/success`;
1564
+ const cancelUrl = `${SITE_ORIGIN}/pricing`;
1565
+
1566
+ const res = await post({ productCode: "ailk-pro", successUrl, cancelUrl });
1567
+
1568
+ expect(res.statusCode).toBe(201);
1569
+ expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
1570
+ expect.objectContaining({ successUrl, cancelUrl }),
1571
+ );
1572
+ });
1573
+
1574
+ it.each([
1575
+ ["successUrl", "https://spoke-b.example/x"],
1576
+ ["successUrl", "https://spoke-a.example.evil.test/x"],
1577
+ ["cancelUrl", "https://spoke-b.example/x"],
1578
+ ["cancelUrl", "https://spoke-a.example.evil.test/x"],
1579
+ ])(
1580
+ "refuses a %s on the unlisted %j with 400 naming the field and no Stripe call",
1581
+ async (field, url) => {
1582
+ const res = await post({ ...VALID_BODY, [field]: url });
1583
+
1584
+ expect(res.statusCode).toBe(400);
1585
+ const body = res.json();
1586
+ expect(body.error).toBe("validation-error");
1587
+ expect(body.details).toHaveLength(1);
1588
+ expect(body.details[0].path).toEqual([field]);
1589
+ expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1590
+ },
1591
+ );
1592
+
1593
+ it("answers 500 config-error with no Stripe call when the list is empty", async () => {
1594
+ config.TRUSTED_ORIGINS = [];
1595
+
1596
+ const res = await post({
1597
+ ...VALID_BODY,
1598
+ successUrl: `${SPOKE_A}/purchase/success`,
1599
+ });
1600
+
1601
+ expect(res.statusCode).toBe(500);
1602
+ expect(res.json()).toEqual({ error: "config-error" });
1603
+ expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1604
+ });
1605
+
1606
+ it("signs the buyer in for a valid cookie forwarded with a listed spoke's Origin", async () => {
1607
+ process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
1608
+ (prisma.session.findUnique as jest.Mock).mockResolvedValue({
1609
+ id: "ses_1",
1610
+ userId: "usr_buyer",
1611
+ expiresAt: new Date(Date.now() + 60_000),
1612
+ activeOrganizationId: null,
1613
+ activeWorkspaceId: null,
1614
+ });
1615
+ (prisma.user.findUnique as jest.Mock).mockImplementation(
1616
+ async (args: { select: Record<string, boolean> }) => {
1617
+ const row: Record<string, unknown> = {
1618
+ id: "usr_buyer",
1619
+ email: "buyer@example.com",
1620
+ emailVerified: true,
1621
+ billingProvider: null,
1622
+ billingCustomerId: null,
1623
+ };
1624
+ return Object.fromEntries(
1625
+ Object.keys(args.select).map((k) => [k, row[k]]),
1626
+ );
1627
+ },
1628
+ );
1629
+
1630
+ const res = await post(
1631
+ {
1632
+ productCode: "ailk-pro",
1633
+ successUrl: `${SPOKE_A}/purchase/success`,
1634
+ cancelUrl: `${SPOKE_A}/purchase/cancel`,
1635
+ },
1636
+ { cookie: COOKIE, origin: SPOKE_A },
1637
+ );
1638
+
1639
+ expect(res.statusCode).toBe(201);
1640
+ expect(mockCreateCheckoutSession.mock.calls[0][0].buyer).toEqual({
1641
+ userId: "usr_buyer",
1642
+ email: "buyer@example.com",
1643
+ });
1644
+ });
1645
+ });