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
@@ -44,7 +44,7 @@ const DRAFT_PATCH_URL = "/v1/project-listings/drafts/listing_abc123";
44
44
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
45
45
  type App = any;
46
46
 
47
- const ENV_KEYS = ["FRONTEND_URL", "NODE_ENV"] as const;
47
+ const ENV_KEYS = ["FRONTEND_URL", "AILK_TRUSTED_ORIGINS", "NODE_ENV"] as const;
48
48
 
49
49
  /**
50
50
  * Build a server whose `config` was resolved from `env`. Restores the prior
@@ -52,6 +52,7 @@ const ENV_KEYS = ["FRONTEND_URL", "NODE_ENV"] as const;
52
52
  */
53
53
  async function buildWith(env: {
54
54
  FRONTEND_URL?: string;
55
+ AILK_TRUSTED_ORIGINS?: string;
55
56
  NODE_ENV: string;
56
57
  }): Promise<App> {
57
58
  for (const key of ENV_KEYS) {
@@ -147,18 +148,19 @@ describe("CORS preflight (#5397)", () => {
147
148
  // ACT
148
149
  const response = await preflight(app, FOREIGN_ORIGIN, "PATCH");
149
150
 
150
- // ASSERT — with a STRING `origin` option, @fastify/cors echoes the
151
- // configured origin rather than the requester's
152
- // (`getAccessControlAllowOriginHeader`), and the refusal is the browser's:
153
- // it blocks any response whose allow-origin is not its own origin. So the
154
- // contract to pin is that the foreign origin is never named back, and the
155
- // wildcard never appears — `credentials: true` with `*` would let any site
156
- // read credentialed responses (the security-review M1 posture server.ts
157
- // already documents). Widening `methods` must not widen WHO may use them.
151
+ // ASSERT — the refusal is the browser's: it blocks any response whose
152
+ // allow-origin is not its own origin. So the contract to pin is that the
153
+ // foreign origin is never named back, and the wildcard never appears —
154
+ // `credentials: true` with `*` would let any site read credentialed
155
+ // responses (the security-review M1 posture server.ts already documents).
156
+ // Widening `methods` must not widen WHO may use them. With the ARRAY
157
+ // `origin` option (#7395 D4), @fastify/cors writes no allow-origin header
158
+ // at all for an unlisted origin (D6.4); before #7395 it echoed the one
159
+ // configured string.
158
160
  const allowOrigin = response.headers["access-control-allow-origin"];
159
161
  expect(allowOrigin).not.toBe(FOREIGN_ORIGIN);
160
162
  expect(allowOrigin).not.toBe("*");
161
- expect(allowOrigin).toBe(FRONTEND_URL);
163
+ expect(allowOrigin).toBeUndefined();
162
164
  });
163
165
 
164
166
  it("advertises every method the registered route table actually serves", async () => {
@@ -193,3 +195,82 @@ describe("CORS preflight (#5397)", () => {
193
195
  }
194
196
  });
195
197
  });
198
+
199
+ // ── #7395 — the trusted-origin allowlist (AC-4) ──────────────────────────────
200
+ //
201
+ // One deployment serves the hub site, its Admin app and every spoke, so CORS
202
+ // answers for each origin the operator listed in FRONTEND_URL or
203
+ // AILK_TRUSTED_ORIGINS, and for no other — in development as in production,
204
+ // since the reflect-any dev arm is gone (D4). A credentialed GET, not only a
205
+ // preflight, carries the header, so a hand-written OPTIONS route cannot pass.
206
+
207
+ describe("CORS trusted-origin allowlist (#7395)", () => {
208
+ const HUB = "https://hub.example";
209
+ const SPOKE_A = "https://spoke-a.example";
210
+ const priorEnv = new Map<string, string | undefined>(
211
+ ENV_KEYS.map((key) => [key, process.env[key]]),
212
+ );
213
+ let app: App;
214
+
215
+ afterEach(async () => {
216
+ if (app) {
217
+ await app.close();
218
+ app = undefined;
219
+ }
220
+ for (const [key, value] of priorEnv) {
221
+ if (value === undefined) {
222
+ delete process.env[key];
223
+ } else {
224
+ process.env[key] = value;
225
+ }
226
+ }
227
+ jest.resetModules();
228
+ });
229
+
230
+ describe.each(["production", "development"])("NODE_ENV=%s", (nodeEnv) => {
231
+ beforeEach(async () => {
232
+ app = await buildWith({
233
+ FRONTEND_URL: HUB,
234
+ AILK_TRUSTED_ORIGINS: SPOKE_A,
235
+ NODE_ENV: nodeEnv,
236
+ });
237
+ });
238
+
239
+ it("allows a PATCH preflight from a listed spoke, with credentials and Vary: Origin", async () => {
240
+ const response = await preflight(app, SPOKE_A, "PATCH");
241
+
242
+ expect(response.headers["access-control-allow-origin"]).toBe(SPOKE_A);
243
+ expect(response.headers["access-control-allow-credentials"]).toBe(
244
+ "true",
245
+ );
246
+ expect(String(response.headers["vary"] ?? "")).toMatch(/\bOrigin\b/i);
247
+ expect(allowedMethods(response)).toContain("PATCH");
248
+ });
249
+
250
+ it("names a listed spoke on a credentialed GET, not only on a preflight", async () => {
251
+ const response = await app.inject({
252
+ method: "GET",
253
+ url: "/health",
254
+ headers: { origin: SPOKE_A, cookie: "better-auth.session_token=x" },
255
+ });
256
+
257
+ expect(response.statusCode).toBe(200);
258
+ expect(response.headers["access-control-allow-origin"]).toBe(SPOKE_A);
259
+ expect(response.headers["access-control-allow-credentials"]).toBe(
260
+ "true",
261
+ );
262
+ });
263
+
264
+ it("still allows the primary FRONTEND_URL origin", async () => {
265
+ const response = await preflight(app, HUB, "PATCH");
266
+
267
+ expect(response.headers["access-control-allow-origin"]).toBe(HUB);
268
+ });
269
+
270
+ it("writes no allow-origin header for an unlisted origin", async () => {
271
+ const response = await preflight(app, FOREIGN_ORIGIN, "PATCH");
272
+
273
+ expect(response.headers["access-control-allow-origin"]).toBeUndefined();
274
+ });
275
+ });
276
+ });
@@ -474,7 +474,8 @@ describe("#6216 D11 — a lead_… path segment is truncated to 8 characters, cs
474
474
  //
475
475
  // POST /v1/checkout/sessions admits a caller without X-Internal-Auth when
476
476
  // OSS_API_INTERNAL_SECRET is unset, and with FRONTEND_URL unset its redirect
477
- // lock answers 500 while the CORS/cookie origin checks pin to localhost. Both
477
+ // lock answers 500 while CORS and cookie writes trust only AILK_TRUSTED_ORIGINS
478
+ // (#7395 D5; before #7395 they pinned to localhost). Both
478
479
  // are optional by design (OSS standalone compat), so startup must never fail
479
480
  // on them — but a production boot says so, once per variable. Each case rebuilds the module graph because `config/index.ts`
480
481
  // snapshots NODE_ENV at import time.
@@ -545,6 +546,9 @@ describe("#6889 — production startup warns on an unset FRONTEND_URL or OSS_API
545
546
  });
546
547
  expect(naming(messages, "FRONTEND_URL")).toHaveLength(1);
547
548
  expect(naming(messages, "OSS_API_INTERNAL_SECRET")).toHaveLength(0);
549
+ // #7395 D5 — nothing is pinned to localhost any more; an unset
550
+ // FRONTEND_URL in production trusts only AILK_TRUSTED_ORIGINS.
551
+ expect(naming(messages, "FRONTEND_URL")[0]).not.toContain("localhost:3000");
548
552
  });
549
553
 
550
554
  it("warns exactly once naming OSS_API_INTERNAL_SECRET when it is unset in production", async () => {
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @file trusted-origins.server.test.ts
3
+ * @description A spoke's cookie-forwarded write through the REAL server (#7395 AC-8).
4
+ *
5
+ * The defect #7395 reports: a spoke renders `/account` against the hub API and
6
+ * forwards the visitor's cookie with the browser's own `Origin`, and every
7
+ * write came back 401 because the auth preHandler compared that `Origin` with
8
+ * `===` against the one `FRONTEND_URL`. The unit suites build the plugin
9
+ * options themselves, so only a boot through `build()` sees what `server.ts`
10
+ * actually wires — `trustedOrigins: [config.FRONTEND_URL]` would pass every
11
+ * one of them and still refuse the spoke here.
12
+ *
13
+ * Each case rebuilds the module graph (`jest.resetModules()`) because
14
+ * `config/index.ts` resolves the allowlist at import time.
15
+ */
16
+
17
+ // Mocked for the same reasons cors.test.ts gives: rate-limit wants a live
18
+ // store, and @working-theory/email pulls an ESM-only transitive dep.
19
+ jest.mock("@fastify/rate-limit", () => jest.fn().mockResolvedValue(undefined));
20
+
21
+ jest.mock("@working-theory/email", () => ({
22
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
23
+ }));
24
+
25
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; the DB boundary, mirrors auth.cookie-path.test.ts
26
+ jest.mock("../lib/prisma.js", () => ({
27
+ prisma: {
28
+ session: { findUnique: jest.fn() },
29
+ apiToken: {
30
+ findUnique: jest.fn(),
31
+ create: jest.fn(),
32
+ update: jest.fn().mockResolvedValue(undefined),
33
+ },
34
+ },
35
+ }));
36
+
37
+ const HUB = "https://hub.example";
38
+ const SPOKE_A = "https://spoke-a.example";
39
+ const ATTACKER = "https://attacker.example.net";
40
+ const COOKIE = "better-auth.session_token=sess_tok_spoke.signature";
41
+
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
43
+ type App = any;
44
+
45
+ const ENV_KEYS = [
46
+ "FRONTEND_URL",
47
+ "AILK_TRUSTED_ORIGINS",
48
+ "NODE_ENV",
49
+ "DATABASE_URL",
50
+ // Cleared so a production boot never trips config's database-module exit.
51
+ "AILK_ENABLED_MODULES",
52
+ ] as const;
53
+
54
+ describe("#7395 — a listed spoke's cookie write through build()", () => {
55
+ const priorEnv = new Map<string, string | undefined>(
56
+ ENV_KEYS.map((key) => [key, process.env[key]]),
57
+ );
58
+ let app: App;
59
+ let prisma: {
60
+ session: { findUnique: jest.Mock };
61
+ apiToken: { create: jest.Mock };
62
+ };
63
+
64
+ beforeEach(async () => {
65
+ process.env.FRONTEND_URL = HUB;
66
+ process.env.AILK_TRUSTED_ORIGINS = SPOKE_A;
67
+ process.env.NODE_ENV = "production";
68
+ process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
69
+ delete process.env.AILK_ENABLED_MODULES;
70
+ jest.resetModules();
71
+
72
+ prisma = (require("../lib/prisma.js") as { prisma: typeof prisma }).prisma;
73
+ prisma.session.findUnique.mockResolvedValue({
74
+ id: "ses_1",
75
+ userId: "usr_spoke_visitor",
76
+ expiresAt: new Date(Date.now() + 60_000),
77
+ activeOrganizationId: null,
78
+ activeWorkspaceId: null,
79
+ });
80
+ prisma.apiToken.create.mockResolvedValue({
81
+ id: "tok_1",
82
+ name: "spoke",
83
+ tokenPrefix: "ailk_abc",
84
+ clientType: "cli",
85
+ scopes: [],
86
+ expiresAt: null,
87
+ createdAt: new Date(),
88
+ });
89
+
90
+ const { build } = require("../server") as typeof import("../server");
91
+ app = await build();
92
+ });
93
+
94
+ afterEach(async () => {
95
+ if (app) {
96
+ await app.close();
97
+ app = undefined;
98
+ }
99
+ for (const [key, value] of priorEnv) {
100
+ if (value === undefined) delete process.env[key];
101
+ else process.env[key] = value;
102
+ }
103
+ jest.resetModules();
104
+ });
105
+
106
+ function postToken(origin: string) {
107
+ return app.inject({
108
+ method: "POST",
109
+ url: "/v1/auth/tokens",
110
+ headers: { cookie: COOKIE, origin },
111
+ payload: { name: "spoke" },
112
+ });
113
+ }
114
+
115
+ it("lets a cookie write from the listed spoke past the auth preHandler", async () => {
116
+ const res = await postToken(SPOKE_A);
117
+
118
+ expect(res.statusCode).not.toBe(401);
119
+ expect(res.json()).not.toEqual({ error: "unauthorized" });
120
+ // The handler ran as the session's user: the token was minted for them.
121
+ expect(res.statusCode).toBe(201);
122
+ expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
123
+ expect(prisma.apiToken.create.mock.calls[0][0].data.userId).toBe(
124
+ "usr_spoke_visitor",
125
+ );
126
+ });
127
+
128
+ it("refuses the same cookie write from an unlisted origin with 401", async () => {
129
+ const res = await postToken(ATTACKER);
130
+
131
+ expect(res.statusCode).toBe(401);
132
+ expect(res.json()).toEqual({ error: "unauthorized" });
133
+ expect(prisma.apiToken.create).not.toHaveBeenCalled();
134
+ });
135
+ });
@@ -0,0 +1,238 @@
1
+ /**
2
+ * @file trusted-origins.test.ts
3
+ * @description The trusted browser-origin allowlist (#7395 AC-2).
4
+ *
5
+ * `resolveTrustedOrigins` is the one derivation CORS, the cookie-forwarded
6
+ * write check and the checkout redirect lock all read (spec D2/D4). It takes
7
+ * `FRONTEND_URL` (lenient, as today) plus the opt-in `AILK_TRUSTED_ORIGINS`
8
+ * (strict: an entry must already be written exactly as a browser serialises
9
+ * an `Origin` header). A refused entry stops the process at startup (D3), and
10
+ * the refusal log names the entry's position and reason, never its text.
11
+ */
12
+
13
+ // Mock dotenv so a developer's local .env never leaks into the config module.
14
+ jest.mock('dotenv', () => ({
15
+ config: jest.fn(),
16
+ }));
17
+
18
+ const mockLoggerError = jest.fn();
19
+ const mockLoggerWarn = jest.fn();
20
+ jest.mock('@working-theory/observability', () => ({
21
+ logger: {
22
+ error: (...args: unknown[]) => mockLoggerError(...args),
23
+ warn: (...args: unknown[]) => mockLoggerWarn(...args),
24
+ info: jest.fn(),
25
+ debug: jest.fn(),
26
+ },
27
+ }));
28
+
29
+ import { resolveTrustedOrigins } from '../index';
30
+
31
+ describe('resolveTrustedOrigins (#7395 D2)', () => {
32
+ it('normalises the primary, trims and de-duplicates the extras, and keeps first-seen order', () => {
33
+ const result = resolveTrustedOrigins({
34
+ FRONTEND_URL: 'https://hub.example/',
35
+ AILK_TRUSTED_ORIGINS:
36
+ ' https://admin.hub.example,https://spoke-a.example,,https://hub.example',
37
+ NODE_ENV: 'production',
38
+ });
39
+
40
+ expect(result.origins).toEqual([
41
+ 'https://hub.example',
42
+ 'https://admin.hub.example',
43
+ 'https://spoke-a.example',
44
+ ]);
45
+ expect(result.refused).toEqual([]);
46
+ expect(result.primaryUnusable).toBe(false);
47
+ expect(Object.isFrozen(result.origins)).toBe(true);
48
+ });
49
+
50
+ it.each([
51
+ ['*', 'wildcard'],
52
+ ['https://*.example.com', 'wildcard'],
53
+ ['null', 'unparseable'],
54
+ ['https://a.example/', 'not-canonical'],
55
+ ['https://a.example/app', 'not-canonical'],
56
+ ['HTTPS://A.EXAMPLE', 'not-canonical'],
57
+ ['https://a.example:443', 'not-canonical'],
58
+ ['ftp://a.example', 'scheme'],
59
+ ['not a url', 'unparseable'],
60
+ ['https://user:pw@a.example', 'not-canonical'],
61
+ ['https://bücher.example', 'not-canonical'],
62
+ ['https://a.example.', 'trailing-dot'],
63
+ ['http://a.example', 'scheme'],
64
+ ])('refuses the extra %j in production (%s)', (entry, reason) => {
65
+ const result = resolveTrustedOrigins({
66
+ FRONTEND_URL: 'https://hub.example',
67
+ AILK_TRUSTED_ORIGINS: `https://ok.example,${entry}`,
68
+ NODE_ENV: 'production',
69
+ });
70
+
71
+ expect(result.refused).toEqual([{ position: 2, reason }]);
72
+ expect(result.origins).toEqual(['https://hub.example', 'https://ok.example']);
73
+ });
74
+
75
+ it('accepts a plain-http extra outside production', () => {
76
+ const result = resolveTrustedOrigins({
77
+ FRONTEND_URL: 'http://localhost:3000',
78
+ AILK_TRUSTED_ORIGINS: 'http://a.example',
79
+ NODE_ENV: 'development',
80
+ });
81
+
82
+ expect(result.refused).toEqual([]);
83
+ expect(result.origins).toEqual(['http://localhost:3000', 'http://a.example']);
84
+ });
85
+
86
+ it('carries no raw entry text in its result', () => {
87
+ const result = resolveTrustedOrigins({
88
+ FRONTEND_URL: 'https://hub.example',
89
+ AILK_TRUSTED_ORIGINS: 'https://user:pw@a.example',
90
+ NODE_ENV: 'production',
91
+ });
92
+
93
+ expect(JSON.stringify(result)).not.toContain('pw');
94
+ });
95
+
96
+ it.each([
97
+ 'https://*',
98
+ // WHATWG decodes `%2A` in a host, so this parses to origin https://*.example.com.
99
+ 'https://%2A.example.com',
100
+ 'https://a.example,https://b.example',
101
+ 'not a url',
102
+ 'mailto:ops@example.com',
103
+ ])(
104
+ 'a FRONTEND_URL of %j contributes no origin and is reported unusable',
105
+ (frontendUrl) => {
106
+ const result = resolveTrustedOrigins({
107
+ FRONTEND_URL: frontendUrl,
108
+ AILK_TRUSTED_ORIGINS: 'https://spoke-a.example',
109
+ NODE_ENV: 'production',
110
+ });
111
+
112
+ expect(result.origins).toEqual(['https://spoke-a.example']);
113
+ expect(result.primaryUnusable).toBe(true);
114
+ expect(result.refused).toEqual([]);
115
+ },
116
+ );
117
+
118
+ it('keeps a plain-http primary in production, as today', () => {
119
+ const result = resolveTrustedOrigins({
120
+ FRONTEND_URL: 'http://site.example',
121
+ NODE_ENV: 'production',
122
+ });
123
+
124
+ expect(result.origins).toEqual(['http://site.example']);
125
+ expect(result.primaryUnusable).toBe(false);
126
+ });
127
+
128
+ it('defaults an unset primary to http://localhost:3000 outside production', () => {
129
+ const result = resolveTrustedOrigins({ NODE_ENV: 'development' });
130
+
131
+ expect(result.origins).toEqual(['http://localhost:3000']);
132
+ expect(result.primaryUnusable).toBe(false);
133
+ });
134
+
135
+ it('trusts nothing for an unset primary in production, without reporting it unusable', () => {
136
+ const result = resolveTrustedOrigins({ NODE_ENV: 'production' });
137
+
138
+ expect(result.origins).toEqual([]);
139
+ expect(result.primaryUnusable).toBe(false);
140
+ });
141
+
142
+ it('trusts only the extras for an unset primary in production', () => {
143
+ const result = resolveTrustedOrigins({
144
+ AILK_TRUSTED_ORIGINS: 'https://hub.example',
145
+ NODE_ENV: 'production',
146
+ });
147
+
148
+ expect(result.origins).toEqual(['https://hub.example']);
149
+ });
150
+ });
151
+
152
+ describe('config.TRUSTED_ORIGINS at startup (#7395 D3/D5)', () => {
153
+ const originalEnv = process.env;
154
+
155
+ beforeEach(() => {
156
+ jest.resetModules();
157
+ process.env = { ...originalEnv };
158
+ // Cleared so a production boot never trips config's database-module exit.
159
+ delete process.env.AILK_ENABLED_MODULES;
160
+ jest.clearAllMocks();
161
+ });
162
+
163
+ afterEach(() => {
164
+ process.env = originalEnv;
165
+ jest.restoreAllMocks();
166
+ });
167
+
168
+ it('exposes the resolved, frozen list on config', () => {
169
+ process.env.FRONTEND_URL = 'https://hub.example/';
170
+ process.env.AILK_TRUSTED_ORIGINS = 'https://spoke-a.example';
171
+
172
+ const { config } = require('../index');
173
+
174
+ expect(config.TRUSTED_ORIGINS).toEqual(['https://hub.example', 'https://spoke-a.example']);
175
+ expect(Object.isFrozen(config.TRUSTED_ORIGINS)).toBe(true);
176
+ expect(config.AILK_TRUSTED_ORIGINS).toBe('https://spoke-a.example');
177
+ // FRONTEND_URL keeps its job as the primary site's address (D7).
178
+ expect(config.FRONTEND_URL).toBe('https://hub.example/');
179
+ });
180
+
181
+ it.each(['production', 'development'])(
182
+ 'exits with a refused entry in %s, logging its position and reason but never its text',
183
+ (nodeEnv) => {
184
+ process.env.NODE_ENV = nodeEnv;
185
+ process.env.FRONTEND_URL = 'https://hub.example';
186
+ process.env.AILK_TRUSTED_ORIGINS = 'https://spoke-a.example,https://user:pw@a.example';
187
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
188
+ throw new Error('Process exit called');
189
+ });
190
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
191
+
192
+ expect(() => require('../index')).toThrow('Process exit called');
193
+ expect(exitSpy).toHaveBeenCalledWith(1);
194
+ expect(mockLoggerError).toHaveBeenCalledTimes(1);
195
+
196
+ const logged = JSON.stringify([mockLoggerError.mock.calls, consoleError.mock.calls]);
197
+ expect(logged).toContain('AILK_TRUSTED_ORIGINS');
198
+ expect(logged).toContain('"position":2');
199
+ expect(logged).toContain('"reason":"not-canonical"');
200
+ expect(logged).not.toContain('pw');
201
+ expect(logged).not.toContain('a.example');
202
+ // The same line also reaches console.error, which survives a worker
203
+ // transport dropping the last line before exit (spec § 9 SA-08).
204
+ expect(consoleError).toHaveBeenCalledTimes(1);
205
+ },
206
+ );
207
+
208
+ it.each(['production', 'development'])(
209
+ 'logs, without exiting or echoing it, a FRONTEND_URL that is set but unusable (%s)',
210
+ (nodeEnv) => {
211
+ process.env.NODE_ENV = nodeEnv;
212
+ process.env.FRONTEND_URL = 'https://secret-token*@hub.example';
213
+ delete process.env.AILK_TRUSTED_ORIGINS;
214
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
215
+ throw new Error('Process exit called');
216
+ });
217
+
218
+ const { config } = require('../index');
219
+
220
+ expect(exitSpy).not.toHaveBeenCalled();
221
+ expect(config.TRUSTED_ORIGINS).toEqual([]);
222
+ expect(mockLoggerError).toHaveBeenCalledTimes(1);
223
+ const logged = JSON.stringify(mockLoggerError.mock.calls);
224
+ expect(logged).toContain('FRONTEND_URL is set but');
225
+ expect(logged).not.toContain('secret-token');
226
+ },
227
+ );
228
+
229
+ it('logs no error for a usable FRONTEND_URL and no extras', () => {
230
+ process.env.FRONTEND_URL = 'https://hub.example';
231
+ delete process.env.AILK_TRUSTED_ORIGINS;
232
+
233
+ const { config } = require('../index');
234
+
235
+ expect(config.TRUSTED_ORIGINS).toEqual(['https://hub.example']);
236
+ expect(mockLoggerError).not.toHaveBeenCalled();
237
+ });
238
+ });
@@ -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,