create-ailk 0.14.0 → 0.16.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 (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The one reader of the listing site key (#7872 D8, AC-6).
3
+ *
4
+ * `NEXT_PUBLIC_AILK_LISTING_SITE_KEY` wins; `AILK_LISTING_PUBLIC_KEY` is the
5
+ * fallback every site configured since create-ailk 0.12.0 already sets; an
6
+ * empty or whitespace value counts as unset, so the listicle directory stays
7
+ * off rather than reading the hub with a blank key.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { listingSiteKey, listingsEnabled } from "../listings-config";
13
+
14
+ const PUBLIC_NAME = "NEXT_PUBLIC_AILK_LISTING_SITE_KEY";
15
+ const FALLBACK_NAME = "AILK_LISTING_PUBLIC_KEY";
16
+
17
+ const original = {
18
+ public: process.env[PUBLIC_NAME],
19
+ fallback: process.env[FALLBACK_NAME],
20
+ };
21
+
22
+ function setEnv(name: string, value: string | undefined): void {
23
+ if (value === undefined) delete process.env[name];
24
+ else process.env[name] = value;
25
+ }
26
+
27
+ afterEach(() => {
28
+ setEnv(PUBLIC_NAME, original.public);
29
+ setEnv(FALLBACK_NAME, original.fallback);
30
+ });
31
+
32
+ describe("listingSiteKey / listingsEnabled", () => {
33
+ it("answers the trimmed public name when both are set", () => {
34
+ setEnv(PUBLIC_NAME, " site-public ");
35
+ setEnv(FALLBACK_NAME, "site-fallback");
36
+
37
+ expect(listingSiteKey()).toBe("site-public");
38
+ expect(listingsEnabled()).toBe(true);
39
+ });
40
+
41
+ it("answers the trimmed fallback when only it is set", () => {
42
+ setEnv(PUBLIC_NAME, undefined);
43
+ setEnv(FALLBACK_NAME, " site-fallback\n");
44
+
45
+ expect(listingSiteKey()).toBe("site-fallback");
46
+ expect(listingsEnabled()).toBe(true);
47
+ });
48
+
49
+ it("falls back when the public name is whitespace only", () => {
50
+ setEnv(PUBLIC_NAME, " ");
51
+ setEnv(FALLBACK_NAME, "site-fallback");
52
+
53
+ expect(listingSiteKey()).toBe("site-fallback");
54
+ expect(listingsEnabled()).toBe(true);
55
+ });
56
+
57
+ it("answers undefined when neither is set", () => {
58
+ setEnv(PUBLIC_NAME, undefined);
59
+ setEnv(FALLBACK_NAME, undefined);
60
+
61
+ expect(listingSiteKey()).toBeUndefined();
62
+ expect(listingsEnabled()).toBe(false);
63
+ });
64
+
65
+ it("answers undefined when both are empty or whitespace", () => {
66
+ setEnv(PUBLIC_NAME, "");
67
+ setEnv(FALLBACK_NAME, " \t");
68
+
69
+ expect(listingSiteKey()).toBeUndefined();
70
+ expect(listingsEnabled()).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("apps/web/.env.example", () => {
75
+ it("documents both names", () => {
76
+ const example = readFileSync(
77
+ join(__dirname, "..", "..", ".env.example"),
78
+ "utf8",
79
+ );
80
+ expect(example).toMatch(new RegExp(`^# ${PUBLIC_NAME}=`, "m"));
81
+ expect(example).toMatch(new RegExp(`^# ${FALLBACK_NAME}=`, "m"));
82
+ });
83
+ });
@@ -294,6 +294,55 @@ describe("no read without a key or the paid package", () => {
294
294
  });
295
295
  });
296
296
 
297
+ describe("the default key reader is listings-config's (#7872 D8)", () => {
298
+ const PUBLIC_NAME = "NEXT_PUBLIC_AILK_LISTING_SITE_KEY";
299
+ const FALLBACK_NAME = "AILK_LISTING_PUBLIC_KEY";
300
+ const original = { public: process.env[PUBLIC_NAME], fallback: process.env[FALLBACK_NAME] };
301
+
302
+ function setEnv(name: string, value: string | undefined): void {
303
+ if (value === undefined) delete process.env[name];
304
+ else process.env[name] = value;
305
+ }
306
+
307
+ /** Every dependency but the key: the extension falls back to its own reader. */
308
+ function keylessDepsExtension() {
309
+ return createListingExtension({
310
+ pathname: async () => path,
311
+ locales: () => ["en", "fr"],
312
+ fetchDirectory,
313
+ loadUi: async () => STUB_UI,
314
+ translate: async () => (key: string) => `listings.${key}`,
315
+ baseUrl: () => "https://site.test",
316
+ });
317
+ }
318
+
319
+ afterEach(() => {
320
+ setEnv(PUBLIC_NAME, original.public);
321
+ setEnv(FALLBACK_NAME, original.fallback);
322
+ });
323
+
324
+ it("reads the directory with NEXT_PUBLIC_AILK_LISTING_SITE_KEY", async () => {
325
+ setEnv(PUBLIC_NAME, "k");
326
+ setEnv(FALLBACK_NAME, undefined);
327
+ path = "/en/partners";
328
+
329
+ await keylessDepsExtension().appendix!({ type: "AboutPage", slug: "partners", locale: "en" });
330
+
331
+ expect(fetchDirectory).toHaveBeenCalledWith("k", "/partners", "en");
332
+ });
333
+
334
+ it("makes no read with neither variable set", async () => {
335
+ setEnv(PUBLIC_NAME, undefined);
336
+ setEnv(FALLBACK_NAME, undefined);
337
+ path = "/en/partners";
338
+
339
+ expect(
340
+ await keylessDepsExtension().appendix!({ type: "AboutPage", slug: "partners", locale: "en" }),
341
+ ).toBeNull();
342
+ expect(fetchDirectory).toHaveBeenCalledTimes(0);
343
+ });
344
+ });
345
+
297
346
  describe("a failed read renders the page as today", () => {
298
347
  let warn: jest.SpyInstance;
299
348
  beforeEach(() => {
@@ -1,7 +1,11 @@
1
1
  // oss: false
2
2
  /**
3
3
  * Tests for enabledMeetingSlugs (#6183 S9b D5) — the D4-resolving read both
4
- * /meet pages share.
4
+ * /meet pages share — and its spoke branch (#7872 D4).
5
+ *
6
+ * The topology seam is mocked: its hub render imports `../auth`, which this
7
+ * suite has no reason to load (`app/openapi.json/__tests__/route.test.ts`'s
8
+ * pattern).
5
9
  */
6
10
  const mockMeetingTypes = jest.fn();
7
11
  jest.mock("@working-theory/api-client", () => ({
@@ -9,6 +13,14 @@ jest.mock("@working-theory/api-client", () => ({
9
13
  schedule: { meetingTypes: (...args: unknown[]) => mockMeetingTypes(...args) },
10
14
  }));
11
15
 
16
+ const mockTopology: { value: "hub" | "spoke" } = { value: "hub" };
17
+ // eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426 D5), not a runtime input; the seam IS the boundary between a hub and a spoke build, and there is no env or HTTP surface that selects it.
18
+ jest.mock("../topology.generated", () => ({
19
+ get TOPOLOGY() {
20
+ return mockTopology.value;
21
+ },
22
+ }));
23
+
12
24
  import { enabledMeetingSlugs } from "../meeting-slugs";
13
25
 
14
26
  describe("enabledMeetingSlugs", () => {
@@ -16,6 +28,7 @@ describe("enabledMeetingSlugs", () => {
16
28
 
17
29
  beforeEach(() => {
18
30
  mockMeetingTypes.mockReset();
31
+ mockTopology.value = "hub";
19
32
  });
20
33
 
21
34
  afterEach(() => {
@@ -70,3 +83,38 @@ describe("enabledMeetingSlugs", () => {
70
83
  await expect(enabledMeetingSlugs()).resolves.toEqual([]);
71
84
  });
72
85
  });
86
+
87
+ describe("enabledMeetingSlugs on a spoke (#7872 D4)", () => {
88
+ const originalEnv = process.env["SCHEDULING_CAL_LINK"];
89
+
90
+ beforeEach(() => {
91
+ mockMeetingTypes.mockReset();
92
+ mockTopology.value = "spoke";
93
+ });
94
+
95
+ afterEach(() => {
96
+ mockTopology.value = "hub";
97
+ if (originalEnv === undefined) delete process.env["SCHEDULING_CAL_LINK"];
98
+ else process.env["SCHEDULING_CAL_LINK"] = originalEnv;
99
+ });
100
+
101
+ it("null + SCHEDULING_CAL_LINK set → [], never the fallback: the hub books only a stored slug", async () => {
102
+ process.env["SCHEDULING_CAL_LINK"] = "acme/intro-call";
103
+ mockMeetingTypes.mockResolvedValue({ ok: true, body: { slugs: null } });
104
+
105
+ await expect(enabledMeetingSlugs()).resolves.toEqual([]);
106
+ });
107
+
108
+ it("passes the workspace's stored list through unchanged", async () => {
109
+ mockMeetingTypes.mockResolvedValue({ ok: true, body: { slugs: ["intro"] } });
110
+
111
+ await expect(enabledMeetingSlugs()).resolves.toEqual(["intro"]);
112
+ });
113
+
114
+ it("a non-ok API result → [], fail closed", async () => {
115
+ process.env["SCHEDULING_CAL_LINK"] = "acme/intro-call";
116
+ mockMeetingTypes.mockResolvedValue({ ok: false, status: 401, body: null });
117
+
118
+ await expect(enabledMeetingSlugs()).resolves.toEqual([]);
119
+ });
120
+ });
@@ -11,6 +11,7 @@
11
11
  // Spec: docs/specs/7644-the-browser-talks-only-to-spec.md §5.1 AC-1 to AC-4.
12
12
 
13
13
  import { handleRelay, RELAY_ROUTES } from "../relay";
14
+ import { __resetRelayRateLimit, __relayRateLimitBucketCount } from "../relay";
14
15
 
15
16
  const HUB = "https://hub.example";
16
17
  const SITE = "https://spoke.example";
@@ -124,6 +125,7 @@ function entryRequest(
124
125
  let errorSpy: jest.SpyInstance;
125
126
 
126
127
  beforeEach(() => {
128
+ __resetRelayRateLimit();
127
129
  fetchMock.mockClear();
128
130
  fetchMock.mockImplementation(async () => hubAnswer());
129
131
  global.fetch = fetchMock as jest.Mock;
@@ -511,5 +513,320 @@ describe("hardening — what the site serves, and a missing hub address", () =>
511
513
  expect(res.status).toBe(201);
512
514
  expect(fetchMock).toHaveBeenCalledTimes(1);
513
515
  });
516
+
517
+ // #7874 D12 — the fallback reads the browser API origin seam. The
518
+ // committed seam is the hub render, so this case runs it unmocked.
519
+ it("on a hub, NEXT_PUBLIC_AILK_API_URL alone still counts as a hub address", async () => {
520
+ delete process.env.AILK_API_URL;
521
+ process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
522
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
523
+ expect(res.status).toBe(201);
524
+ expect(fetchMock).toHaveBeenCalledTimes(1);
525
+ });
526
+
527
+ it("on a spoke (the seam's spoke render), that fallback is skipped", async () => {
528
+ delete process.env.AILK_API_URL;
529
+ process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
530
+ let res: Response | undefined;
531
+ await jest.isolateModulesAsync(async () => {
532
+ jest.doMock("../api-origin.generated", () => ({
533
+ browserApiOrigin: (): string | undefined => undefined,
534
+ }));
535
+ const spoke = await import("../relay");
536
+ res = await spoke.handleRelay(relayRequest("/v1/leads/submit"));
537
+ });
538
+ if (res === undefined) throw new Error("the spoke relay did not answer");
539
+ expect(res.status).toBe(502);
540
+ expect(await errorCode(res)).toBe("relay_unreachable");
541
+ expect(fetchMock).not.toHaveBeenCalled();
542
+ });
543
+ });
544
+ });
545
+
546
+ // The relay's own per-visitor rate limit (#7707, spec
547
+ // docs/specs/7707-relay-rate-limit-spec.md §5.1 AC-1 to AC-9).
548
+ describe("the per-visitor rate limit (#7707)", () => {
549
+ const VISITOR = { "x-forwarded-for": "203.0.113.7" };
550
+
551
+ /** Spends the first visitor's 8-call visitor budget, then clears fetchMock. */
552
+ async function spendVisitorBudget(
553
+ headers: Record<string, string | null> = VISITOR,
554
+ ): Promise<void> {
555
+ for (let i = 0; i < 8; i++) {
556
+ await handleRelay(relayRequest("/v1/leads/submit", { headers }));
557
+ }
558
+ fetchMock.mockClear();
559
+ }
560
+
561
+ describe("AC-1 — 8 forwarded, the 9th refused, on each visitor route", () => {
562
+ it.each(VISITOR_ENTRIES)(
563
+ "$method $path: first 8 calls forwarded, the 9th refused",
564
+ async (entry) => {
565
+ for (let i = 0; i < 8; i++) {
566
+ const res = await handleRelay(entryRequest(entry, VISITOR));
567
+ expect(res.status).not.toBe(429);
568
+ }
569
+ fetchMock.mockClear();
570
+
571
+ const res = await handleRelay(entryRequest(entry, VISITOR));
572
+ expect(res.status).toBe(429);
573
+ expect(await errorCode(res)).toBe("rate_limited");
574
+ expect(res.headers.get("retry-after")).toBe("600");
575
+ expect(res.headers.get("cache-control")).toBe("no-store");
576
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
577
+ expect(fetchMock).not.toHaveBeenCalled();
578
+ },
579
+ );
580
+ });
581
+
582
+ describe("AC-2 — the budget is shared across the five visitor routes", () => {
583
+ it("2 calls each to submit/subscribe/callback/availability, then book is refused", async () => {
584
+ const shared = VISITOR_ENTRIES.slice(0, 4);
585
+ for (const entry of shared) {
586
+ for (let i = 0; i < 2; i++) {
587
+ const res = await handleRelay(entryRequest(entry, VISITOR));
588
+ expect(res.status).not.toBe(429);
589
+ }
590
+ }
591
+ const book = VISITOR_ENTRIES[4];
592
+ if (!book) throw new Error("expected a fifth visitor entry");
593
+ const res = await handleRelay(entryRequest(book, VISITOR));
594
+ expect(res.status).toBe(429);
595
+ });
596
+ });
597
+
598
+ describe("AC-3 — the budget applies with and without AILK_VISITOR_FORWARD_KEY", () => {
599
+ const FORWARD_KEY_32 = "f".repeat(32);
600
+
601
+ it.each([
602
+ ["unset", undefined],
603
+ ["set", FORWARD_KEY_32],
604
+ ])("forward key %s: still 8 forwarded then refused", async (_label, key) => {
605
+ if (key) process.env.AILK_VISITOR_FORWARD_KEY = key;
606
+ else delete process.env.AILK_VISITOR_FORWARD_KEY;
607
+
608
+ await spendVisitorBudget();
609
+ const res = await handleRelay(relayRequest("/v1/leads/submit", { headers: VISITOR }));
610
+ expect(res.status).toBe(429);
611
+ expect(fetchMock).not.toHaveBeenCalled();
612
+ });
613
+ });
614
+
615
+ describe("AC-4 — identity", () => {
616
+ it("a different visitor IP is forwarded", async () => {
617
+ await spendVisitorBudget();
618
+ const res = await handleRelay(
619
+ relayRequest("/v1/leads/submit", {
620
+ headers: { "x-forwarded-for": "198.51.100.9" },
621
+ }),
622
+ );
623
+ expect(res.status).not.toBe(429);
624
+ });
625
+
626
+ it("the first value of a multi-hop x-forwarded-for is the identity", async () => {
627
+ await spendVisitorBudget();
628
+ const res = await handleRelay(
629
+ relayRequest("/v1/leads/submit", {
630
+ headers: { "x-forwarded-for": "203.0.113.7, 10.0.0.1" },
631
+ }),
632
+ );
633
+ expect(res.status).toBe(429);
634
+ });
635
+
636
+ it("an IPv4-mapped IPv6 address maps to its IPv4 identity", async () => {
637
+ await spendVisitorBudget();
638
+ const res = await handleRelay(
639
+ relayRequest("/v1/leads/submit", {
640
+ headers: { "x-forwarded-for": "::ffff:203.0.113.7" },
641
+ }),
642
+ );
643
+ expect(res.status).toBe(429);
644
+ });
645
+
646
+ it("x-real-ip alone resolves to the same identity as x-forwarded-for", async () => {
647
+ await spendVisitorBudget();
648
+ const res = await handleRelay(
649
+ relayRequest("/v1/leads/submit", {
650
+ headers: { "x-forwarded-for": null, "x-real-ip": "203.0.113.7" },
651
+ }),
652
+ );
653
+ expect(res.status).toBe(429);
654
+ });
655
+
656
+ it("an IPv6 /64 prefix is one identity", async () => {
657
+ await spendVisitorBudget({ "x-forwarded-for": "2001:db8:0:0::1" });
658
+
659
+ const blocked = await handleRelay(
660
+ relayRequest("/v1/leads/submit", {
661
+ headers: { "x-forwarded-for": "2001:db8::2" },
662
+ }),
663
+ );
664
+ expect(blocked.status).toBe(429);
665
+
666
+ const forwarded = await handleRelay(
667
+ relayRequest("/v1/leads/submit", {
668
+ headers: { "x-forwarded-for": "2001:db8:0:1::1" },
669
+ }),
670
+ );
671
+ expect(forwarded.status).not.toBe(429);
672
+ });
673
+
674
+ it("an unparseable address shares the unknown identity with no address at all", async () => {
675
+ await spendVisitorBudget({ "x-forwarded-for": null, "x-real-ip": null });
676
+ const res = await handleRelay(
677
+ relayRequest("/v1/leads/submit", {
678
+ headers: { "x-forwarded-for": "not-an-ip" },
679
+ }),
680
+ );
681
+ expect(res.status).toBe(429);
682
+ });
683
+ });
684
+
685
+ describe("AC-5 — the window resets after windowMs", () => {
686
+ it("refused just before the window ends, forwarded once it does", async () => {
687
+ const T0 = Date.now();
688
+ const nowSpy = jest.spyOn(Date, "now").mockReturnValue(T0);
689
+
690
+ await spendVisitorBudget();
691
+ const stillBlocked = await handleRelay(
692
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
693
+ );
694
+ expect(stillBlocked.status).toBe(429);
695
+
696
+ nowSpy.mockReturnValue(T0 + 599_000);
697
+ const almostThere = await handleRelay(
698
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
699
+ );
700
+ expect(almostThere.status).toBe(429);
701
+ expect(almostThere.headers.get("retry-after")).toBe("1");
702
+
703
+ nowSpy.mockReturnValue(T0 + 600_000);
704
+ const freshWindow = await handleRelay(
705
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
706
+ );
707
+ expect(freshWindow.status).not.toBe(429);
708
+
709
+ nowSpy.mockRestore();
710
+ });
711
+ });
712
+
713
+ describe("AC-6 — refusals before the budget check spend nothing", () => {
714
+ it("403s, 404s and 508s before the budget check don't consume it", async () => {
715
+ for (let i = 0; i < 12; i++) {
716
+ const res = await handleRelay(
717
+ relayRequest("/v1/leads/submit", {
718
+ headers: { ...VISITOR, "sec-fetch-site": "cross-site" },
719
+ }),
720
+ );
721
+ expect(res.status).toBe(403);
722
+ }
723
+ for (let i = 0; i < 12; i++) {
724
+ const res = await handleRelay(
725
+ relayRequest("/v1/workspaces", { method: "POST", headers: VISITOR }),
726
+ );
727
+ expect(res.status).toBe(404);
728
+ }
729
+ for (let i = 0; i < 12; i++) {
730
+ const res = await handleRelay(
731
+ relayRequest("/v1/leads/submit", {
732
+ headers: { ...VISITOR, "x-ailk-relayed": "1" },
733
+ }),
734
+ );
735
+ expect(res.status).toBe(508);
736
+ }
737
+
738
+ fetchMock.mockClear();
739
+ for (let i = 0; i < 8; i++) {
740
+ const res = await handleRelay(
741
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
742
+ );
743
+ expect(res.status).not.toBe(429);
744
+ }
745
+ expect(fetchMock).toHaveBeenCalledTimes(8);
746
+ });
747
+ });
748
+
749
+ describe("AC-7 — the budget check precedes content-type and body-size checks", () => {
750
+ it("a 9th call with a bad content-type gets 429, not 415", async () => {
751
+ await spendVisitorBudget();
752
+ const res = await handleRelay(
753
+ relayRequest("/v1/leads/submit", {
754
+ headers: { ...VISITOR, "content-type": "text/plain" },
755
+ }),
756
+ );
757
+ expect(res.status).toBe(429);
758
+ });
759
+
760
+ it("a 9th call with an oversized body gets 429, not 413", async () => {
761
+ await spendVisitorBudget();
762
+ const res = await handleRelay(
763
+ relayRequest("/v1/leads/submit", {
764
+ headers: VISITOR,
765
+ body: "x".repeat(1_048_577),
766
+ }),
767
+ );
768
+ expect(res.status).toBe(429);
769
+ });
770
+ });
771
+
772
+ describe("AC-8 — the session route has its own, wider, independent budget", () => {
773
+ it("30 forwarded, the 31st refused; the visitor budget is untouched", async () => {
774
+ for (let i = 0; i < 30; i++) {
775
+ const res = await handleRelay(
776
+ relayRequest("/v1/commerce/invoices", { headers: VISITOR }),
777
+ );
778
+ expect(res.status).not.toBe(429);
779
+ }
780
+ fetchMock.mockClear();
781
+
782
+ const refused = await handleRelay(
783
+ relayRequest("/v1/commerce/invoices", { headers: VISITOR }),
784
+ );
785
+ expect(refused.status).toBe(429);
786
+ expect(refused.headers.get("retry-after")).toBe("600");
787
+ expect(fetchMock).not.toHaveBeenCalled();
788
+
789
+ const stillVisitor = await handleRelay(
790
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
791
+ );
792
+ expect(stillVisitor.status).not.toBe(429);
793
+ });
794
+
795
+ it("a fresh visitor's visitor budget doesn't touch the session budget", async () => {
796
+ const OTHER = { "x-forwarded-for": "198.51.100.42" };
797
+ for (let i = 0; i < 8; i++) {
798
+ await handleRelay(relayRequest("/v1/leads/submit", { headers: OTHER }));
799
+ }
800
+ const res = await handleRelay(
801
+ relayRequest("/v1/commerce/invoices", { headers: OTHER }),
802
+ );
803
+ expect(res.status).not.toBe(429);
804
+ });
805
+ });
806
+
807
+ describe("AC-9 — the store is capped and evicts the oldest entry first", () => {
808
+ it(
809
+ "the first visitor's bucket is evicted at capacity, so its next call opens a fresh window",
810
+ async () => {
811
+ await spendVisitorBudget();
812
+ for (let i = 0; i < 10_000; i++) {
813
+ const ip = `10.${(i >> 16) & 0xff}.${(i >> 8) & 0xff}.${i & 0xff}`;
814
+ await handleRelay(
815
+ relayRequest("/v1/leads/submit", {
816
+ headers: { "x-forwarded-for": ip },
817
+ }),
818
+ );
819
+ }
820
+ expect(__relayRateLimitBucketCount()).toBe(10_000);
821
+
822
+ fetchMock.mockClear();
823
+ const res = await handleRelay(
824
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
825
+ );
826
+ expect(res.status).not.toBe(429);
827
+ expect(fetchMock).toHaveBeenCalledTimes(1);
828
+ },
829
+ 30_000,
830
+ );
514
831
  });
515
832
  });
@@ -118,6 +118,58 @@ describe("SITE_NAME / SITE_BASE_URL (#4157)", () => {
118
118
  });
119
119
  });
120
120
 
121
+ /**
122
+ * `COPYRIGHT_HOLDER` — the footer's holder (#7796): `siteIdentity.legalName`
123
+ * when declared, else the site name, exactly as before.
124
+ */
125
+ describe("COPYRIGHT_HOLDER (#7796)", () => {
126
+ function writeIdentity(identityLines: string): void {
127
+ writeFileSync(
128
+ join(tmpRoot, "_site.mdx"),
129
+ `---\nsiteName: Wendi Henry\nsiteIdentity:\n kind: Person\n name: Wendi Henry\n${identityLines}---\n`,
130
+ );
131
+ process.env["AILK_CONTENT_ROOT"] = tmpRoot;
132
+ delete process.env["NEXT_PUBLIC_SITE_NAME"];
133
+ }
134
+
135
+ it("is siteIdentity.legalName when the site declares one", async () => {
136
+ writeIdentity(" legalName: SalesSmyth LLC\n");
137
+ const { COPYRIGHT_HOLDER, SITE_NAME } = await loadSiteBrand();
138
+
139
+ expect(COPYRIGHT_HOLDER).toBe("SalesSmyth LLC");
140
+ expect(SITE_NAME).toBe("Wendi Henry");
141
+ });
142
+
143
+ it("falls back to SITE_NAME when no legalName is declared", async () => {
144
+ writeIdentity("");
145
+ const { COPYRIGHT_HOLDER, SITE_NAME } = await loadSiteBrand();
146
+
147
+ expect(COPYRIGHT_HOLDER).toBe(SITE_NAME);
148
+ });
149
+
150
+ it("falls back to SITE_NAME when legalName is blank or not a string", async () => {
151
+ writeIdentity(' legalName: " "\n');
152
+ expect((await loadSiteBrand()).COPYRIGHT_HOLDER).toBe("Wendi Henry");
153
+
154
+ writeIdentity(" legalName: 42\n");
155
+ expect((await loadSiteBrand()).COPYRIGHT_HOLDER).toBe("Wendi Henry");
156
+ });
157
+
158
+ it("follows the NEXT_PUBLIC_SITE_NAME override when no legalName is declared", async () => {
159
+ writeIdentity("");
160
+ process.env["NEXT_PUBLIC_SITE_NAME"] = "Env Override Co";
161
+ expect((await loadSiteBrand()).COPYRIGHT_HOLDER).toBe("Env Override Co");
162
+ });
163
+
164
+ it("falls back to the neutral default when _site.mdx is absent", async () => {
165
+ process.env["AILK_CONTENT_ROOT"] = tmpRoot;
166
+ delete process.env["NEXT_PUBLIC_SITE_NAME"];
167
+ const { COPYRIGHT_HOLDER, FALLBACK_SITE_NAME } = await loadSiteBrand();
168
+
169
+ expect(COPYRIGHT_HOLDER).toBe(FALLBACK_SITE_NAME);
170
+ });
171
+ });
172
+
121
173
  /**
122
174
  * `siteLogo()` — the brand triad's logomark (#6820 D5). No `brand:` renders
123
175
  * exactly today's element; a declared mark is the one rendered, with its dark