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
@@ -11,6 +11,14 @@ jest.mock("../../lib/prisma.js");
11
11
  import * as configModule from "../../config/index.js";
12
12
  import { prisma } from "../../lib/prisma.js";
13
13
  import { postListingApplication, updateListingMessage } from "../slack-notify";
14
+ import {
15
+ LISTING_DECISION_BLOCK_NAMED_SINCE_SECONDS,
16
+ listingDecisionBlockId,
17
+ parseListingDecisionBlockId,
18
+ } from "../slack-notify";
19
+
20
+ // #7522 D5 — the workspace the listing belongs to; the posted actions block names it.
21
+ const WS = "wksp_00000000-0000-7000-8000-000000000001";
14
22
 
15
23
  // #5036 D2/D5 reshaped what reaches Slack: `audience` is gone (folded into
16
24
  // `oneLiner`'s guidance) and `stage` is the six-rung `product` ladder. Since
@@ -26,6 +34,7 @@ const LISTING = {
26
34
  product: "working-prototype",
27
35
  founderName: "Ada Founder",
28
36
  founderCompany: "Ada Co",
37
+ workspaceId: WS,
29
38
  };
30
39
 
31
40
  function mockFetchOnce(body: unknown, ok = true): jest.Mock {
@@ -276,3 +285,64 @@ describe("updateListingMessage", () => {
276
285
  await expect(updateListingMessage(decided, "active", "Erin")).resolves.toBeUndefined();
277
286
  });
278
287
  });
288
+
289
+ // ── #7522 — the actions block names the listing's workspace ────────────────
290
+
291
+ describe("the listing-decision block_id (#7522)", () => {
292
+ beforeEach(() => {
293
+ jest.clearAllMocks();
294
+ });
295
+
296
+ afterEach(() => {
297
+ jest.replaceProperty(configModule, "config", {
298
+ ...configModule.config,
299
+ AILK_SLACK_BOT_TOKEN: undefined,
300
+ AILK_SLACK_CHANNEL_ID: undefined,
301
+ });
302
+ });
303
+
304
+ it("posts an actions block whose block_id names the listing's workspace, in the format the route parses", async () => {
305
+ jest.replaceProperty(configModule, "config", {
306
+ ...configModule.config,
307
+ AILK_SLACK_BOT_TOKEN: "xoxb-test",
308
+ AILK_SLACK_CHANNEL_ID: "C123",
309
+ });
310
+ const fetchMock = mockFetchOnce({ ok: true, channel: "C123", ts: "1699999999.000100" });
311
+
312
+ await postListingApplication({ ...LISTING });
313
+
314
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
315
+ const actionsBlock = body.blocks.find((b: { type: string }) => b.type === "actions");
316
+ expect(actionsBlock.block_id).toBe(listingDecisionBlockId(WS));
317
+ expect(actionsBlock.block_id).toBe("listing_decision:" + WS);
318
+ // The button values stay the bare listing id.
319
+ expect(actionsBlock.elements[0]).toMatchObject({ action_id: "listing_approve", value: LISTING.id });
320
+ expect(actionsBlock.elements[1]).toMatchObject({ action_id: "listing_reject", value: LISTING.id });
321
+ });
322
+
323
+ it("round-trips: the parser reads back the workspace the producer wrote", () => {
324
+ expect(parseListingDecisionBlockId(listingDecisionBlockId(WS))).toEqual({
325
+ kind: "named",
326
+ workspaceId: WS,
327
+ });
328
+ });
329
+
330
+ it("reads an absent block_id or the bare legacy literal as the legacy arm", () => {
331
+ expect(parseListingDecisionBlockId(undefined)).toEqual({ kind: "legacy" });
332
+ expect(parseListingDecisionBlockId("listing_decision")).toEqual({ kind: "legacy" });
333
+ });
334
+
335
+ it("reads an empty suffix, a non-workspace id, or a foreign block as invalid — never as legacy", () => {
336
+ expect(parseListingDecisionBlockId("listing_decision:")).toEqual({ kind: "invalid" });
337
+ expect(
338
+ parseListingDecisionBlockId("listing_decision:prjl_00000000-0000-7000-8000-000000000001"),
339
+ ).toEqual({ kind: "invalid" });
340
+ expect(parseListingDecisionBlockId("other")).toEqual({ kind: "invalid" });
341
+ });
342
+
343
+ it("the cutover is a whole UTC hour after the pin", () => {
344
+ expect(Number.isInteger(LISTING_DECISION_BLOCK_NAMED_SINCE_SECONDS)).toBe(true);
345
+ expect(LISTING_DECISION_BLOCK_NAMED_SINCE_SECONDS % 3600).toBe(0);
346
+ expect(LISTING_DECISION_BLOCK_NAMED_SINCE_SECONDS).toBeGreaterThan(1790985600);
347
+ });
348
+ });
@@ -0,0 +1,374 @@
1
+ /**
2
+ * @file spoke-resolution.test.ts
3
+ *
4
+ * `resolveCallerSite(request)` and the public resolver's spoke arms (#7532,
5
+ * spec docs/specs/7532-a-spoke-s-public-reads-spec.md D3, AC-1, AC-3, AC-4).
6
+ *
7
+ * A spoke names itself with ONE request header, `x-ailk-spoke-key`, looked up
8
+ * by its SHA-256 digest in the hub's registry. Nothing else on the request —
9
+ * a workspace id, `Host`, `X-Forwarded-Host`, `Origin` — selects a workspace.
10
+ *
11
+ * Fixture (spec § 5): three workspaces — the sentinel default `F`, `W1`, `W2`;
12
+ * spokes `S1` → `W1`, `S1b` → `W1`, `S2` → `W2`, each with its own key.
13
+ * `db.spoke.findUnique` answers by `credentialHash`.
14
+ *
15
+ * External dependency boundaries mocked:
16
+ * - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
17
+ * - lib/content-adapter.js (so the adapter the resolver picks is observable)
18
+ */
19
+ import { createHash } from "node:crypto";
20
+
21
+ // 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.
22
+ jest.mock("../../lib/prisma.js");
23
+ // eslint-disable-next-line no-restricted-syntax -- AC-1 asserts WHICH adapter the resolver asks for (workspace-scoped vs shared mdx); the double records that argument
24
+ jest.mock("../../lib/content-adapter.js", () => ({
25
+ getContentAdapter: jest.fn(() => ({ kind: "adapter-double" })),
26
+ }));
27
+
28
+ import type { FastifyRequest } from "fastify";
29
+
30
+ import * as configModule from "../../config/index.js";
31
+ import { getContentAdapter } from "../../lib/content-adapter.js";
32
+ import { mockSentinelWorkspaceExists } from "../../lib/__mocks__/prisma.js";
33
+ import { prisma } from "../../lib/prisma.js";
34
+ import {
35
+ resolveCallerSite,
36
+ resolvePublicContentAdapter,
37
+ resolvePublicWorkspace,
38
+ } from "../public-site-resolver.js";
39
+
40
+ const db = jest.mocked(prisma);
41
+
42
+ const W1 = "wksp_01920000-0000-7000-8000-0000000000b1";
43
+ const W2 = "wksp_01920000-0000-7000-8000-0000000000b2";
44
+ const S1 = "spoke_01920000-0000-7000-8000-0000000000c1";
45
+ const S1B = "spoke_01920000-0000-7000-8000-0000000000c2";
46
+ const S2 = "spoke_01920000-0000-7000-8000-0000000000c3";
47
+
48
+ /** A well-formed 52-character spoke key, distinct per tag. */
49
+ function spokeKey(tag: string): string {
50
+ return `ailk_spk_${tag}${"x".repeat(43 - tag.length)}`;
51
+ }
52
+ const KEY_S1 = spokeKey("S1");
53
+ const KEY_S1B = spokeKey("S1b");
54
+ const KEY_S2 = spokeKey("S2");
55
+ const KEY_UNKNOWN = spokeKey("nobody");
56
+
57
+ const digest = (key: string) => createHash("sha256").update(key).digest("hex");
58
+
59
+ type SpokeRow = { id: string; workspaceId: string; revokedAt: Date | null };
60
+
61
+ /** Program the registry: `db.spoke.findUnique` answers by digest. */
62
+ function seedRegistry(revoked: readonly string[] = []): void {
63
+ const rows = new Map<string, SpokeRow>([
64
+ [digest(KEY_S1), { id: S1, workspaceId: W1, revokedAt: null }],
65
+ [digest(KEY_S1B), { id: S1B, workspaceId: W1, revokedAt: null }],
66
+ [digest(KEY_S2), { id: S2, workspaceId: W2, revokedAt: null }],
67
+ ]);
68
+ for (const row of rows.values()) {
69
+ if (revoked.includes(row.id)) row.revokedAt = new Date();
70
+ }
71
+ db.spoke.findUnique.mockImplementation((async ({
72
+ where,
73
+ }: {
74
+ where: { credentialHash: string };
75
+ }) => rows.get(where.credentialHash) ?? null) as never);
76
+ }
77
+
78
+ /** A request double carrying only what the resolver may read. */
79
+ function requestWith(headers: Record<string, string | string[]>): {
80
+ request: FastifyRequest;
81
+ warn: jest.Mock;
82
+ } {
83
+ const warn = jest.fn();
84
+ // eslint-disable-next-line no-restricted-syntax -- a FastifyRequest double carrying only the two members the resolver reads (headers, log)
85
+ const request = {
86
+ headers,
87
+ log: { warn, info: jest.fn(), error: jest.fn() },
88
+ } as unknown as FastifyRequest;
89
+ return { request, warn };
90
+ }
91
+
92
+ function setStrictMode(on: boolean): void {
93
+ jest.replaceProperty(configModule, "config", {
94
+ ...configModule.config,
95
+ AILK_HUB_REQUIRE_SPOKE_KEY: on,
96
+ });
97
+ }
98
+
99
+ /** No logged object may carry a key, its digest or its 17-character prefix. */
100
+ function expectNoSecretIn(log: jest.Mock): void {
101
+ const serialized = JSON.stringify(log.mock.calls);
102
+ for (const key of [KEY_S1, KEY_S1B, KEY_S2, KEY_UNKNOWN]) {
103
+ expect(serialized).not.toContain(key);
104
+ expect(serialized).not.toContain(digest(key));
105
+ expect(serialized).not.toContain(key.slice(0, 17));
106
+ }
107
+ }
108
+
109
+ const savedAdapter = process.env.CONTENT_ADAPTER;
110
+ const savedDatabaseUrl = process.env.DATABASE_URL;
111
+
112
+ beforeEach(() => {
113
+ jest.resetAllMocks();
114
+ (getContentAdapter as jest.Mock).mockReturnValue({ kind: "adapter-double" });
115
+ process.env.DATABASE_URL = "postgres://stub";
116
+ process.env.CONTENT_ADAPTER = "database";
117
+ seedRegistry();
118
+ mockSentinelWorkspaceExists(db, W1, W2);
119
+ db.workspace.count.mockResolvedValue(3);
120
+ setStrictMode(false);
121
+ });
122
+
123
+ afterEach(() => {
124
+ jest.restoreAllMocks();
125
+ if (savedAdapter === undefined) delete process.env.CONTENT_ADAPTER;
126
+ else process.env.CONTENT_ADAPTER = savedAdapter;
127
+ if (savedDatabaseUrl === undefined) delete process.env.DATABASE_URL;
128
+ else process.env.DATABASE_URL = savedDatabaseUrl;
129
+ });
130
+
131
+ describe("resolveCallerSite — a registered spoke (AC-1)", () => {
132
+ it.each([
133
+ [KEY_S1, S1, W1],
134
+ [KEY_S1B, S1B, W1],
135
+ [KEY_S2, S2, W2],
136
+ ])("resolves its key to its own workspace", async (key, spokeId, workspaceId) => {
137
+ const { request, warn } = requestWith({ "x-ailk-spoke-key": key });
138
+
139
+ await expect(resolveCallerSite(request)).resolves.toEqual({
140
+ kind: "spoke",
141
+ spokeId,
142
+ workspaceId,
143
+ });
144
+ expect(db.spoke.findUnique).toHaveBeenCalledTimes(1);
145
+ expect(db.spoke.findUnique).toHaveBeenCalledWith(
146
+ expect.objectContaining({ where: { credentialHash: digest(key) } }),
147
+ );
148
+ expectNoSecretIn(warn);
149
+ });
150
+
151
+ it("trims the header value before hashing it", async () => {
152
+ const { request } = requestWith({ "x-ailk-spoke-key": ` ${KEY_S2} ` });
153
+ await expect(resolveCallerSite(request)).resolves.toMatchObject({
154
+ kind: "spoke",
155
+ workspaceId: W2,
156
+ });
157
+ });
158
+
159
+ it("ignores a workspace id, host and origin a spoke ALSO sends", async () => {
160
+ const { request } = requestWith({
161
+ "x-ailk-spoke-key": KEY_S1,
162
+ host: "w2.example",
163
+ "x-forwarded-host": "w2.example",
164
+ origin: "https://w2.example",
165
+ "x-ailk-workspace-id": W2,
166
+ });
167
+ await expect(resolveCallerSite(request)).resolves.toEqual({
168
+ kind: "spoke",
169
+ spokeId: S1,
170
+ workspaceId: W1,
171
+ });
172
+ });
173
+ });
174
+
175
+ describe("resolveCallerSite — fail closed (AC-3)", () => {
176
+ it("(i) an unregistered key is refused as key-unknown", async () => {
177
+ const { request, warn } = requestWith({ "x-ailk-spoke-key": KEY_UNKNOWN });
178
+ await expect(resolveCallerSite(request)).resolves.toEqual({
179
+ kind: "refused",
180
+ reason: "key-unknown",
181
+ });
182
+ expect(warn).toHaveBeenCalledWith(
183
+ { reason: "key-unknown" },
184
+ expect.any(String),
185
+ );
186
+ expectNoSecretIn(warn);
187
+ });
188
+
189
+ it("a revoked key is refused as key-revoked, and only that key", async () => {
190
+ seedRegistry([S1]);
191
+ const revoked = requestWith({ "x-ailk-spoke-key": KEY_S1 });
192
+ await expect(resolveCallerSite(revoked.request)).resolves.toEqual({
193
+ kind: "refused",
194
+ reason: "key-revoked",
195
+ });
196
+ expect(revoked.warn).toHaveBeenCalledWith(
197
+ { reason: "key-revoked", spokeId: S1 },
198
+ expect.any(String),
199
+ );
200
+ expectNoSecretIn(revoked.warn);
201
+
202
+ const sibling = requestWith({ "x-ailk-spoke-key": KEY_S1B });
203
+ await expect(resolveCallerSite(sibling.request)).resolves.toMatchObject({
204
+ kind: "spoke",
205
+ workspaceId: W1,
206
+ });
207
+ });
208
+
209
+ it.each<[string, string | string[]]>([
210
+ ["a duplicated header, as Node joins it", `${KEY_S1}, ${KEY_S2}`],
211
+ ["a string[]", [KEY_S1, KEY_S2]],
212
+ ["an empty value", ""],
213
+ ["a blank value", " "],
214
+ ["a 129-character value", "k".repeat(129)],
215
+ ["a non-ASCII value", `${KEY_S1.slice(0, 40)}é`],
216
+ ["a value carrying a control character", `${KEY_S1.slice(0, 40)}\u0001`],
217
+ ])("(ii)/(iii) %s is refused as key-malformed, before any lookup", async (_label, value) => {
218
+ const { request, warn } = requestWith({ "x-ailk-spoke-key": value });
219
+ await expect(resolveCallerSite(request)).resolves.toEqual({
220
+ kind: "refused",
221
+ reason: "key-malformed",
222
+ });
223
+ expect(db.spoke.findUnique).not.toHaveBeenCalled();
224
+ expectNoSecretIn(warn);
225
+ });
226
+
227
+ it("(v) strict mode refuses a header-less request whatever host, origin or workspace header it carries", async () => {
228
+ setStrictMode(true);
229
+ const { request } = requestWith({
230
+ host: "w1.example",
231
+ "x-forwarded-host": "w1.example",
232
+ origin: "https://w1.example",
233
+ "x-ailk-workspace-id": W1,
234
+ });
235
+ await expect(resolveCallerSite(request)).resolves.toEqual({
236
+ kind: "refused",
237
+ reason: "key-required",
238
+ });
239
+ expect(db.spoke.findUnique).not.toHaveBeenCalled();
240
+ });
241
+ });
242
+
243
+ describe("resolveCallerSite — the registry module is loaded only when a key needs it (AC-3 iv, AC-4)", () => {
244
+ /**
245
+ * A factory that throws when evaluated: requiring `lib/spoke-store.js` at
246
+ * all fails the test. `isolateModules` gives the resolver a fresh module
247
+ * registry, so a previous case's import cannot satisfy it.
248
+ */
249
+ function withThrowingSpokeStore(
250
+ run: (mod: typeof import("../public-site-resolver.js")) => Promise<void>,
251
+ ): Promise<void> {
252
+ return new Promise<void>((resolve, reject) => {
253
+ jest.isolateModules(() => {
254
+ jest.doMock("../../lib/spoke-store.js", () => {
255
+ throw new Error("lib/spoke-store.js must not be imported");
256
+ });
257
+ // A fresh registry instance, required after doMock.
258
+ const mod = require("../public-site-resolver.js") as typeof import("../public-site-resolver.js");
259
+ run(mod).then(resolve, reject);
260
+ });
261
+ });
262
+ }
263
+
264
+ afterEach(() => {
265
+ jest.dontMock("../../lib/spoke-store.js");
266
+ });
267
+
268
+ it("a header-less request is anonymous: zero registry calls, no registry import", async () => {
269
+ await withThrowingSpokeStore(async (mod) => {
270
+ const { request } = requestWith({});
271
+ await expect(mod.resolveCallerSite(request)).resolves.toEqual({
272
+ kind: "anonymous",
273
+ });
274
+ });
275
+ expect(db.spoke.findUnique).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it("a bare request object with no headers at all is anonymous (the frozen suite's shape)", async () => {
279
+ await withThrowingSpokeStore(async (mod) => {
280
+ await expect(
281
+ mod.resolveCallerSite({} as FastifyRequest),
282
+ ).resolves.toEqual({ kind: "anonymous" });
283
+ });
284
+ });
285
+
286
+ it("(iv) a valid-looking key with DATABASE_URL unset is no-database, with no registry import", async () => {
287
+ delete process.env.DATABASE_URL;
288
+ await withThrowingSpokeStore(async (mod) => {
289
+ const { request, warn } = requestWith({ "x-ailk-spoke-key": KEY_S1 });
290
+ await expect(mod.resolveCallerSite(request)).resolves.toEqual({
291
+ kind: "refused",
292
+ reason: "no-database",
293
+ });
294
+ expectNoSecretIn(warn);
295
+ });
296
+ expect(db.spoke.findUnique).not.toHaveBeenCalled();
297
+ });
298
+ });
299
+
300
+ describe("resolvePublicWorkspace — the spoke prefix (D3)", () => {
301
+ it.each(["database", "mdx"])(
302
+ "a spoke resolves to its own workspace on the %s adapter",
303
+ async (adapter) => {
304
+ process.env.CONTENT_ADAPTER = adapter;
305
+ const one = requestWith({ "x-ailk-spoke-key": KEY_S1 });
306
+ const two = requestWith({ "x-ailk-spoke-key": KEY_S2 });
307
+
308
+ await expect(resolvePublicWorkspace(one.request)).resolves.toEqual({
309
+ kind: "resolved",
310
+ workspaceId: W1,
311
+ });
312
+ await expect(resolvePublicWorkspace(two.request)).resolves.toEqual({
313
+ kind: "resolved",
314
+ workspaceId: W2,
315
+ });
316
+ // The one-workspace predicate is never consulted for a spoke.
317
+ expect(db.workspace.count).not.toHaveBeenCalled();
318
+ },
319
+ );
320
+
321
+ it("a refused key is unresolved (spoke-refused), never the alias or the default", async () => {
322
+ const { request } = requestWith({ "x-ailk-spoke-key": KEY_UNKNOWN });
323
+ await expect(resolvePublicWorkspace(request)).resolves.toEqual({
324
+ kind: "unresolved",
325
+ reason: "spoke-refused",
326
+ });
327
+ expect(db.workspace.count).not.toHaveBeenCalled();
328
+ });
329
+
330
+ it("a header-less request keeps today's answer — ambiguous on a three-workspace hub", async () => {
331
+ const { request } = requestWith({});
332
+ await expect(resolvePublicWorkspace(request)).resolves.toEqual({
333
+ kind: "unresolved",
334
+ reason: "ambiguous",
335
+ });
336
+ });
337
+ });
338
+
339
+ describe("resolvePublicContentAdapter — a spoke's content read (AC-1)", () => {
340
+ it("on the database adapter, reads the spoke's own workspace", async () => {
341
+ const { request } = requestWith({ "x-ailk-spoke-key": KEY_S1 });
342
+
343
+ await expect(resolvePublicContentAdapter(request)).resolves.toEqual({
344
+ kind: "adapter-double",
345
+ });
346
+ expect(getContentAdapter).toHaveBeenCalledTimes(1);
347
+ expect(getContentAdapter).toHaveBeenCalledWith({
348
+ workspaceId: W1,
349
+ prisma: db,
350
+ });
351
+ });
352
+
353
+ it("on the mdx adapter, keeps reading the shared mdx source — no workspace argument", async () => {
354
+ process.env.CONTENT_ADAPTER = "mdx";
355
+ const { request } = requestWith({ "x-ailk-spoke-key": KEY_S1 });
356
+
357
+ await resolvePublicContentAdapter(request);
358
+ expect(getContentAdapter).toHaveBeenCalledTimes(1);
359
+ expect(getContentAdapter).toHaveBeenCalledWith();
360
+ });
361
+
362
+ it.each<[string, Record<string, string>, boolean]>([
363
+ ["an unregistered key", { "x-ailk-spoke-key": KEY_UNKNOWN }, false],
364
+ ["a malformed key", { "x-ailk-spoke-key": "" }, false],
365
+ ["a header-less request in strict mode", { host: "w1.example" }, true],
366
+ ])("%s resolves to null — no adapter at all (AC-3)", async (_label, headers, strict) => {
367
+ setStrictMode(strict);
368
+ const { request, warn } = requestWith(headers);
369
+
370
+ await expect(resolvePublicContentAdapter(request)).resolves.toBeNull();
371
+ expect(getContentAdapter).not.toHaveBeenCalled();
372
+ expectNoSecretIn(warn);
373
+ });
374
+ });
@@ -17,12 +17,13 @@
17
17
  * keeps the two paths from drifting.
18
18
  *
19
19
  * PRECEDENCE, NOT PARALLELISM. When the multi-tenant module is present,
20
- * `resolveActiveTenant` (the slice) is the entry point and calls
21
- * `resolveDefaultTenant` only on the branch it always called it on — the
22
- * previously-unconditional-deny null-binding branch. Nothing about the
23
- * multi-tenant path changes. Without the module, `middleware/default-tenant.ts`
24
- * calls this directly. There is one resolution, reached two ways, never two
25
- * resolutions.
20
+ * `resolveActiveTenant` (the slice) is the entry point and, on its
21
+ * null-binding branch only (the session carries no active workspace), calls
22
+ * `resolveDefaultTenantForOperator` — the same pinned, verified-operator entry
23
+ * point the base guard uses (#7523). Its explicit-binding branch never reaches
24
+ * this file. Without the module, `middleware/default-tenant.ts` calls
25
+ * `resolveDefaultTenantForOperator` directly. There is one resolution, behind
26
+ * one pin check, reached two ways — never two resolutions.
26
27
  *
27
28
  * DEFAULT-DENY IS THE FLOOR. `resolveDefaultTenant` returns `null` — never a
28
29
  * grant — the instant the deployment is not unambiguously single-tenant
@@ -145,14 +146,14 @@ async function bindOperatorMembership(userId: Id<"user">): Promise<void> {
145
146
  * whose `User.email` matches `AILK_OPERATOR_EMAIL`, denying everyone when the
146
147
  * pin is unset (#5693 security review, High finding).
147
148
  *
148
- * WHY THE BASE PATH NEEDS THIS AND THE MULTI-TENANT PATH DOES NOT. Registering
149
- * the review surface on a module-less build made two things reachable together
150
- * that were previously unreachable there: this resolution's TOFU operator bind,
151
- * and `services/waitlist-dashboard.ts`'s `isPlatformOperator`, which grants
149
+ * WHY BOTH PATHS NEED THIS. Registering the review surface on a module-less
150
+ * build made two things reachable together that were previously unreachable
151
+ * there: this resolution's TOFU operator bind, and
152
+ * `services/waitlist-operator-reads.ts`'s `isPlatformOperator`, which grants
152
153
  * `{kind: "all"}` — every site's rows, unfiltered — to an `owner` of the
153
- * sentinel default Organization whenever `AILK_OPERATOR_EMAIL` is merely SET.
154
- * That predicate never compares the caller to the pin; the pin is an on/off
155
- * switch for it.
154
+ * sentinel default Organization. Until #7523 that predicate never compared the
155
+ * caller to the pin; the pin was an on/off switch for it. It now goes through
156
+ * `isPinnedOperator` below too, but the bind is the half this function closes.
156
157
  *
157
158
  * Unpinned TOFU plus that predicate is an escalation. The site's auth
158
159
  * auto-provisions a `User` on first login, so on a deployment with the pin
@@ -170,21 +171,29 @@ async function bindOperatorMembership(userId: Id<"user">): Promise<void> {
170
171
  * but only at bind time and only for the bind; this is the one that gates the
171
172
  * grant.
172
173
  *
173
- * The multi-tenant module is unaffected: `resolveActiveTenant` keeps calling
174
- * `resolveDefaultTenant` directly, on exactly the branch it always called it
175
- * on. This wrapper is reached only from `middleware/default-tenant.ts`, which
176
- * only ever runs on a build without that module.
174
+ * The multi-tenant module reached the same race the same way: its null-binding
175
+ * branch called `resolveDefaultTenant` directly, so on a one-workspace
176
+ * multi-tenant deployment with the pin unset the first signed-in caller to
177
+ * touch a tenant route from `apps/web` (whose sessions never carry an active
178
+ * workspace) was seated as owner. Since #7523 that branch calls this wrapper
179
+ * too (docs/specs/7523-a-multi-tenant-session-with-spec.md D1), so a session
180
+ * with no active workspace is granted the default tenant only when its
181
+ * caller is the pinned, verified operator. Sessions that DO carry an active
182
+ * workspace — every Admin-app request — never reach this function.
177
183
  *
178
184
  * `docs/specs/single-tenant-owner-pin-spec.md` D5 already recommended pinning;
179
- * on this path it is mandatory rather than advisory, and unset fails closed.
185
+ * on both paths it is now mandatory rather than advisory, and unset fails
186
+ * closed.
180
187
  *
181
188
  * ONE KNOWN IMPRECISION, deliberate. `User.email` is `@unique` in Postgres,
182
189
  * which is byte-exact, while this comparison lowercases both sides — so two
183
190
  * distinct rows (`Operator@x.com` and `operator@x.com`) could both satisfy one
184
- * pin. It is not reachable today: `apps/web/auth.ts` configures social
185
- * providers only, so the address is provider-asserted and normalized rather
186
- * than caller-chosen. It becomes reachable the moment a credential provider is
187
- * enabled, and the durable fix is case-insensitive email uniqueness in the
191
+ * pin. `apps/web/auth.ts` no longer configures social providers only: it also
192
+ * enables email+password and magic-link sign-in, so the address can be
193
+ * caller-chosen. Only a VERIFIED row is compared (`findVerifiedEmail`), and
194
+ * verifying it needs a mailbox proof, so a second row for the pin's address
195
+ * in a different case is reachable only by someone who controls that
196
+ * mailbox. The durable fix is still case-insensitive email uniqueness in the
188
197
  * schema (citext, or lowercase-at-write) so "matches the pin" and "is unique"
189
198
  * agree. Matching case-sensitively here instead would be worse: it would lock
190
199
  * out an operator who typed their own address with different capitalisation.
@@ -192,9 +201,26 @@ async function bindOperatorMembership(userId: Id<"user">): Promise<void> {
192
201
  export async function resolveDefaultTenantForOperator(
193
202
  userId: Id<"user">,
194
203
  ): Promise<TenantContext | null> {
204
+ return (await isPinnedOperator(userId)) ? resolveDefaultTenant(userId) : null;
205
+ }
206
+
207
+ /**
208
+ * The one pin-match predicate in `apps/api` (#7523 D2): `true` only when
209
+ * `AILK_OPERATOR_EMAIL` is set and the caller's VERIFIED `User.email` equals
210
+ * it. Both sides are trimmed and lowercased; a blank pin counts as unset and
211
+ * refuses before any read, so it can never "match" a blank `User.email`.
212
+ *
213
+ * Shared by the two tenant guards (through `resolveDefaultTenantForOperator`)
214
+ * and by `services/waitlist-operator-reads.ts`'s `isPlatformOperator`. It
215
+ * lives in this base-build file because that operator-read file rides more
216
+ * than one module slice and may import only files every build ships.
217
+ * `bindOperatorMembership` keeps its own inline check: it must allow a bind
218
+ * when the pin is unset, which this predicate never does.
219
+ */
220
+ export async function isPinnedOperator(userId: Id<"user">): Promise<boolean> {
195
221
  const pinnedEmail = process.env.AILK_OPERATOR_EMAIL?.trim().toLowerCase();
196
222
  if (!pinnedEmail) {
197
- return null;
223
+ return false;
198
224
  }
199
225
  // The verified-email read is `services/verified-email.ts` (#5701 D4) — the
200
226
  // one this and the four other email-keyed paths share. It returns the
@@ -202,12 +228,9 @@ export async function resolveDefaultTenantForOperator(
202
228
  // against the pin a proven claim rather than an asserted one.
203
229
  const verified = await findVerifiedEmail(userId);
204
230
  if (!verified.ok) {
205
- return null;
206
- }
207
- if (verified.value.email.trim().toLowerCase() !== pinnedEmail) {
208
- return null;
231
+ return false;
209
232
  }
210
- return resolveDefaultTenant(userId);
233
+ return verified.value.email.trim().toLowerCase() === pinnedEmail;
211
234
  }
212
235
 
213
236
  /**