create-ailk 0.12.1 → 0.13.0

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 (55) hide show
  1. package/dist/module-architecture.js +27 -0
  2. package/package.json +3 -3
  3. package/templates/.env.example +23 -9
  4. package/templates/apps/api/CLAUDE.md +21 -17
  5. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
  6. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
  7. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
  8. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
  9. package/templates/apps/api/src/config/index.ts +35 -20
  10. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
  11. package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
  12. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
  13. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
  14. package/templates/apps/api/src/lib/agent-store.ts +521 -0
  15. package/templates/apps/api/src/lib/spoke-store.ts +13 -2
  16. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
  17. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
  18. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
  19. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
  20. package/templates/apps/api/src/middleware/auth.ts +219 -5
  21. package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
  22. package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
  23. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
  24. package/templates/apps/api/src/routes/leads/index.ts +14 -6
  25. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
  26. package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
  27. package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
  28. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
  29. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
  30. package/templates/apps/api/src/server.ts +27 -0
  31. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
  32. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
  33. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
  34. package/templates/apps/api/src/services/audit-log.ts +278 -47
  35. package/templates/apps/api/src/services/default-tenant.ts +55 -5
  36. package/templates/apps/api/src/services/leads-inbox.ts +12 -21
  37. package/templates/apps/api/src/services/project-listings.ts +1 -21
  38. package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
  39. package/templates/apps/mcp/CLAUDE.md +44 -5
  40. package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
  41. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
  42. package/templates/apps/mcp/src/http-transport.ts +107 -1
  43. package/templates/apps/mcp/src/workspace-server.ts +193 -0
  44. package/templates/apps/web/.env.example +64 -37
  45. package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
  46. package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
  47. package/templates/apps/web/lib/relay.ts +279 -0
  48. package/templates/apps/web/middleware.ts +5 -4
  49. package/templates/apps/web/next.config.mjs +6 -0
  50. package/templates/database/CHANGELOG.md +15 -0
  51. package/templates/database/__tests__/agents.db.test.ts +235 -0
  52. package/templates/database/auth/schema.prisma +61 -0
  53. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  54. package/templates/database/package.json +1 -1
  55. package/versions.json +34 -34
@@ -25,35 +25,71 @@ CONTENT_ADAPTER=mdx
25
25
  # CONTENT_REVALIDATE_SECRET=
26
26
 
27
27
  # =============================================================================
28
- # apps/api base URL (browser-side lead-capture forms) — #3965
29
- # =============================================================================
30
- # apps/web and apps/api are separate origins in every deployment shape that
31
- # doesn't front them with a rewrite/proxy. A browser-side block (e.g.
32
- # NewsletterFormInline, packages/ui/src/blocks/NewsletterFormInline.tsx)
33
- # posting a root-relative action (e.g. /v1/leads/subscribe) resolves it
34
- # against apps/web's OWN origin -- not apps/api's -- unless this is set.
35
- # Same convention as apps/workspace/lib/client-api.ts's identically-named var.
36
- # Should be apps/api's origin with NO trailing slash (a single trailing
37
- # slash is stripped before joining, but keep this clean regardless).
38
- #
39
- # THIS IS THE ONE BROWSER-SIDE NAME (#5397). All three browser readers of the
40
- # apps/api origin resolve it from this variable: resolveSubmitUrl
41
- # (packages/ui, the form blocks above), apps/workspace's client-api, and
28
+ # The same-origin relay, and the browser-side apps/api name — #7644, #5397
29
+ # =============================================================================
30
+ # THIS SITE'S OWN FORMS NEVER CALL apps/api FROM THE BROWSER (#7644). Lead
31
+ # capture, newsletter signup, step-flow submits, booking and the commerce
32
+ # invoice form post to THIS site's own /v1/... address; app/v1/[...path]/route.ts
33
+ # forwards each server-side to apps/api at AILK_API_URL, attaching this site's
34
+ # AILK_SPOKE_KEY when it is set and none when it is not (a single-site install
35
+ # takes the same path). next.config.mjs sets NEXT_PUBLIC_AILK_SAME_ORIGIN_RELAY
36
+ # for that — it is part of the source, not a variable to set here. The relay
37
+ # forwards exactly these, and refuses every other /v1 path with a 404:
38
+ #
39
+ # POST /v1/leads/submit POST /v1/leads/subscribe
40
+ # POST /v1/leads/callback GET /v1/schedule/availability
41
+ # POST /v1/schedule/book POST /v1/commerce/invoices
42
+ #
43
+ # An authored form `action` must name one of them. Adding one is a reviewed
44
+ # change to RELAY_ROUTES in lib/relay.ts and to this list. The visitor's
45
+ # x-forwarded-for and user-agent reach the hub as received (and the keyed
46
+ # visitor IP, with AILK_VISITOR_FORWARD_KEY — server-side only, never
47
+ # NEXT_PUBLIC_; see the repo-root .env.example, #7394). A cross-site post is
48
+ # refused before the hub is called.
49
+ #
50
+ # EDGE PRECONDITION: the relay forwards x-forwarded-for exactly as this site
51
+ # received it, so run the site behind an edge that OVERWRITES the client-address
52
+ # headers (as Vercel does), not one that appends to them. Otherwise a caller
53
+ # can choose its own rate-limit identity at the hub (spec SEC-7644-6). And when
54
+ # the HUB's edge overwrites x-forwarded-for with the address it saw (this
55
+ # site's server), set AILK_VISITOR_FORWARD_KEY here and on the hub: without it
56
+ # every visitor of this site shares one rate limit on the hub's lead routes.
57
+ #
58
+ # AILK_SPOKE_KEY ON A HUB WITH SPOKES (#7633). A hub whose spoke registry holds
59
+ # a row refuses every public call without a key by default, so EVERY site of it
60
+ # sets its own key — the hub's own site too, registered against the operator's
61
+ # own workspace. The relay above carries it on the browser's calls; the site's
62
+ # server-side reads (meeting types, the /meet pages, Server Actions) send it
63
+ # themselves. It is sent only when AILK_API_URL is https: or loopback.
64
+ # AILK_SPOKE_KEY=
65
+ #
66
+ # UPGRADE ORDER for a hub with spokes. 1) Redeploy every site on this release,
67
+ # the hub's own site included — a site still on the previous release keeps
68
+ # posting to the hub directly, and keeps working, until then. 2) Register a key
69
+ # for every site (the #7532 runbook) and set it as AILK_SPOKE_KEY. 3) Only then
70
+ # upgrade the hub to the release that refuses keyless callers (#7633; the
71
+ # runbook's "Before you upgrade a hub"). After that a spoke's origin can
72
+ # come off the hub's AILK_TRUSTED_ORIGINS — unless the site uses the commerce
73
+ # invoice form, a cookie-authenticated owner write whose Origin the hub still
74
+ # checks against that list.
75
+ #
76
+ # NEXT_PUBLIC_AILK_API_URL is still THE ONE BROWSER-SIDE NAME of apps/api's
77
+ # origin (#5397) for the two browser readers the relay does not cover:
42
78
  # @working-theory/api-client's getApiBase() — which runs in the browser
43
79
  # wherever a "use client" component binds it, e.g. the founder application's
44
- # ListingApplicationClient. getApiBase() previously read NEXT_PUBLIC_API_URL,
45
- # a name this file never documented, so a site configured exactly per these
46
- # docs sent its browser traffic to http://localhost:3001. That name is still
47
- # honoured as a deprecated fallback; do not set it in a new deployment.
80
+ # ListingApplicationClient — and the report reader's download link. It is also
81
+ # what packages/ui's resolveSubmitUrl falls back to in a build WITHOUT the
82
+ # relay (an older scaffold that upgrades @working-theory/ui alone). Should be
83
+ # apps/api's origin with NO trailing slash. getApiBase() previously read
84
+ # NEXT_PUBLIC_API_URL, a name this file never documented, so a site configured
85
+ # exactly per these docs sent its browser traffic to http://localhost:3001.
86
+ # That name is still honoured as a deprecated fallback; do not set it in a new
87
+ # deployment.
48
88
  #
49
89
  # The server-side counterpart is AILK_API_URL (see the repo-root .env.example)
50
90
  # — deliberately NOT NEXT_PUBLIC_, and it still wins over this one when both
51
- # are set, so an RSC/Server-Action call can target an internal origin the
52
- # browser never sees.
53
- #
54
- # A spoke calling a hub API server-to-server forwards its visitors for the
55
- # hub's per-visitor lead limit with AILK_VISITOR_FORWARD_KEY — server-side
56
- # only, never NEXT_PUBLIC_ (see the repo-root .env.example, #7394).
91
+ # are set, so an RSC/Server-Action call and the relay can target an internal
92
+ # origin the browser never sees.
57
93
  # NEXT_PUBLIC_AILK_API_URL=http://localhost:3002
58
94
 
59
95
  # =============================================================================
@@ -188,16 +224,7 @@ CONTENT_ADAPTER=mdx
188
224
  # boundary checkable rather than merely intended.
189
225
  # SCHEDULING_CAL_LINK=owner/event-slug
190
226
  #
191
- # SPLIT-ORIGIN DEPLOYS NEED A CSP EXTENSION. The booker calls this site's
192
- # /v1/schedule/* routes from the browser, resolved through
193
- # NEXT_PUBLIC_AILK_API_URL. Set that variable and the calls become
194
- # cross-origin, but the baseline CSP in security-headers.config.ts is
195
- # `connect-src 'self' https://api.stripe.com` — so the browser blocks them and
196
- # the booker shows its generic unavailable state with no explanation. Pass the
197
- # API origin through the customer extension `buildHeaders` accepts:
198
- #
199
- # buildHeaders({ csp: { "connect-src": ["'self'", "<your apps/api origin>"] } })
200
- #
201
- # Deploying apps/web and apps/api on one origin (leaving
202
- # NEXT_PUBLIC_AILK_API_URL unset) needs no extension — the calls stay
203
- # same-origin and 'self' already covers them.
227
+ # The booker's /v1/schedule/* calls go through this site's same-origin relay
228
+ # (see the relay block above, #7644), so the baseline CSP's
229
+ # `connect-src 'self'` already covers them — no CSP extension is needed for
230
+ # them, whatever NEXT_PUBLIC_AILK_API_URL is set to.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The same-origin relay's route (#7644 D1). Every `/v1/...` request to this
3
+ * site lands here and is answered by `handleRelay` (lib/relay.ts): forwarded
4
+ * to the hub when it is one of the fixed relayed routes, refused otherwise.
5
+ * Next answers any other method with 405 itself.
6
+ *
7
+ * `/v1` is exempt from next-intl's locale rewrite in middleware.ts, as `/api`
8
+ * is; a static `v1` segment wins over `[locale]`, so no page is shadowed.
9
+ */
10
+ import { handleRelay } from "../../../lib/relay";
11
+
12
+ export const runtime = "nodejs";
13
+ export const dynamic = "force-dynamic";
14
+
15
+ export const GET = handleRelay;
16
+ export const POST = handleRelay;
@@ -0,0 +1,515 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ // The same-origin relay (#7644): the site serves the root-relative /v1 paths
5
+ // its own browser forms post to and forwards each, server-side, to the hub.
6
+ //
7
+ // Drives `handleRelay` with real `Request` objects through the real
8
+ // `relayToApi` (jest maps @working-theory/api-client to source), with the
9
+ // global `fetch` — the hub — stubbed.
10
+ //
11
+ // Spec: docs/specs/7644-the-browser-talks-only-to-spec.md §5.1 AC-1 to AC-4.
12
+
13
+ import { handleRelay, RELAY_ROUTES } from "../relay";
14
+
15
+ const HUB = "https://hub.example";
16
+ const SITE = "https://spoke.example";
17
+ const K1 = "K1";
18
+ const FORWARD_KEY = "FWD";
19
+ const BODY = '{"siteId":"x","email":"a@b.example"}';
20
+ const QUERY = "?calLink=acme/intro&start=2026-10-05";
21
+
22
+ const ENV_KEYS = [
23
+ "AILK_API_URL",
24
+ "NEXT_PUBLIC_AILK_API_URL",
25
+ "NEXT_PUBLIC_API_URL",
26
+ "AILK_SPOKE_KEY",
27
+ "AILK_VISITOR_FORWARD_KEY",
28
+ ] as const;
29
+ const priorEnv = new Map<string, string | undefined>(
30
+ ENV_KEYS.map((name) => [name, process.env[name]]),
31
+ );
32
+
33
+ function hubAnswer(
34
+ status = 201,
35
+ body = '{"id":"lead_1","persisted":"db"}',
36
+ headers: Record<string, string> = { "content-type": "application/json" },
37
+ ): Response {
38
+ return new Response(body, { status, headers });
39
+ }
40
+
41
+ const fetchMock = jest.fn(async (_url: string, _init: RequestInit) =>
42
+ hubAnswer(),
43
+ );
44
+ const priorFetch = global.fetch;
45
+
46
+ /** The `(url, init)` of the one hub call. */
47
+ function sent(): { url: string; init: RequestInit; headers: Headers } {
48
+ expect(fetchMock).toHaveBeenCalledTimes(1);
49
+ const call = fetchMock.mock.calls[0];
50
+ if (!call) throw new Error("the hub was not called");
51
+ const [url, init] = call;
52
+ return { url, init, headers: new Headers(init.headers) };
53
+ }
54
+
55
+ const SAME_ORIGIN = {
56
+ origin: SITE,
57
+ "sec-fetch-site": "same-origin",
58
+ };
59
+
60
+ /**
61
+ * A request to the relay at `path`. A same-origin browser POST by default:
62
+ * `host: spoke.example`, its own `Origin`, `Sec-Fetch-Site: same-origin`
63
+ * and a JSON body. `headers` overrides; a `null` value removes the header.
64
+ */
65
+ function relayRequest(
66
+ path: string,
67
+ {
68
+ method = "POST",
69
+ headers = {},
70
+ body = BODY,
71
+ }: {
72
+ method?: string;
73
+ headers?: Record<string, string | null>;
74
+ body?: string;
75
+ } = {},
76
+ ): Request {
77
+ const merged: Record<string, string | null> = {
78
+ host: "spoke.example",
79
+ ...SAME_ORIGIN,
80
+ ...(method === "POST" ? { "content-type": "application/json" } : {}),
81
+ ...headers,
82
+ };
83
+ const init: RequestInit = {
84
+ method,
85
+ headers: Object.fromEntries(
86
+ Object.entries(merged).filter(
87
+ (entry): entry is [string, string] => entry[1] !== null,
88
+ ),
89
+ ),
90
+ };
91
+ if (method === "POST") init.body = body;
92
+ return new Request(`${SITE}${path}`, init);
93
+ }
94
+
95
+ async function errorCode(res: Response): Promise<string | undefined> {
96
+ const json = (await res.json()) as { ok?: boolean; error?: { code?: string } };
97
+ expect(json.ok).toBe(false);
98
+ return json.error?.code;
99
+ }
100
+
101
+ const ENTRIES: ReadonlyArray<{ method: "GET" | "POST"; path: string }> = [
102
+ { method: "POST", path: "/v1/leads/submit" },
103
+ { method: "POST", path: "/v1/leads/subscribe" },
104
+ { method: "POST", path: "/v1/leads/callback" },
105
+ { method: "GET", path: "/v1/schedule/availability" },
106
+ { method: "POST", path: "/v1/schedule/book" },
107
+ { method: "POST", path: "/v1/commerce/invoices" },
108
+ ];
109
+ const VISITOR_ENTRIES = ENTRIES.filter(
110
+ (e) => e.path !== "/v1/commerce/invoices",
111
+ );
112
+
113
+ /** The relay request for one D2 entry, with the GET's query string. */
114
+ function entryRequest(
115
+ entry: { method: string; path: string },
116
+ headers: Record<string, string | null> = {},
117
+ ): Request {
118
+ return relayRequest(
119
+ entry.method === "GET" ? `${entry.path}${QUERY}` : entry.path,
120
+ { method: entry.method, headers },
121
+ );
122
+ }
123
+
124
+ let errorSpy: jest.SpyInstance;
125
+
126
+ beforeEach(() => {
127
+ fetchMock.mockClear();
128
+ fetchMock.mockImplementation(async () => hubAnswer());
129
+ global.fetch = fetchMock as jest.Mock;
130
+ for (const name of ENV_KEYS) delete process.env[name];
131
+ process.env.AILK_API_URL = HUB;
132
+ process.env.AILK_SPOKE_KEY = K1;
133
+ errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
134
+ });
135
+
136
+ afterEach(() => {
137
+ errorSpy.mockRestore();
138
+ });
139
+
140
+ afterAll(() => {
141
+ global.fetch = priorFetch;
142
+ for (const [name, value] of priorEnv) {
143
+ if (value === undefined) delete process.env[name];
144
+ else process.env[name] = value;
145
+ }
146
+ });
147
+
148
+ describe("AC-1 — forwarding, one entry each", () => {
149
+ it.each(ENTRIES)(
150
+ "$method $path is forwarded once, unchanged, and its answer passed back",
151
+ async (entry) => {
152
+ const res = await handleRelay(entryRequest(entry));
153
+
154
+ const { url, init } = sent();
155
+ expect(url).toBe(
156
+ `${HUB}${entry.path}${entry.method === "GET" ? QUERY : ""}`,
157
+ );
158
+ expect(init.method).toBe(entry.method);
159
+ if (entry.method === "POST") expect(init.body).toBe(BODY);
160
+ else expect(init.body).toBeUndefined();
161
+
162
+ expect(res.status).toBe(201);
163
+ expect(await res.text()).toBe('{"id":"lead_1","persisted":"db"}');
164
+ expect(res.headers.get("content-type")).toBe("application/json");
165
+ expect(res.headers.get("cache-control")).toBe("no-store");
166
+ },
167
+ );
168
+
169
+ it("forwards the body byte-for-byte, never re-serialised", async () => {
170
+ const body = '{ "b":1,\n "a" : [2, 1] }';
171
+ await handleRelay(relayRequest("/v1/leads/submit", { body }));
172
+ expect(sent().init.body).toBe(body);
173
+ });
174
+
175
+ it("drops a query string on a POST entry", async () => {
176
+ await handleRelay(relayRequest("/v1/leads/submit?x=1"));
177
+ expect(sent().url).toBe(`${HUB}/v1/leads/submit`);
178
+ });
179
+
180
+ it("passes the hub's 429 and retry-after through", async () => {
181
+ fetchMock.mockImplementation(async () =>
182
+ hubAnswer(429, '{"ok":false}', {
183
+ "content-type": "application/json",
184
+ "retry-after": "30",
185
+ }),
186
+ );
187
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
188
+ expect(res.status).toBe(429);
189
+ expect(res.headers.get("retry-after")).toBe("30");
190
+ expect(await res.text()).toBe('{"ok":false}');
191
+ });
192
+
193
+ it("answers 502 relay_unreachable when the hub cannot be reached", async () => {
194
+ fetchMock.mockImplementation(async () => {
195
+ throw new TypeError("fetch failed");
196
+ });
197
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
198
+ expect(res.status).toBe(502);
199
+ expect(await errorCode(res)).toBe("relay_unreachable");
200
+ });
201
+ });
202
+
203
+ describe("AC-2 — only the list", () => {
204
+ it.each([
205
+ ["POST", "/v1/workspaces"],
206
+ ["GET", "/v1/schedule/meeting-types"],
207
+ ["POST", "/v1/flows/x/steps"],
208
+ ["GET", "/v1/leads/submit"],
209
+ ["POST", "/v1/leads/submit/"],
210
+ ["POST", "/v1/leads/submit/extra"],
211
+ ["POST", "/v1/Leads/submit"],
212
+ ])("%s %s answers 404 not_found and calls no hub", async (method, path) => {
213
+ const res = await handleRelay(relayRequest(path, { method }));
214
+ expect(res.status).toBe(404);
215
+ expect(await errorCode(res)).toBe("not_found");
216
+ expect(fetchMock).not.toHaveBeenCalled();
217
+ });
218
+
219
+ it("RELAY_ROUTES is exactly the six D2 pairs, and frozen", () => {
220
+ expect(RELAY_ROUTES).toEqual(ENTRIES);
221
+ expect(Object.isFrozen(RELAY_ROUTES)).toBe(true);
222
+ for (const route of RELAY_ROUTES) expect(Object.isFrozen(route)).toBe(true);
223
+ });
224
+ });
225
+
226
+ describe("AC-3 — the key, and only the site's key", () => {
227
+ it.each(ENTRIES)(
228
+ "$method $path carries the site's key and refuses redirects",
229
+ async (entry) => {
230
+ await handleRelay(entryRequest(entry));
231
+ const { init, headers } = sent();
232
+ expect(headers.get("x-ailk-spoke-key")).toBe(K1);
233
+ expect(init.redirect).toBe("error");
234
+ },
235
+ );
236
+
237
+ it.each(ENTRIES)(
238
+ "$method $path without a site key carries none and still works (the single-site path)",
239
+ async (entry) => {
240
+ delete process.env.AILK_SPOKE_KEY;
241
+ const res = await handleRelay(entryRequest(entry));
242
+ const { init, headers } = sent();
243
+ expect(headers.get("x-ailk-spoke-key")).toBeNull();
244
+ expect(init.redirect).toBe("error");
245
+ expect(res.status).toBe(201);
246
+ },
247
+ );
248
+
249
+ const CLIENT_SENT = {
250
+ "x-ailk-spoke-key": "EVIL",
251
+ "x-ailk-visitor-forward-key": "EVIL",
252
+ "x-ailk-workspace-id": "wksp_x",
253
+ authorization: "Bearer t",
254
+ };
255
+
256
+ it("a client-sent key, forward key, workspace header and authorization never reach the hub", async () => {
257
+ await handleRelay(relayRequest("/v1/leads/submit", { headers: CLIENT_SENT }));
258
+ const { headers } = sent();
259
+ expect(headers.get("x-ailk-spoke-key")).toBe(K1);
260
+ expect(headers.get("x-ailk-visitor-forward-key")).toBeNull();
261
+ expect(headers.get("x-ailk-workspace-id")).toBeNull();
262
+ expect(headers.get("authorization")).toBeNull();
263
+ });
264
+
265
+ it("with no site key, a client-sent key still never reaches the hub", async () => {
266
+ delete process.env.AILK_SPOKE_KEY;
267
+ await handleRelay(relayRequest("/v1/leads/submit", { headers: CLIENT_SENT }));
268
+ expect(sent().headers.get("x-ailk-spoke-key")).toBeNull();
269
+ });
270
+ });
271
+
272
+ describe("AC-4 — the visitor, unchanged; a cross-site post, refused", () => {
273
+ it("forwards x-forwarded-for and user-agent exactly as received", async () => {
274
+ await handleRelay(
275
+ relayRequest("/v1/leads/submit", {
276
+ headers: {
277
+ "x-forwarded-for": "203.0.113.5, 10.0.0.1",
278
+ "user-agent": "UA/1",
279
+ },
280
+ }),
281
+ );
282
+ const { headers } = sent();
283
+ expect(headers.get("x-forwarded-for")).toBe("203.0.113.5, 10.0.0.1");
284
+ expect(headers.get("user-agent")).toBe("UA/1");
285
+ });
286
+
287
+ it("sends x-real-ip as x-forwarded-for when that is all it has", async () => {
288
+ await handleRelay(
289
+ relayRequest("/v1/leads/submit", {
290
+ headers: { "x-real-ip": "203.0.113.9" },
291
+ }),
292
+ );
293
+ expect(sent().headers.get("x-forwarded-for")).toBe("203.0.113.9");
294
+ });
295
+
296
+ it("sends no x-forwarded-for when it has neither", async () => {
297
+ await handleRelay(relayRequest("/v1/leads/submit"));
298
+ expect(sent().headers.get("x-forwarded-for")).toBeNull();
299
+ });
300
+
301
+ it.each(VISITOR_ENTRIES)(
302
+ "$method $path carries the keyed visitor IP when the site has a forward key",
303
+ async (entry) => {
304
+ process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
305
+ await handleRelay(
306
+ entryRequest(entry, { "x-forwarded-for": "203.0.113.5, 10.0.0.1" }),
307
+ );
308
+ const { headers } = sent();
309
+ expect(headers.get("x-ailk-visitor-ip")).toBe("203.0.113.5");
310
+ expect(headers.get("x-ailk-visitor-forward-key")).toBe(FORWARD_KEY);
311
+ },
312
+ );
313
+
314
+ it("POST /v1/commerce/invoices carries no keyed visitor IP", async () => {
315
+ process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
316
+ await handleRelay(
317
+ relayRequest("/v1/commerce/invoices", {
318
+ headers: { "x-forwarded-for": "203.0.113.5" },
319
+ }),
320
+ );
321
+ const { headers } = sent();
322
+ expect(headers.get("x-ailk-visitor-ip")).toBeNull();
323
+ expect(headers.get("x-ailk-visitor-forward-key")).toBeNull();
324
+ });
325
+
326
+ it("forwards cookie and origin on /v1/commerce/invoices", async () => {
327
+ await handleRelay(
328
+ relayRequest("/v1/commerce/invoices", { headers: { cookie: "s=1" } }),
329
+ );
330
+ const { headers } = sent();
331
+ expect(headers.get("cookie")).toBe("s=1");
332
+ expect(headers.get("origin")).toBe(SITE);
333
+ });
334
+
335
+ it.each(VISITOR_ENTRIES)(
336
+ "$method $path forwards neither cookie nor origin",
337
+ async (entry) => {
338
+ await handleRelay(entryRequest(entry, { cookie: "s=1" }));
339
+ const { headers } = sent();
340
+ expect(headers.get("cookie")).toBeNull();
341
+ expect(headers.get("origin")).toBeNull();
342
+ },
343
+ );
344
+
345
+ it.each<[string, Record<string, string | null>]>([
346
+ ["a foreign origin", { origin: "https://evil.example" }],
347
+ ["origin: null", { origin: "null" }],
348
+ ["the same host on another port", { origin: "https://spoke.example:8443" }],
349
+ [
350
+ "sec-fetch-site: cross-site with a same-host origin",
351
+ { "sec-fetch-site": "cross-site" },
352
+ ],
353
+ ["sec-fetch-site: same-site", { "sec-fetch-site": "same-site" }],
354
+ ])("%s answers 403 cross_site and calls no hub", async (_label, headers) => {
355
+ const res = await handleRelay(relayRequest("/v1/leads/submit", { headers }));
356
+ expect(res.status).toBe(403);
357
+ expect(await errorCode(res)).toBe("cross_site");
358
+ expect(fetchMock).not.toHaveBeenCalled();
359
+ });
360
+
361
+ it("admits an origin that matches the forwarded host behind a proxy", async () => {
362
+ const res = await handleRelay(
363
+ relayRequest("/v1/leads/submit", {
364
+ headers: {
365
+ host: "internal:3000",
366
+ "x-forwarded-host": "spoke.example, proxy",
367
+ },
368
+ }),
369
+ );
370
+ expect(res.status).toBe(201);
371
+ expect(fetchMock).toHaveBeenCalledTimes(1);
372
+ });
373
+
374
+ it("admits a JSON POST that carries neither origin nor sec-fetch-site (a non-browser caller)", async () => {
375
+ const res = await handleRelay(
376
+ relayRequest("/v1/leads/submit", {
377
+ headers: { origin: null, "sec-fetch-site": null },
378
+ }),
379
+ );
380
+ expect(res.status).toBe(201);
381
+ expect(fetchMock).toHaveBeenCalledTimes(1);
382
+ });
383
+
384
+ it("the forwarded host wins over a matching host header", async () => {
385
+ const res = await handleRelay(
386
+ relayRequest("/v1/leads/submit", {
387
+ headers: { host: "spoke.example", "x-forwarded-host": "proxy.example" },
388
+ }),
389
+ );
390
+ expect(res.status).toBe(403);
391
+ expect(await errorCode(res)).toBe("cross_site");
392
+ expect(fetchMock).not.toHaveBeenCalled();
393
+ });
394
+
395
+ it("a POST that is not JSON answers 415", async () => {
396
+ const res = await handleRelay(
397
+ relayRequest("/v1/leads/submit", {
398
+ headers: { "content-type": "text/plain" },
399
+ }),
400
+ );
401
+ expect(res.status).toBe(415);
402
+ expect(await errorCode(res)).toBe("unsupported_media_type");
403
+ expect(fetchMock).not.toHaveBeenCalled();
404
+ });
405
+
406
+ it("admits a JSON content-type with parameters, in any case", async () => {
407
+ const res = await handleRelay(
408
+ relayRequest("/v1/leads/submit", {
409
+ headers: { "content-type": "Application/JSON; charset=utf-8" },
410
+ }),
411
+ );
412
+ expect(res.status).toBe(201);
413
+ });
414
+
415
+ it("a body over 1 MiB answers 413", async () => {
416
+ const res = await handleRelay(
417
+ relayRequest("/v1/leads/submit", { body: "x".repeat(1_048_577) }),
418
+ );
419
+ expect(res.status).toBe(413);
420
+ expect(await errorCode(res)).toBe("payload_too_large");
421
+ expect(fetchMock).not.toHaveBeenCalled();
422
+ });
423
+
424
+ it("a body of exactly 1 MiB is forwarded", async () => {
425
+ const res = await handleRelay(
426
+ relayRequest("/v1/leads/submit", { body: "x".repeat(1_048_576) }),
427
+ );
428
+ expect(res.status).toBe(201);
429
+ });
430
+
431
+ it("a request already relayed answers 508 relay_loop", async () => {
432
+ const res = await handleRelay(
433
+ relayRequest("/v1/leads/submit", { headers: { "x-ailk-relayed": "1" } }),
434
+ );
435
+ expect(res.status).toBe(508);
436
+ expect(await errorCode(res)).toBe("relay_loop");
437
+ expect(fetchMock).not.toHaveBeenCalled();
438
+ });
439
+
440
+ it("marks every forwarded call so a self-pointing hub address cannot loop", async () => {
441
+ await handleRelay(relayRequest("/v1/leads/submit"));
442
+ expect(sent().headers.get("x-ailk-relayed")).toBe("1");
443
+ });
444
+
445
+ it("forwards nothing outside its allow-list", async () => {
446
+ await handleRelay(
447
+ relayRequest("/v1/leads/submit", {
448
+ headers: { "x-custom": "1", "accept-language": "en" },
449
+ }),
450
+ );
451
+ const names = [...sent().headers.keys()].sort();
452
+ expect(names).toEqual(["content-type", "x-ailk-relayed", "x-ailk-spoke-key"]);
453
+ });
454
+ });
455
+
456
+ describe("hardening — what the site serves, and a missing hub address", () => {
457
+ const mediaType = (res: Response) =>
458
+ res.headers.get("content-type")?.split(";")[0]?.trim();
459
+
460
+ it("passes a JSON content-type through as the hub sent it", async () => {
461
+ fetchMock.mockImplementation(async () =>
462
+ hubAnswer(201, "{}", { "content-type": "application/json; charset=utf-8" }),
463
+ );
464
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
465
+ expect(res.headers.get("content-type")).toBe(
466
+ "application/json; charset=utf-8",
467
+ );
468
+ });
469
+
470
+ it.each([
471
+ ["an HTML error page", { "content-type": "text/html" }],
472
+ ["no content-type at all", {}],
473
+ ])("serves %s from the hub as text/plain, never as markup", async (_label, headers) => {
474
+ fetchMock.mockImplementation(async () =>
475
+ hubAnswer(502, "<p>bad gateway</p>", headers),
476
+ );
477
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
478
+ expect(res.status).toBe(502);
479
+ expect(mediaType(res)).toBe("text/plain");
480
+ expect(await res.text()).toBe("<p>bad gateway</p>");
481
+ });
482
+
483
+ it("marks every relayed answer and every refusal nosniff", async () => {
484
+ const relayed = await handleRelay(relayRequest("/v1/leads/submit"));
485
+ const refused = await handleRelay(relayRequest("/v1/nope"));
486
+ expect(relayed.headers.get("x-content-type-options")).toBe("nosniff");
487
+ expect(refused.headers.get("x-content-type-options")).toBe("nosniff");
488
+ expect(mediaType(refused)).toBe("application/json");
489
+ });
490
+
491
+ describe("in production", () => {
492
+ const env = process.env as Record<string, string | undefined>;
493
+ const priorNodeEnv = env.NODE_ENV;
494
+ beforeEach(() => {
495
+ env.NODE_ENV = "production";
496
+ });
497
+ afterEach(() => {
498
+ env.NODE_ENV = priorNodeEnv;
499
+ });
500
+
501
+ it("answers 502 relay_unreachable and calls nothing when no hub address is set", async () => {
502
+ delete process.env.AILK_API_URL;
503
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
504
+ expect(res.status).toBe(502);
505
+ expect(await errorCode(res)).toBe("relay_unreachable");
506
+ expect(fetchMock).not.toHaveBeenCalled();
507
+ });
508
+
509
+ it("forwards as usual when a hub address is set", async () => {
510
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
511
+ expect(res.status).toBe(201);
512
+ expect(fetchMock).toHaveBeenCalledTimes(1);
513
+ });
514
+ });
515
+ });