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
@@ -29,8 +29,8 @@ export const CONTENT_PAYLOADS = Object.freeze([
29
29
  kind: "template-set",
30
30
  name: "scaffold-commerce",
31
31
  feature: "commerce",
32
- version: "1.0.0",
33
- digest: "baa4ac6e86ec7e3b367eb67e77c3407da90c7ff9ef51d5f4e1185615194543ee",
32
+ version: "1.0.1",
33
+ digest: "112704a7910dba3275defc0cb86b1b4f33fc8f262f4c09320e8f6b3a8d873916",
34
34
  sourceRoot: "packages/create-ailk/module-templates/commerce/.claude/skills/scaffold-commerce",
35
35
  files: Object.freeze([
36
36
  "references/lookup-keys-template.md",
@@ -790,6 +790,15 @@ export const MODULE_SLICES = {
790
790
  // deliverable by BOTH bundles.
791
791
  "apps/api/src/services/inbox-messages.ts",
792
792
  "apps/api/src/routes/workspaces/messages.ts",
793
+ // #7399 — the two siblings #6216 left `workspace`-only. They register
794
+ // under that same `workspace || multi-tenant` gate, so a scaffold with
795
+ // `multi-tenant` and without `workspace` loaded them with no file behind
796
+ // the specifier and 500'd every request at server construction. Co-owned
797
+ // with `workspace` (see that slice's entry below); both import only
798
+ // `lib/tenant-db.ts` (owned here) and base files.
799
+ // `__tests__/tenancy-slice-resolution.test.ts` holds the whole gate set.
800
+ "apps/api/src/routes/workspaces/general.ts",
801
+ "apps/api/src/routes/workspaces/members.ts",
793
802
  "apps/api/src/lib/tenant-db.ts",
794
803
  "apps/api/src/lib/ws-token.ts",
795
804
  // #6157 D16 — the magnet Admin authoring surface, co-owned with
@@ -890,6 +899,8 @@ export const MODULE_SLICES = {
890
899
  "apps/web/app/[locale]/(authed)/admin/page.tsx",
891
900
  "apps/web/app/[locale]/(authed)/admin/site/page.tsx",
892
901
  "apps/web/app/[locale]/(authed)/admin/team/page.tsx",
902
+ // Co-owned with `multi-tenant` (#7399) — see that slice's entry: both
903
+ // register under the workspace||multi-tenant gate.
893
904
  "apps/api/src/routes/workspaces/general.ts",
894
905
  "apps/api/src/routes/workspaces/members.ts",
895
906
  // Co-owned with `multi-tenant` (#6216) — see that slice's entry: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -59,10 +59,20 @@ CONTENT_ADAPTER=mdx
59
59
  # Unset in production, either one makes the API log a startup warning; the
60
60
  # API keeps running.
61
61
  # FRONTEND_URL is the site the browser loads (no trailing slash). Set it on the
62
- # API project: unset in production, CORS, cookie origin checks and the
63
- # billing-portal return URLs fall back to http://localhost:3000, and checkout
64
- # answers 500 to a caller without X-Internal-Auth.
62
+ # API project: it is the first trusted origin for CORS, cookie-authenticated
63
+ # writes and checkout redirects. Unset in production, those three trust only
64
+ # AILK_TRUSTED_ORIGINS below (with neither set, they refuse every browser
65
+ # origin and checkout answers 500 to a caller without X-Internal-Auth), and the
66
+ # billing-portal return URLs fall back to http://localhost:3000.
65
67
  # FRONTEND_URL=https://your-domain.com
68
+ # AILK_TRUSTED_ORIGINS lists MORE browser origins the API trusts, beside
69
+ # FRONTEND_URL — the Admin app and spoke sites a hub API serves. Comma-separated
70
+ # exact origins, written as a browser sends them: scheme + host (+ port), no
71
+ # path, no trailing slash, lower-case. `*`, wildcard hosts and any other form
72
+ # are refused and the API does not start; production requires https. Each
73
+ # listed origin gets CORS reads, cookie writes and checkout-redirect trust, so
74
+ # list ONLY origins the deployment owner operates — never another party's site.
75
+ # AILK_TRUSTED_ORIGINS=https://admin.your-domain.com,https://spoke.example
66
76
  # OSS_API_INTERNAL_SECRET gates POST /v1/checkout/sessions. Unset, the route
67
77
  # admits every caller. Set, it answers 401 to any caller that does not send it
68
78
  # as X-Internal-Auth — including the MCP start_purchase tool and a browser
@@ -15,6 +15,11 @@ PORT=3002
15
15
  # Frontend URL (for CORS)
16
16
  # =============================================================================
17
17
  FRONTEND_URL=http://localhost:3000
18
+ # More trusted browser origins (Admin app, spoke sites): comma-separated exact
19
+ # origins, no path or trailing slash; `*` is refused at startup; https in production.
20
+ # Each listed origin gets credentialed CORS, cookie writes and checkout-redirect
21
+ # trust — list only origins the deployment owner operates.
22
+ # AILK_TRUSTED_ORIGINS=https://admin.your-domain.com,https://spoke.example
18
23
 
19
24
  # =============================================================================
20
25
  # Local Database (required in production)
@@ -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
+ });