create-ailk 0.11.5 → 0.12.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 (76) hide show
  1. package/component-catalog.json +35 -2
  2. package/component-catalog.md +9 -6
  3. package/dist/add-module.js +13 -0
  4. package/dist/module-architecture.d.ts +8 -0
  5. package/dist/module-architecture.js +33 -0
  6. package/dist/paid-sections.d.ts +23 -0
  7. package/dist/paid-sections.js +119 -0
  8. package/dist/programmatic.js +8 -0
  9. package/package.json +3 -3
  10. package/templates/.env.example +69 -3
  11. package/templates/apps/api/.env.example +22 -0
  12. package/templates/apps/api/CLAUDE.md +19 -7
  13. package/templates/apps/api/package.json +1 -0
  14. package/templates/apps/api/src/bin/spokes.ts +137 -0
  15. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
  16. package/templates/apps/api/src/config/index.ts +97 -0
  17. package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
  18. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
  19. package/templates/apps/api/src/lib/spoke-store.ts +131 -0
  20. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
  21. package/templates/apps/api/src/routes/leads/index.ts +33 -3
  22. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
  23. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
  24. package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
  25. package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
  26. package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
  27. package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
  28. package/templates/apps/api/src/server.ts +48 -0
  29. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
  30. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
  31. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
  32. package/templates/apps/api/src/services/default-tenant.ts +51 -28
  33. package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
  34. package/templates/apps/api/src/services/lead-routing.ts +13 -1
  35. package/templates/apps/api/src/services/project-listings.ts +209 -18
  36. package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
  37. package/templates/apps/api/src/services/slack-notify.ts +54 -3
  38. package/templates/apps/web/.env.example +14 -0
  39. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
  40. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
  41. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
  42. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
  43. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
  44. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
  45. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
  46. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
  47. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
  48. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
  49. package/templates/apps/web/app/[locale]/layout.tsx +4 -0
  50. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
  51. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
  52. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
  53. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
  54. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
  55. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
  56. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
  57. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
  58. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
  59. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
  60. package/templates/apps/web/jest.config.cjs +1 -1
  61. package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
  62. package/templates/apps/web/lib/listings.tsx +472 -0
  63. package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
  64. package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
  65. package/templates/apps/web/messages/en.json +10 -0
  66. package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
  67. package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
  68. package/templates/database/CHANGELOG.md +24 -0
  69. package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
  70. package/templates/database/auth/schema.prisma +31 -0
  71. package/templates/database/config/schema.prisma +26 -0
  72. package/templates/database/inbox/schema.prisma +21 -0
  73. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
  74. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
  75. package/templates/database/package.json +1 -1
  76. package/versions.json +34 -34
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @file spoke-store.test.ts
3
+ *
4
+ * The hub's spoke registry boundary (#7532, spec
5
+ * docs/specs/7532-a-spoke-s-public-reads-spec.md D2, D7, AC-5): minting a key,
6
+ * its digest, the one lookup the resolver makes, and the three operator
7
+ * functions the `spokes` CLI calls.
8
+ *
9
+ * External dependency boundary mocked:
10
+ * - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
11
+ */
12
+
13
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
14
+ jest.mock("../prisma.js");
15
+
16
+ import { createHash } from "node:crypto";
17
+
18
+ import { prisma } from "../prisma.js";
19
+ import {
20
+ SPOKE_KEY_PREFIX,
21
+ findSpokeByKeyHash,
22
+ hashSpokeKey,
23
+ listSpokes,
24
+ mintSpokeKey,
25
+ registerSpoke,
26
+ revokeSpoke,
27
+ } from "../spoke-store.js";
28
+
29
+ const db = jest.mocked(prisma);
30
+
31
+ const W1 = "wksp_00000000-0000-7000-8000-0000000000b1";
32
+ const S1 = "spoke_00000000-0000-7000-8000-0000000000c1";
33
+
34
+ beforeEach(() => {
35
+ jest.resetAllMocks();
36
+ });
37
+
38
+ describe("mintSpokeKey / hashSpokeKey (D2)", () => {
39
+ it("mints `ailk_spk_` + 32 random bytes in base64url — 52 characters", () => {
40
+ const key = mintSpokeKey();
41
+ expect(key.startsWith(SPOKE_KEY_PREFIX)).toBe(true);
42
+ expect(key).toHaveLength(52);
43
+ expect(key.slice(SPOKE_KEY_PREFIX.length)).toMatch(/^[A-Za-z0-9_-]{43}$/);
44
+ });
45
+
46
+ it("never mints the same key twice", () => {
47
+ const keys = new Set(Array.from({ length: 50 }, () => mintSpokeKey()));
48
+ expect(keys.size).toBe(50);
49
+ });
50
+
51
+ it("digests the TRIMMED key to lowercase hex SHA-256", () => {
52
+ const key = mintSpokeKey();
53
+ const expected = createHash("sha256").update(key).digest("hex");
54
+ expect(hashSpokeKey(key)).toBe(expected);
55
+ expect(hashSpokeKey(` ${key}\t`)).toBe(expected);
56
+ expect(hashSpokeKey(key)).toMatch(/^[0-9a-f]{64}$/);
57
+ });
58
+ });
59
+
60
+ describe("findSpokeByKeyHash (D3 step 5)", () => {
61
+ it("is one findUnique by digest, selecting only what the resolver needs", async () => {
62
+ const row = { id: S1, workspaceId: W1, revokedAt: null };
63
+ db.spoke.findUnique.mockResolvedValue(row as never);
64
+ const digest = "a".repeat(64);
65
+
66
+ await expect(findSpokeByKeyHash(digest)).resolves.toEqual(row);
67
+ expect(db.spoke.findUnique).toHaveBeenCalledTimes(1);
68
+ expect(db.spoke.findUnique).toHaveBeenCalledWith({
69
+ where: { credentialHash: digest },
70
+ select: { id: true, workspaceId: true, revokedAt: true },
71
+ });
72
+ });
73
+
74
+ it("answers null when no row carries the digest", async () => {
75
+ db.spoke.findUnique.mockResolvedValue(null);
76
+ await expect(findSpokeByKeyHash("b".repeat(64))).resolves.toBeNull();
77
+ });
78
+ });
79
+
80
+ describe("registerSpoke (D7, AC-5)", () => {
81
+ it("stores the key's digest and a 17-character prefix, never the key", async () => {
82
+ db.workspace.findUnique.mockResolvedValue({ id: W1 } as never);
83
+ db.spoke.create.mockImplementation((async (args: {
84
+ data: Record<string, unknown>;
85
+ }) => ({ id: S1, createdAt: new Date(), ...args.data })) as never);
86
+
87
+ const result = await registerSpoke({ workspaceId: W1, name: "acme" });
88
+
89
+ expect(result.ok).toBe(true);
90
+ if (!result.ok) return;
91
+ const createArg = db.spoke.create.mock.calls[0]![0] as {
92
+ data: Record<string, unknown>;
93
+ };
94
+ expect(createArg.data["credentialHash"]).toMatch(/^[0-9a-f]{64}$/);
95
+ expect(createArg.data["credentialHash"]).toBe(hashSpokeKey(result.key));
96
+ expect(createArg.data["credentialPrefix"]).toBe(result.key.slice(0, 17));
97
+ expect(createArg.data["workspaceId"]).toBe(W1);
98
+ expect(createArg.data["name"]).toBe("acme");
99
+ // No create argument equals the returned key.
100
+ expect(JSON.stringify(createArg)).not.toContain(result.key);
101
+ expect(result.spoke.id).toBe(S1);
102
+ });
103
+
104
+ it("refuses a workspace that does not exist, creating nothing", async () => {
105
+ db.workspace.findUnique.mockResolvedValue(null);
106
+
107
+ await expect(
108
+ registerSpoke({ workspaceId: W1, name: "acme" }),
109
+ ).resolves.toEqual({ ok: false, reason: "workspace-not-found" });
110
+ expect(db.spoke.create).not.toHaveBeenCalled();
111
+ });
112
+ });
113
+
114
+ describe("revokeSpoke (D7, AC-5)", () => {
115
+ it("sets revokedAt on that one unrevoked row only", async () => {
116
+ db.spoke.updateMany.mockResolvedValue({ count: 1 });
117
+
118
+ await expect(revokeSpoke(S1)).resolves.toBe(true);
119
+ const arg = db.spoke.updateMany.mock.calls[0]![0] as {
120
+ where: unknown;
121
+ data: { revokedAt: unknown };
122
+ };
123
+ expect(arg.where).toEqual({ id: S1, revokedAt: null });
124
+ expect(arg.data.revokedAt).toBeInstanceOf(Date);
125
+ });
126
+
127
+ it("reports false when no unrevoked row matched", async () => {
128
+ db.spoke.updateMany.mockResolvedValue({ count: 0 });
129
+ await expect(revokeSpoke(S1)).resolves.toBe(false);
130
+ });
131
+ });
132
+
133
+ describe("listSpokes (D7, AC-5)", () => {
134
+ it("never selects the credential digest", async () => {
135
+ db.spoke.findMany.mockResolvedValue([]);
136
+
137
+ await listSpokes();
138
+
139
+ const arg = db.spoke.findMany.mock.calls[0]![0] as {
140
+ select: Record<string, boolean>;
141
+ };
142
+ expect(arg.select).not.toHaveProperty("credentialHash");
143
+ expect(arg.select).toEqual({
144
+ id: true,
145
+ name: true,
146
+ workspaceId: true,
147
+ credentialPrefix: true,
148
+ createdAt: true,
149
+ revokedAt: true,
150
+ });
151
+ });
152
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The hub's spoke registry — its ONE data-access boundary (#7532, spec
3
+ * docs/specs/7532-a-spoke-s-public-reads-spec.md D1, D2, D7).
4
+ *
5
+ * A spoke is a separately deployed site that calls this API server-to-server,
6
+ * bound to exactly one workspace. It authenticates with a hub-minted key sent
7
+ * in `x-ailk-spoke-key`. This file is the only one that names `prisma.spoke`.
8
+ *
9
+ * THE KEY IS NEVER STORED. The row keeps the lowercase hex SHA-256 of the
10
+ * trimmed key (`credentialHash`) and its first 17 characters
11
+ * (`credentialPrefix`: `ailk_spk_` plus 8 random ones) so an operator can
12
+ * recognise a key in a list. A fast digest is enough: the key carries 256
13
+ * bits of entropy, so a lookup by its unique digest leaks nothing usable.
14
+ *
15
+ * WHAT A KEY PROVES. It identifies a deployed SITE, not a person: every
16
+ * anonymous visitor of that site, and every agent using its `apps/mcp`, acts
17
+ * through it. It grants what the site's anonymous visitors can already do on
18
+ * the hub — read that workspace's enabled meeting types and public content,
19
+ * and write leads and bookings into it.
20
+ *
21
+ * Two kinds of caller, kept apart:
22
+ * - the public resolver (`services/public-site-resolver.ts`) calls
23
+ * `hashSpokeKey` and `findSpokeByKeyHash` only;
24
+ * - the operator CLI (`bin/spokes.ts`) calls `registerSpoke`, `revokeSpoke`
25
+ * and `listSpokes`. No route imports them.
26
+ *
27
+ * Nothing caches a row: a revocation takes effect on the next request (NFR4).
28
+ */
29
+ import { createHash, randomBytes } from "node:crypto";
30
+
31
+ import { prisma } from "./prisma.js";
32
+
33
+ /** Every spoke key starts with this, so a leaked one is recognisable. */
34
+ export const SPOKE_KEY_PREFIX = "ailk_spk_";
35
+
36
+ /** How much of the key the row keeps for recognition: the prefix + 8 characters. */
37
+ const CREDENTIAL_PREFIX_LENGTH = SPOKE_KEY_PREFIX.length + 8;
38
+
39
+ /** A new key: `ailk_spk_` + 32 random bytes in base64url (52 characters). */
40
+ export function mintSpokeKey(): string {
41
+ return `${SPOKE_KEY_PREFIX}${randomBytes(32).toString("base64url")}`;
42
+ }
43
+
44
+ /** The stored digest of a key: lowercase hex SHA-256 of the TRIMMED value. */
45
+ export function hashSpokeKey(key: string): string {
46
+ return createHash("sha256").update(key.trim()).digest("hex");
47
+ }
48
+
49
+ export interface SpokeLookupRow {
50
+ id: string;
51
+ workspaceId: string;
52
+ revokedAt: Date | null;
53
+ }
54
+
55
+ /** The one read the resolver makes: a `findUnique` by digest. */
56
+ export async function findSpokeByKeyHash(
57
+ credentialHash: string,
58
+ ): Promise<SpokeLookupRow | null> {
59
+ return prisma.spoke.findUnique({
60
+ where: { credentialHash },
61
+ select: { id: true, workspaceId: true, revokedAt: true },
62
+ });
63
+ }
64
+
65
+ // ── Operator functions — `bin/spokes.ts` only ────────────────────────────────
66
+
67
+ export interface SpokeListRow {
68
+ id: string;
69
+ name: string;
70
+ workspaceId: string;
71
+ credentialPrefix: string;
72
+ createdAt: Date;
73
+ revokedAt: Date | null;
74
+ }
75
+
76
+ const SPOKE_LIST_SELECT = {
77
+ id: true,
78
+ name: true,
79
+ workspaceId: true,
80
+ credentialPrefix: true,
81
+ createdAt: true,
82
+ revokedAt: true,
83
+ } as const;
84
+
85
+ export type RegisterSpokeResult =
86
+ | { ok: true; spoke: SpokeListRow; key: string }
87
+ | { ok: false; reason: "workspace-not-found" };
88
+
89
+ /**
90
+ * Register a spoke bound to one existing workspace. The returned `key` is the
91
+ * only copy that will ever exist: the caller prints it once.
92
+ */
93
+ export async function registerSpoke(params: {
94
+ workspaceId: string;
95
+ name: string;
96
+ }): Promise<RegisterSpokeResult> {
97
+ const workspace = await prisma.workspace.findUnique({
98
+ where: { id: params.workspaceId },
99
+ select: { id: true },
100
+ });
101
+ if (!workspace) return { ok: false, reason: "workspace-not-found" };
102
+
103
+ const key = mintSpokeKey();
104
+ const spoke = await prisma.spoke.create({
105
+ data: {
106
+ workspaceId: params.workspaceId,
107
+ name: params.name,
108
+ credentialHash: hashSpokeKey(key),
109
+ credentialPrefix: key.slice(0, CREDENTIAL_PREFIX_LENGTH),
110
+ },
111
+ select: SPOKE_LIST_SELECT,
112
+ });
113
+ return { ok: true, spoke, key };
114
+ }
115
+
116
+ /** Revoke one spoke. `false` when no unrevoked spoke has that id. */
117
+ export async function revokeSpoke(id: string): Promise<boolean> {
118
+ const { count } = await prisma.spoke.updateMany({
119
+ where: { id, revokedAt: null },
120
+ data: { revokedAt: new Date() },
121
+ });
122
+ return count === 1;
123
+ }
124
+
125
+ /** Every spoke, newest first. Never selects the credential digest. */
126
+ export async function listSpokes(): Promise<SpokeListRow[]> {
127
+ return prisma.spoke.findMany({
128
+ select: SPOKE_LIST_SELECT,
129
+ orderBy: { createdAt: "desc" },
130
+ });
131
+ }
@@ -0,0 +1,331 @@
1
+ /**
2
+ * @file spoke.test.ts
3
+ *
4
+ * `POST /v1/leads/{submit,subscribe,callback}` called by a registered spoke of
5
+ * a multi-workspace hub (#7532, spec
6
+ * docs/specs/7532-a-spoke-s-public-reads-spec.md D3, D4; AC-2, AC-3, AC-5).
7
+ * The three real lead routes run through the real resolver and the real
8
+ * lead-routing service; only the database and the email provider are doubles.
9
+ *
10
+ * Fixture (spec § 5): three workspaces — the sentinel default `F` (the site
11
+ * alias), `W1` and `W2`; spokes `S1` → `W1`, `S1b` → `W1`, `S2` → `W2`.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { Writable } from "node:stream";
15
+
16
+ import Fastify from "fastify";
17
+ import type { FastifyInstance } from "fastify";
18
+
19
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
20
+ jest.mock("../../../lib/prisma.js");
21
+
22
+ jest.mock("@working-theory/email", () => ({
23
+ sendLeadNotification: jest.fn(),
24
+ sendRequestConfirmEmail: jest.fn(),
25
+ sendRequestPaidLinkEmail: jest.fn(),
26
+ }));
27
+
28
+ // @working-theory/license: consumed transitively by enrichment-shim; mocked to
29
+ // avoid needing a license key in tests (the leads index suite's own reason).
30
+ jest.mock("@working-theory/license", () => ({
31
+ checkLicense: jest.fn().mockResolvedValue({
32
+ tier: "free",
33
+ plan: null,
34
+ features: [],
35
+ expiresAt: null,
36
+ revoked: false,
37
+ }),
38
+ }));
39
+
40
+ // The real request-confirm service, with its link sender observable: a
41
+ // spoke's callback must never reach it (spec D4, SEC-7532-2). The call itself
42
+ // is what AC-2 asserts, so the double wraps the real function rather than
43
+ // replacing it.
44
+ // eslint-disable-next-line no-restricted-syntax -- AC-2 asserts this internal function is never called; requireActual keeps its behaviour
45
+ jest.mock("../../../services/request-confirm.js", () => {
46
+ const actual = jest.requireActual("../../../services/request-confirm.js");
47
+ return {
48
+ ...actual,
49
+ sendRequestConfirmLink: jest.fn(actual.sendRequestConfirmLink),
50
+ };
51
+ });
52
+
53
+ import * as configModule from "../../../config/index.js";
54
+ import {
55
+ SENTINEL_WORKSPACE_ID,
56
+ mockSentinelWorkspaceExists,
57
+ } from "../../../lib/__mocks__/prisma.js";
58
+ import { prisma } from "../../../lib/prisma.js";
59
+ import { __resetSiteWorkspaceCacheForTests } from "../../../lib/site.js";
60
+ import { sendRequestConfirmLink } from "../../../services/request-confirm.js";
61
+ import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
62
+ import { leadCallbackRoute, leadSubmitRoute, leadSubscribeRoute } from "../index";
63
+
64
+ const db = jest.mocked(prisma);
65
+ const email = jest.requireMock("@working-theory/email") as {
66
+ sendLeadNotification: jest.Mock;
67
+ sendRequestConfirmEmail: jest.Mock;
68
+ };
69
+
70
+ const F = SENTINEL_WORKSPACE_ID;
71
+ const W1 = "wksp_01920000-0000-7000-8000-0000000000b1";
72
+ const W2 = "wksp_01920000-0000-7000-8000-0000000000b2";
73
+ const S1 = "spoke_01920000-0000-7000-8000-0000000000c1";
74
+ const S1B = "spoke_01920000-0000-7000-8000-0000000000c2";
75
+ const S2 = "spoke_01920000-0000-7000-8000-0000000000c3";
76
+
77
+ function spokeKey(tag: string): string {
78
+ return `ailk_spk_${tag}${"x".repeat(43 - tag.length)}`;
79
+ }
80
+ const KEY_S1 = spokeKey("S1");
81
+ const KEY_S1B = spokeKey("S1b");
82
+ const KEY_S2 = spokeKey("S2");
83
+ const KEY_UNKNOWN = spokeKey("nobody");
84
+ const ALL_KEYS = [KEY_S1, KEY_S1B, KEY_S2, KEY_UNKNOWN];
85
+
86
+ const digest = (key: string) => createHash("sha256").update(key).digest("hex");
87
+
88
+ function seedRegistry(revoked: readonly string[] = []): void {
89
+ const rows = new Map([
90
+ [digest(KEY_S1), { id: S1, workspaceId: W1, revokedAt: null as Date | null }],
91
+ [digest(KEY_S1B), { id: S1B, workspaceId: W1, revokedAt: null as Date | null }],
92
+ [digest(KEY_S2), { id: S2, workspaceId: W2, revokedAt: null as Date | null }],
93
+ ]);
94
+ for (const row of rows.values()) {
95
+ if (revoked.includes(row.id)) row.revokedAt = new Date();
96
+ }
97
+ db.spoke.findUnique.mockImplementation((async ({
98
+ where,
99
+ }: {
100
+ where: { credentialHash: string };
101
+ }) => rows.get(where.credentialHash) ?? null) as never);
102
+ }
103
+
104
+ function setStrictMode(on: boolean): void {
105
+ jest.replaceProperty(configModule, "config", {
106
+ ...configModule.config,
107
+ AILK_HUB_REQUIRE_SPOKE_KEY: on,
108
+ });
109
+ }
110
+
111
+ type Kind = "submit" | "subscribe" | "callback";
112
+
113
+ /** Each kind's body — every one names `siteId: W2`, which must be ignored. */
114
+ const BODIES: Record<Kind, Record<string, unknown>> = {
115
+ submit: {
116
+ siteId: W2,
117
+ source: "contact",
118
+ email: "prospect@example.com",
119
+ payload: { message: "hello" },
120
+ },
121
+ subscribe: { siteId: W2, source: "footer", email: "reader@example.com" },
122
+ callback: { siteId: W2, name: "Ada Lovelace", phone: "+44 7700 900000", payload: {} },
123
+ };
124
+
125
+ /** The create delegate each kind writes through. */
126
+ function createDelegate(kind: Kind) {
127
+ if (kind === "submit") return db.lead.create;
128
+ if (kind === "subscribe") return db.subscriber.create;
129
+ return db.callback.create;
130
+ }
131
+
132
+ function expectNoWrite(): void {
133
+ expect(db.lead.create).not.toHaveBeenCalled();
134
+ expect(db.subscriber.create).not.toHaveBeenCalled();
135
+ expect(db.callback.create).not.toHaveBeenCalled();
136
+ }
137
+
138
+ const REQUEST_CONFIRM_ENV = [
139
+ "AILK_REQUEST_CONFIRM_ENABLED",
140
+ "AILK_REQUEST_CONFIRM_TO",
141
+ "AILK_REQUEST_CONFIRM_FROM",
142
+ "AILK_PUBLIC_API_URL",
143
+ ] as const;
144
+
145
+ describe("a spoke calling the lead routes on a multi-workspace hub", () => {
146
+ setupLeadFileEnv("spoke");
147
+
148
+ let app: FastifyInstance;
149
+ let logLines: string[];
150
+
151
+ beforeEach(async () => {
152
+ // clearAllMocks (not reset): the license double's resolved value must
153
+ // survive between cases, as in the leads index suite.
154
+ jest.clearAllMocks();
155
+ __resetSiteWorkspaceCacheForTests();
156
+ process.env.DATABASE_URL = "postgres://stub";
157
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
158
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
159
+ setStrictMode(false);
160
+ seedRegistry();
161
+ mockSentinelWorkspaceExists(db, W1, W2);
162
+ db.workspace.count.mockResolvedValue(3);
163
+ db.lead.create.mockResolvedValue({ id: "lead_01920000-0000-7000-8000-0000000000d1" } as never);
164
+ db.subscriber.create.mockResolvedValue({ id: "subscr_01920000-0000-7000-8000-0000000000d2" } as never);
165
+ db.callback.create.mockResolvedValue({ id: "callbk_01920000-0000-7000-8000-0000000000d3" } as never);
166
+ db.callback.updateMany.mockResolvedValue({ count: 1 });
167
+ email.sendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-test" });
168
+ email.sendRequestConfirmEmail.mockResolvedValue({ ok: true, messageId: "msg-confirm" });
169
+
170
+ logLines = [];
171
+ const stream = new Writable({
172
+ write(chunk, _encoding, done) {
173
+ logLines.push(String(chunk));
174
+ done();
175
+ },
176
+ });
177
+ app = Fastify({ logger: { level: "info", stream } });
178
+ await app.register(leadSubmitRoute);
179
+ await app.register(leadSubscribeRoute);
180
+ await app.register(leadCallbackRoute);
181
+ await app.ready();
182
+ });
183
+
184
+ afterEach(async () => {
185
+ await app.close();
186
+ // No log line written by any case carries a key, its digest or its prefix (AC-3).
187
+ const written = logLines.join("\n");
188
+ for (const key of ALL_KEYS) {
189
+ expect(written).not.toContain(key);
190
+ expect(written).not.toContain(digest(key));
191
+ expect(written).not.toContain(key.slice(0, 17));
192
+ }
193
+ jest.restoreAllMocks();
194
+ delete process.env.DATABASE_URL;
195
+ delete process.env.AILK_LEAD_NOTIFICATION_TO;
196
+ delete process.env.AILK_LEAD_NOTIFICATION_FROM;
197
+ for (const key of REQUEST_CONFIRM_ENV) delete process.env[key];
198
+ });
199
+
200
+ function post(kind: Kind, headers: Record<string, string> = {}, body = BODIES[kind]) {
201
+ return app.inject({ method: "POST", url: `/v1/leads/${kind}`, headers, payload: body });
202
+ }
203
+
204
+ // ── AC-2 ────────────────────────────────────────────────────────────────
205
+
206
+ describe.each<Kind>(["submit", "subscribe", "callback"])("POST /v1/leads/%s (AC-2)", (kind) => {
207
+ it("a spoke's lead lands in its own workspace, whatever siteId the body names", async () => {
208
+ const res = await post(kind, { "x-ailk-spoke-key": KEY_S1 });
209
+
210
+ expect(res.statusCode).toBe(201);
211
+ expect(res.json().persisted).toBe("db");
212
+ const create = createDelegate(kind);
213
+ expect(create).toHaveBeenCalledTimes(1);
214
+ const { data } = create.mock.calls[0]![0] as {
215
+ data: { siteId: string; workspaceId: string };
216
+ };
217
+ expect(data.siteId).toBe(W1);
218
+ expect(data.workspaceId).toBe(W1);
219
+ });
220
+
221
+ it("another spoke's lead lands in its own workspace", async () => {
222
+ const res = await post(kind, { "x-ailk-spoke-key": KEY_S2 }, {
223
+ ...BODIES[kind],
224
+ siteId: W1,
225
+ });
226
+
227
+ expect(res.statusCode).toBe(201);
228
+ const { data } = createDelegate(kind).mock.calls[0]![0] as {
229
+ data: { siteId: string; workspaceId: string };
230
+ };
231
+ expect(data.siteId).toBe(W2);
232
+ expect(data.workspaceId).toBe(W2);
233
+ });
234
+ });
235
+
236
+ it("a spoke's callback never takes request-and-confirm: the generic notification goes, no link is minted (AC-2, SEC-7532-2)", async () => {
237
+ process.env.AILK_REQUEST_CONFIRM_ENABLED = "true";
238
+ process.env.AILK_REQUEST_CONFIRM_TO = "owner@example.com";
239
+ process.env.AILK_REQUEST_CONFIRM_FROM = "hello@example.com";
240
+ process.env.AILK_PUBLIC_API_URL = "https://api.example.com";
241
+
242
+ const res = await post("callback", { "x-ailk-spoke-key": KEY_S1 });
243
+ expect(res.statusCode).toBe(201);
244
+ await new Promise((resolve) => setImmediate(resolve));
245
+
246
+ expect(sendRequestConfirmLink).not.toHaveBeenCalled();
247
+ expect(email.sendRequestConfirmEmail).not.toHaveBeenCalled();
248
+ expect(email.sendLeadNotification).toHaveBeenCalledTimes(1);
249
+ expect(db.callback.updateMany).not.toHaveBeenCalled();
250
+ });
251
+
252
+ // ── AC-3 ────────────────────────────────────────────────────────────────
253
+
254
+ describe.each<[string, Record<string, string>, boolean]>([
255
+ ["(i) an unregistered key", { "x-ailk-spoke-key": KEY_UNKNOWN }, false],
256
+ ["(ii) a duplicated header", { "x-ailk-spoke-key": `${KEY_S1}, ${KEY_S2}` }, false],
257
+ ["(iii) an empty key", { "x-ailk-spoke-key": "" }, false],
258
+ [
259
+ "(v) strict mode, a header-less request naming W1 every other way",
260
+ {
261
+ host: "w1.example",
262
+ "x-forwarded-host": "w1.example",
263
+ origin: "https://w1.example",
264
+ "x-ailk-workspace-id": W1,
265
+ },
266
+ true,
267
+ ],
268
+ ])("%s (AC-3)", (_label, headers, strict) => {
269
+ it.each<Kind>(["submit", "subscribe", "callback"])(
270
+ "POST /v1/leads/%s answers 503 tenant_unresolved before any write",
271
+ async (kind) => {
272
+ setStrictMode(strict);
273
+ const res = await post(kind, headers, { ...BODIES[kind], siteId: W1 });
274
+
275
+ expect(res.statusCode).toBe(503);
276
+ expect(res.json().error).toBe("tenant_unresolved");
277
+ expectNoWrite();
278
+ },
279
+ );
280
+ });
281
+
282
+ it("(iv) a valid key with DATABASE_URL unset answers 503 — never the zero-config alias", async () => {
283
+ delete process.env.DATABASE_URL;
284
+ const res = await post("submit", { "x-ailk-spoke-key": KEY_S1 });
285
+
286
+ expect(res.statusCode).toBe(503);
287
+ expect(res.json().error).toBe("tenant_unresolved");
288
+ expectNoWrite();
289
+ });
290
+
291
+ // ── AC-5 ────────────────────────────────────────────────────────────────
292
+
293
+ describe("with S1 revoked (AC-5)", () => {
294
+ beforeEach(() => {
295
+ seedRegistry([S1]);
296
+ });
297
+
298
+ it("S1's key gets 503 with no write; S1b (also W1) and S2 keep their own workspaces", async () => {
299
+ const revoked = await post("submit", { "x-ailk-spoke-key": KEY_S1 });
300
+ expect(revoked.statusCode).toBe(503);
301
+ expectNoWrite();
302
+
303
+ const sibling = await post("submit", { "x-ailk-spoke-key": KEY_S1B });
304
+ expect(sibling.statusCode).toBe(201);
305
+ const siblingData = (db.lead.create.mock.calls[0]![0] as { data: { workspaceId: string } }).data;
306
+ expect(siblingData.workspaceId).toBe(W1);
307
+
308
+ const other = await post("submit", { "x-ailk-spoke-key": KEY_S2 });
309
+ expect(other.statusCode).toBe(201);
310
+ const otherData = (db.lead.create.mock.calls[1]![0] as { data: { workspaceId: string } }).data;
311
+ expect(otherData.workspaceId).toBe(W2);
312
+ });
313
+
314
+ it("in strict mode, S1's header-less retry is refused too", async () => {
315
+ setStrictMode(true);
316
+ const res = await post("submit");
317
+ expect(res.statusCode).toBe(503);
318
+ expectNoWrite();
319
+ });
320
+
321
+ it("with strict mode off, S1's header-less retry is anonymous and lands under the alias F, never W1 (SEC-7532-3)", async () => {
322
+ const res = await post("submit");
323
+ expect(res.statusCode).toBe(201);
324
+ const { data } = db.lead.create.mock.calls[0]![0] as {
325
+ data: { siteId: string; workspaceId: string };
326
+ };
327
+ expect(data.siteId).toBe(F);
328
+ expect(data.workspaceId).toBe(F);
329
+ });
330
+ });
331
+ });
@@ -29,6 +29,15 @@
29
29
  * result (no DATABASE_URL, or the alias names no workspace this database
30
30
  * holds) is a 503 before any write is attempted — never a guess. See
31
31
  * `lead-routing.ts`'s own header for the persistence-side half.
32
+ *
33
+ * A REGISTERED SPOKE (#7532 D4) resolves FIRST: a hub serving several spokes
34
+ * reads the spoke's key from `x-ailk-spoke-key` through
35
+ * `resolveCallerSite()` (`services/public-site-resolver.ts`) and writes the
36
+ * lead into that spoke's own workspace. A refused key — malformed, unknown or
37
+ * revoked, or no key at all on a hub in strict mode — is the same 503
38
+ * `tenant_unresolved` before any write. A header-less request on a hub
39
+ * without strict mode takes the alias resolution above, unchanged. The body's
40
+ * `siteId` stays ignored on every branch.
32
41
  */
33
42
 
34
43
  import { track } from "@working-theory/analytics/server";
@@ -40,7 +49,9 @@ import {
40
49
  type LeadKind,
41
50
  type RouteLeadInput,
42
51
  routeLead,
52
+ type SourceMeta,
43
53
  } from "../../services/lead-routing.js";
54
+ import { resolveCallerSite } from "../../services/public-site-resolver.js";
44
55
  import { sendRequestConfirmLink } from "../../services/request-confirm.js";
45
56
 
46
57
  /**
@@ -125,12 +136,22 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
125
136
  // private @working-theory/lead-routing package (not present in this
126
137
  // repo, not inspectable) is the one consumer this trace could not
127
138
  // complete; see docs/specs/5112-leads-ip-trace-spec.md §8.
128
- const sourceMeta = {
139
+ const sourceMeta: SourceMeta = {
129
140
  ip: request.ip,
130
141
  userAgent: request.headers["user-agent"],
131
142
  receivedAt: new Date().toISOString(),
132
143
  };
133
144
 
145
+ // #7532 D4 — a registered spoke resolves first, to its own workspace; a
146
+ // refused key never falls through to the alias below.
147
+ const caller = await resolveCallerSite(request);
148
+ if (caller.kind === "refused") {
149
+ return reply.status(503).send({
150
+ error: "tenant_unresolved",
151
+ message: "This site is not configured to accept submissions.",
152
+ });
153
+ }
154
+
134
155
  // #6203 — resolve the tenant SERVER-SIDE before any write is
135
156
  // attempted. Two branches, deliberately different:
136
157
  //
@@ -150,7 +171,10 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
150
171
  // deleted after boot, a transient read), and it fails closed the
151
172
  // same way: reject, never guess (spec AC-4).
152
173
  let workspaceId: string;
153
- if (!process.env.DATABASE_URL) {
174
+ if (caller.kind === "spoke") {
175
+ workspaceId = caller.workspaceId;
176
+ sourceMeta.viaSpoke = true;
177
+ } else if (!process.env.DATABASE_URL) {
154
178
  workspaceId = currentSiteId();
155
179
  } else {
156
180
  const resolved = await resolveSiteWorkspaceId();
@@ -239,7 +263,13 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
239
263
  // would match nothing and the operator would be left with no email at
240
264
  // all. On that path the generic notification is sent instead, which is
241
265
  // what keeps "exactly one" true in both directions.
242
- if (kind === "callback" && result.persisted === "db") {
266
+ // A spoke's callback never takes this path (#7532 D4, SEC-7532-2): the
267
+ // link is minted on the deployment's one Stripe account.
268
+ if (
269
+ kind === "callback" &&
270
+ result.persisted === "db" &&
271
+ caller.kind !== "spoke"
272
+ ) {
243
273
  const callbackInput = buildInput(kind, body, workspaceId) as Extract<
244
274
  RouteLeadInput,
245
275
  { kind: "callback" }