create-ailk 0.11.5 → 0.12.1

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 (77) 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 +34 -0
  69. package/templates/database/__tests__/build.test.ts +131 -0
  70. package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
  71. package/templates/database/auth/schema.prisma +31 -0
  72. package/templates/database/config/schema.prisma +26 -0
  73. package/templates/database/inbox/schema.prisma +21 -0
  74. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
  75. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
  76. package/templates/database/package.json +3 -3
  77. package/versions.json +34 -34
@@ -0,0 +1,528 @@
1
+ // Not marked oss: false (D-48/#5366 escalation) — tests the
2
+ // always-shipping project-listings anonymous surface; see
3
+ // routes/project-listings/me.ts's header comment.
4
+ /**
5
+ * @file directory.test.ts
6
+ *
7
+ * THE ACCEPTANCE GATE for `GET /v1/project-listings/directory` (#7521 AC-1,
8
+ * AC-3): a landing page's directory — the live listicle that names it, its
9
+ * schema.org entry type, and its ACCEPTED entries with their public profile.
10
+ *
11
+ * Real `projectListingDirectoryRoute`, real services, mocked `lib/prisma.js`
12
+ * over a small in-memory store of listicles, listings and site configs. The
13
+ * last block boots the real `build()` so the directory's own rate-limited
14
+ * scope is asserted where it is registered, not in a copy of it.
15
+ *
16
+ * Spec: docs/specs/7521-a-generic-directory-surface-for-spec.md §5.1.
17
+ */
18
+
19
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock
20
+ jest.mock("../../../lib/prisma.js");
21
+
22
+ jest.mock("@fastify/cors", () => jest.fn().mockResolvedValue(undefined));
23
+
24
+ jest.mock("@working-theory/email", () => ({
25
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
26
+ sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
27
+ }));
28
+
29
+ jest.mock("@working-theory/license", () => ({
30
+ checkLicense: jest.fn().mockResolvedValue({
31
+ tier: "free",
32
+ plan: null,
33
+ features: [],
34
+ expiresAt: null,
35
+ revoked: false,
36
+ }),
37
+ }));
38
+
39
+ import Fastify from "fastify";
40
+ import type { FastifyInstance } from "fastify";
41
+ import { listingDirectoryResponseSchema } from "@working-theory/validation";
42
+
43
+ import { mockWorkspaceExists } from "../../../lib/__mocks__/prisma.js";
44
+ import { prisma } from "../../../lib/prisma.js";
45
+ import { __resetSiteWorkspaceCacheForTests } from "../../../lib/site.js";
46
+ import { __resetBuckets } from "../../../middleware/rate-limit.js";
47
+ import { build } from "../../../server.js";
48
+ import { projectListingDirectoryRoute } from "../index.js";
49
+
50
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
51
+
52
+ const SITE_A = "wksp_00000000-0000-7000-8000-00000000000a";
53
+ const SITE_B = "wksp_00000000-0000-7000-8000-00000000000b";
54
+ const KEY_A = "pk-site-a";
55
+ const KEY_B = "pk-site-b";
56
+
57
+ type Row = Record<string, unknown>;
58
+
59
+ let listings: Row[];
60
+ let flows: Row[];
61
+ /** `SiteListingConfig` rows by siteId: `{ publicKey, data }`. */
62
+ let configs: Map<string, { publicKey: string; data: Record<string, unknown> }>;
63
+
64
+ const en = (s: string, fr?: string) => (fr ? { en: s, fr } : { en: s });
65
+
66
+ function listicleConfig(listing?: Record<string, unknown>) {
67
+ return {
68
+ slug: "apply",
69
+ defaultLocale: "en",
70
+ steps: [
71
+ {
72
+ key: "application",
73
+ type: "field",
74
+ copy: { title: en("Apply") },
75
+ fields: [
76
+ { name: "full_name", inputType: "text", mapsTo: "name", copy: { label: en("Name") } },
77
+ { name: "email", inputType: "email", mapsTo: "email", copy: { label: en("Email") } },
78
+ { name: "website", inputType: "url", copy: { label: en("Website", "Site web") } },
79
+ {
80
+ name: "stage",
81
+ inputType: "select",
82
+ options: [
83
+ { value: "idea", label: en("Idea", "Idée") },
84
+ { value: "live", label: en("Live", "En ligne") },
85
+ ],
86
+ copy: { label: en("Stage", "Étape") },
87
+ },
88
+ ],
89
+ },
90
+ ],
91
+ ...(listing !== undefined && { listing }),
92
+ };
93
+ }
94
+
95
+ function flow(overrides: Row): Row {
96
+ return {
97
+ siteId: SITE_A,
98
+ workspaceId: SITE_A,
99
+ kind: "listicle",
100
+ status: "live",
101
+ landingPage: "/partners",
102
+ config: listicleConfig({ entryType: "Organization" }),
103
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
104
+ ...overrides,
105
+ };
106
+ }
107
+
108
+ let nextListing = 1;
109
+ function listing(overrides: Row): Row {
110
+ const n = nextListing++;
111
+ return {
112
+ id: `prjl_00000000-0000-7000-8000-${String(n).padStart(12, "0")}`,
113
+ siteId: SITE_A,
114
+ workspaceId: SITE_A,
115
+ capturedByFlowId: "flow_partners",
116
+ leadId: null,
117
+ slug: `entry-${n}`,
118
+ name: `Entry ${n}`,
119
+ headline: null,
120
+ oneLiner: `Entry ${n} in one line.`,
121
+ problem: null,
122
+ solution: null,
123
+ url: null,
124
+ product: null,
125
+ commitment: null,
126
+ teamComposition: null,
127
+ faqs: null,
128
+ anythingElse: null,
129
+ address: null,
130
+ addressLine1: null,
131
+ addressLine2: null,
132
+ addressCity: null,
133
+ addressRegion: null,
134
+ addressPostalCode: null,
135
+ addressCountry: null,
136
+ questions: null,
137
+ siteAnswers: { website: "https://entry.test", stage: "live", email: "x@y.test" },
138
+ windowDays: 30,
139
+ founderName: "A Founder",
140
+ founderFirstName: null,
141
+ founderLastName: null,
142
+ founderEmail: "founder@example.com",
143
+ founderPhone: "+12015550123",
144
+ emailVerifiedAt: null,
145
+ founderCompany: null,
146
+ logoMarkUrl: null,
147
+ logoWordmarkUrl: null,
148
+ featured: false,
149
+ status: "active",
150
+ consentAt: new Date("2026-09-01T00:00:00.000Z"),
151
+ decidedAt: new Date("2026-09-10T00:00:00.000Z"),
152
+ decidedBy: "owner",
153
+ issueUrl: null,
154
+ experimentKey: null,
155
+ windowEndsAt: new Date("2099-01-01T00:00:00.000Z"),
156
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
157
+ updatedAt: new Date("2026-09-01T00:00:00.000Z"),
158
+ ...overrides,
159
+ };
160
+ }
161
+
162
+ function matches(row: Row, where: Record<string, unknown>): boolean {
163
+ return Object.entries(where).every(([key, value]) => {
164
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) {
165
+ const clause = value as Record<string, unknown>;
166
+ if ("in" in clause) return (clause["in"] as unknown[]).includes(row[key]);
167
+ if ("not" in clause) return row[key] !== clause["not"];
168
+ }
169
+ return row[key] === value;
170
+ });
171
+ }
172
+
173
+ type SortTerm = "asc" | "desc" | { sort: "asc" | "desc"; nulls?: "first" | "last" };
174
+ type OrderBy = Array<Record<string, SortTerm>>;
175
+ /** Prisma's ordering, with Postgres's default of NULL first on DESC, last on ASC. */
176
+ function ordered(rows: Row[], orderBy: OrderBy | undefined): Row[] {
177
+ if (!orderBy) return rows;
178
+ return [...rows].sort((a, b) => {
179
+ for (const term of orderBy) {
180
+ const [key, spec] = Object.entries(term)[0]!;
181
+ const dir = typeof spec === "string" ? spec : spec.sort;
182
+ const nulls = (typeof spec === "string" ? undefined : spec.nulls) ?? (dir === "desc" ? "first" : "last");
183
+ const av = a[key] instanceof Date ? (a[key] as Date).getTime() : (a[key] as string | null);
184
+ const bv = b[key] instanceof Date ? (b[key] as Date).getTime() : (b[key] as string | null);
185
+ if (av === bv) continue;
186
+ if (av === null || av === undefined) return nulls === "first" ? -1 : 1;
187
+ if (bv === null || bv === undefined) return nulls === "first" ? 1 : -1;
188
+ const cmp = av < bv ? -1 : 1;
189
+ return dir === "asc" ? cmp : -cmp;
190
+ }
191
+ return 0;
192
+ });
193
+ }
194
+
195
+ function installStore(): void {
196
+ listings = [];
197
+ flows = [];
198
+ configs = new Map([
199
+ [SITE_A, { publicKey: KEY_A, data: {} }],
200
+ [SITE_B, { publicKey: KEY_B, data: {} }],
201
+ ]);
202
+ nextListing = 1;
203
+
204
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
205
+ ({ where }: { where: { siteId?: string; publicKey?: string } }) => {
206
+ if (where.publicKey !== undefined) {
207
+ for (const [siteId, row] of configs) {
208
+ if (row.publicKey === where.publicKey) return Promise.resolve({ siteId });
209
+ }
210
+ return Promise.resolve(null);
211
+ }
212
+ const row = configs.get(where.siteId!);
213
+ return Promise.resolve(row ? { data: row.data } : null);
214
+ },
215
+ );
216
+ (prisma.flowDefinition.findMany as jest.Mock).mockImplementation(
217
+ ({ where, orderBy }: { where: Record<string, unknown>; orderBy?: OrderBy }) =>
218
+ Promise.resolve(ordered(flows.filter((f) => matches(f, where)), orderBy)),
219
+ );
220
+ (prisma.projectListing.findMany as jest.Mock).mockImplementation(
221
+ ({ where, orderBy, take }: { where: Record<string, unknown>; orderBy?: OrderBy; take?: number }) => {
222
+ const found = ordered(listings.filter((l) => matches(l, where)), orderBy);
223
+ return Promise.resolve(take === undefined ? found : found.slice(0, take));
224
+ },
225
+ );
226
+ (prisma.waitlistScore.count as jest.Mock).mockResolvedValue(0);
227
+ }
228
+
229
+ async function buildApp(): Promise<FastifyInstance> {
230
+ const app = Fastify();
231
+ await app.register(projectListingDirectoryRoute);
232
+ await app.ready();
233
+ return app;
234
+ }
235
+
236
+ type Directory = {
237
+ landingPage: string;
238
+ entryType: string;
239
+ entries: Array<{ slug: string; status: string; profile: Array<Record<string, string>> }>;
240
+ } | null;
241
+
242
+ const read = async (app: FastifyInstance, query: string) => {
243
+ const res = await app.inject({ method: "GET", url: `/v1/project-listings/directory?${query}` });
244
+ return { res, data: (res.json() as { data?: Directory }).data };
245
+ };
246
+
247
+ // ─── AC-1 — which entries a landing page shows ─────────────────────────────
248
+
249
+ describe("GET /v1/project-listings/directory (#7521 AC-1)", () => {
250
+ let app: FastifyInstance;
251
+
252
+ beforeEach(async () => {
253
+ jest.clearAllMocks();
254
+ installStore();
255
+ app = await buildApp();
256
+ });
257
+
258
+ afterEach(async () => {
259
+ await app.close();
260
+ });
261
+
262
+ it("returns the live listicle's entry type and exactly its accepted entries, the elapsed one closed, in /public's order", async () => {
263
+ flows.push(flow({ id: "flow_partners" }));
264
+ const live = listing({ slug: "acme", decidedAt: new Date("2026-09-10T00:00:00.000Z") });
265
+ const elapsed = listing({
266
+ slug: "globex",
267
+ decidedAt: new Date("2026-09-12T00:00:00.000Z"),
268
+ windowEndsAt: new Date("2026-09-20T00:00:00.000Z"),
269
+ });
270
+ const requested = listing({ slug: null, status: "requested", decidedAt: null });
271
+ listings.push(live, elapsed, requested);
272
+
273
+ const { res, data } = await read(app, `siteKey=${KEY_A}&landingPage=/partners`);
274
+
275
+ expect(res.statusCode).toBe(200);
276
+ expect(listingDirectoryResponseSchema.safeParse(data).success).toBe(true);
277
+ expect(data!.landingPage).toBe("/partners");
278
+ expect(data!.entryType).toBe("Organization");
279
+ // /public's order: the three newest by decidedAt first.
280
+ expect(data!.entries.map((e) => [e.slug, e.status])).toEqual([
281
+ ["globex", "closed"],
282
+ ["acme", "active"],
283
+ ]);
284
+ });
285
+
286
+ it("a draft listicle, another landing page, another workspace and an uncaptured listing contribute nothing", async () => {
287
+ flows.push(
288
+ flow({ id: "flow_partners" }),
289
+ flow({ id: "flow_draft", status: "draft" }),
290
+ flow({ id: "flow_tools", landingPage: "/tools" }),
291
+ );
292
+ listings.push(
293
+ listing({ slug: "kept" }),
294
+ listing({ slug: "from-draft", capturedByFlowId: "flow_draft" }),
295
+ listing({ slug: "from-tools", capturedByFlowId: "flow_tools" }),
296
+ listing({ slug: "other-workspace", siteId: SITE_B, workspaceId: SITE_B }),
297
+ listing({ slug: "uncaptured", capturedByFlowId: null }),
298
+ );
299
+
300
+ const { data } = await read(app, `siteKey=${KEY_A}&landingPage=/partners`);
301
+
302
+ expect(data!.entries.map((e) => e.slug)).toEqual(["kept"]);
303
+ // The flow read carried every scope term.
304
+ expect((prisma.flowDefinition.findMany as jest.Mock).mock.calls[0][0].where).toEqual({
305
+ siteId: SITE_A,
306
+ workspaceId: SITE_A,
307
+ kind: "listicle",
308
+ status: "live",
309
+ landingPage: "/partners",
310
+ });
311
+ });
312
+
313
+ it("a landing page no live listicle names answers data: null, and reads no listings", async () => {
314
+ flows.push(flow({ id: "flow_partners" }));
315
+ listings.push(listing({ slug: "acme" }));
316
+
317
+ const { res, data } = await read(app, `siteKey=${KEY_A}&landingPage=/about`);
318
+
319
+ expect(res.statusCode).toBe(200);
320
+ expect(data).toBeNull();
321
+ expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
322
+ });
323
+
324
+ it("returns at most 200 entries — the most recently accepted", async () => {
325
+ flows.push(flow({ id: "flow_partners" }));
326
+ for (let i = 0; i < 250; i++) {
327
+ listings.push(
328
+ listing({ slug: `e-${i}`, decidedAt: new Date(Date.UTC(2026, 0, 1) + i * 60_000) }),
329
+ );
330
+ }
331
+
332
+ const { res, data } = await read(app, `siteKey=${KEY_A}&landingPage=/partners`);
333
+
334
+ expect(res.statusCode).toBe(200);
335
+ expect(data!.entries).toHaveLength(200);
336
+ expect(data!.entries.map((e) => e.slug)).not.toContain("e-0");
337
+ expect(data!.entries.map((e) => e.slug)).toContain("e-249");
338
+ });
339
+
340
+ it("an active row with no decidedAt never takes a slot from an accepted one", async () => {
341
+ flows.push(flow({ id: "flow_partners" }));
342
+ listings.push(listing({ slug: "undecided", decidedAt: null }));
343
+ for (let i = 0; i < 200; i++) {
344
+ listings.push(
345
+ listing({ slug: `e-${i}`, decidedAt: new Date(Date.UTC(2026, 0, 1) + i * 60_000) }),
346
+ );
347
+ }
348
+
349
+ const { data } = await read(app, `siteKey=${KEY_A}&landingPage=/partners`);
350
+
351
+ expect(data!.entries).toHaveLength(200);
352
+ expect(data!.entries.map((e) => e.slug)).not.toContain("undecided");
353
+ expect(data!.entries.map((e) => e.slug)).toContain("e-0");
354
+ });
355
+
356
+ it("a listicle with no `listing` block publishes as CreativeWork", async () => {
357
+ flows.push(flow({ id: "flow_partners", config: listicleConfig() }));
358
+ listings.push(listing({ slug: "acme" }));
359
+
360
+ const { data } = await read(app, `siteKey=${KEY_A}&landingPage=/partners`);
361
+
362
+ expect(data!.entryType).toBe("CreativeWork");
363
+ });
364
+
365
+ it("the earlier-created listicle supplies the entry type when two name one page; both contribute entries", async () => {
366
+ flows.push(
367
+ flow({ id: "flow_later", config: listicleConfig({ entryType: "Person" }), createdAt: new Date("2026-09-05") }),
368
+ flow({ id: "flow_partners", createdAt: new Date("2026-09-01") }),
369
+ );
370
+ listings.push(listing({ slug: "a" }), listing({ slug: "b", capturedByFlowId: "flow_later" }));
371
+
372
+ const { data } = await read(app, `siteKey=${KEY_A}&landingPage=/partners`);
373
+
374
+ expect(data!.entryType).toBe("Organization");
375
+ expect(data!.entries.map((e) => e.slug).sort()).toEqual(["a", "b"]);
376
+ });
377
+
378
+ it("each entry's profile carries the listicle's public answers for the request locale, and no contact column", async () => {
379
+ flows.push(flow({ id: "flow_partners" }));
380
+ listings.push(listing({ slug: "acme" }));
381
+
382
+ const { res, data } = await read(app, `siteKey=${KEY_A}&landingPage=/partners&locale=fr`);
383
+
384
+ expect(data!.entries[0]!.profile).toEqual([
385
+ { name: "website", label: "Site web", value: "https://entry.test", kind: "url" },
386
+ { name: "stage", label: "Étape", value: "En ligne", kind: "select" },
387
+ ]);
388
+ const body = res.body;
389
+ expect(body).not.toContain("founder@example.com");
390
+ expect(body).not.toContain("x@y.test");
391
+ expect(body).not.toContain("+12015550123");
392
+ });
393
+
394
+ it("a malformed query is a 400 validation_failed", async () => {
395
+ const { res } = await read(app, `siteKey=${KEY_A}&landingPage=//evil.test`);
396
+ expect(res.statusCode).toBe(400);
397
+ expect((res.json() as { error: { code: string } }).error.code).toBe("validation_failed");
398
+ });
399
+ });
400
+
401
+ // ─── AC-3 — both tenancy shapes, one read ───────────────────────────────────
402
+
403
+ describe("the directory resolves both tenancy shapes (#7521 AC-3)", () => {
404
+ let app: FastifyInstance;
405
+ let savedSiteId: string | undefined;
406
+ let savedPublicKey: string | undefined;
407
+
408
+ beforeEach(async () => {
409
+ jest.clearAllMocks();
410
+ installStore();
411
+ savedSiteId = process.env["AILK_SITE_ID"];
412
+ savedPublicKey = process.env["AILK_LISTING_PUBLIC_KEY"];
413
+ delete process.env["AILK_SITE_ID"];
414
+ delete process.env["AILK_LISTING_PUBLIC_KEY"];
415
+ __resetSiteWorkspaceCacheForTests();
416
+ app = await buildApp();
417
+ });
418
+
419
+ afterEach(async () => {
420
+ if (savedSiteId === undefined) delete process.env["AILK_SITE_ID"];
421
+ else process.env["AILK_SITE_ID"] = savedSiteId;
422
+ if (savedPublicKey === undefined) delete process.env["AILK_LISTING_PUBLIC_KEY"];
423
+ else process.env["AILK_LISTING_PUBLIC_KEY"] = savedPublicKey;
424
+ await app.close();
425
+ });
426
+
427
+ function seedSiteA(): void {
428
+ flows.push(flow({ id: "flow_partners" }));
429
+ listings.push(listing({ slug: "acme" }), listing({ slug: "globex" }));
430
+ }
431
+
432
+ it("multi-tenant (SiteListingConfig.publicKey) and single-tenant (env key) answer the identical body", async () => {
433
+ seedSiteA();
434
+ const multi = await read(app, `siteKey=${KEY_A}&landingPage=/partners`);
435
+
436
+ // Single-tenant: no config row carries the key; the env key names the
437
+ // deployment's own site, resolved through `resolveSiteWorkspaceId()`.
438
+ configs.delete(SITE_A);
439
+ process.env["AILK_LISTING_PUBLIC_KEY"] = "pk-single-tenant";
440
+ process.env["AILK_SITE_ID"] = SITE_A;
441
+ mockWorkspaceExists(prisma, SITE_A);
442
+ const single = await read(app, "siteKey=pk-single-tenant&landingPage=/partners");
443
+
444
+ expect(multi.res.statusCode).toBe(200);
445
+ expect(single.res.statusCode).toBe(200);
446
+ expect(single.data!.entries).toHaveLength(2);
447
+ expect(single.data).toEqual(multi.data);
448
+ });
449
+
450
+ it("an unmatched key is a 400 unknown_site_key and does not fall through to the env path", async () => {
451
+ seedSiteA();
452
+ process.env["AILK_LISTING_PUBLIC_KEY"] = "pk-single-tenant";
453
+ process.env["AILK_SITE_ID"] = SITE_A;
454
+ mockWorkspaceExists(prisma, SITE_A);
455
+
456
+ const { res } = await read(app, "siteKey=pk-nobodys-site&landingPage=/partners");
457
+
458
+ expect(res.statusCode).toBe(400);
459
+ expect((res.json() as { error: { code: string } }).error.code).toBe("unknown_site_key");
460
+ expect(prisma.flowDefinition.findMany).not.toHaveBeenCalled();
461
+ });
462
+
463
+ it("two workspaces of one organization (the hub's spokes) each see only their own entries", async () => {
464
+ seedSiteA();
465
+ flows.push(flow({ id: "flow_b", siteId: SITE_B, workspaceId: SITE_B }));
466
+ listings.push(
467
+ listing({ slug: "b-entry", siteId: SITE_B, workspaceId: SITE_B, capturedByFlowId: "flow_b" }),
468
+ );
469
+
470
+ const a = await read(app, `siteKey=${KEY_A}&landingPage=/partners`);
471
+ const b = await read(app, `siteKey=${KEY_B}&landingPage=/partners`);
472
+
473
+ expect(a.data!.entries.map((e) => e.slug).sort()).toEqual(["acme", "globex"]);
474
+ expect(b.data!.entries.map((e) => e.slug)).toEqual(["b-entry"]);
475
+ });
476
+ });
477
+
478
+ // ─── AC-1 — the directory's own rate-limited scope, through the real build ──
479
+
480
+ describe("the directory's rate-limit scope (#7521 AC-1, SEC-7521-D1)", () => {
481
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; matches leads-forwarded-rate-limit.test.ts
482
+ let server: any;
483
+ const priorModules = process.env["AILK_ENABLED_MODULES"];
484
+
485
+ beforeEach(async () => {
486
+ jest.clearAllMocks();
487
+ installStore();
488
+ __resetBuckets();
489
+ process.env["AILK_ENABLED_MODULES"] = "signal-collectors";
490
+ server = await build();
491
+ await server.ready();
492
+ });
493
+
494
+ afterEach(async () => {
495
+ if (server) await server.close();
496
+ server = undefined;
497
+ });
498
+
499
+ afterAll(() => {
500
+ if (priorModules === undefined) delete process.env["AILK_ENABLED_MODULES"];
501
+ else process.env["AILK_ENABLED_MODULES"] = priorModules;
502
+ });
503
+
504
+ it("15 directory reads in one minute from one IP all answer 200 — the leads scope would refuse the 11th", async () => {
505
+ const codes: number[] = [];
506
+ for (let i = 0; i < 15; i++) {
507
+ const res = await server.inject({
508
+ method: "GET",
509
+ url: `/v1/project-listings/directory?siteKey=${KEY_A}&landingPage=/partners`,
510
+ headers: { "x-forwarded-for": "198.51.100.7" },
511
+ });
512
+ codes.push(res.statusCode);
513
+ }
514
+ expect(codes).toEqual(Array(15).fill(200));
515
+
516
+ // The same IP's eleventh `/public` read — leadsScope — is refused.
517
+ const publicCodes: number[] = [];
518
+ for (let i = 0; i < 11; i++) {
519
+ const res = await server.inject({
520
+ method: "GET",
521
+ url: `/v1/project-listings/public?siteKey=${KEY_A}`,
522
+ headers: { "x-forwarded-for": "198.51.100.7" },
523
+ });
524
+ publicCodes.push(res.statusCode);
525
+ }
526
+ expect(publicCodes[10]).toBe(429);
527
+ });
528
+ });