create-ailk 0.11.4 → 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 (81) hide show
  1. package/component-catalog.json +36 -3
  2. package/component-catalog.md +10 -7
  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/__tests__/content-write-production-boot.test.ts +104 -0
  15. package/templates/apps/api/src/__tests__/root-modules.test.ts +175 -0
  16. package/templates/apps/api/src/bin/spokes.ts +137 -0
  17. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
  18. package/templates/apps/api/src/config/index.ts +97 -0
  19. package/templates/apps/api/src/config/modules.ts +38 -0
  20. package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
  21. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
  22. package/templates/apps/api/src/lib/spoke-store.ts +131 -0
  23. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
  24. package/templates/apps/api/src/routes/leads/index.ts +33 -3
  25. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
  26. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
  27. package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
  28. package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
  29. package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
  30. package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
  31. package/templates/apps/api/src/server.ts +56 -1
  32. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
  33. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +19 -0
  34. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
  35. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
  36. package/templates/apps/api/src/services/default-tenant.ts +51 -28
  37. package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
  38. package/templates/apps/api/src/services/lead-routing.ts +13 -1
  39. package/templates/apps/api/src/services/project-listings.ts +209 -18
  40. package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
  41. package/templates/apps/api/src/services/revalidation.ts +8 -1
  42. package/templates/apps/api/src/services/slack-notify.ts +54 -3
  43. package/templates/apps/web/.env.example +14 -0
  44. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
  45. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
  46. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
  47. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
  48. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
  49. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
  50. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
  51. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
  52. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
  53. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
  54. package/templates/apps/web/app/[locale]/layout.tsx +4 -0
  55. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
  56. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
  57. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
  58. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
  59. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
  60. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
  61. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
  62. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
  63. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
  64. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
  65. package/templates/apps/web/jest.config.cjs +1 -1
  66. package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
  67. package/templates/apps/web/lib/listings.tsx +472 -0
  68. package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
  69. package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
  70. package/templates/apps/web/messages/en.json +49 -15
  71. package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
  72. package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
  73. package/templates/database/CHANGELOG.md +31 -0
  74. package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
  75. package/templates/database/auth/schema.prisma +31 -0
  76. package/templates/database/config/schema.prisma +26 -0
  77. package/templates/database/inbox/schema.prisma +21 -0
  78. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
  79. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
  80. package/templates/database/package.json +1 -1
  81. package/versions.json +34 -34
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @file hub-require-spoke-key.test.ts
3
+ * @description `AILK_HUB_REQUIRE_SPOKE_KEY` — the hub's strict-mode flag
4
+ * (#7532, spec docs/specs/7532-a-spoke-s-public-reads-spec.md D7, AC-5).
5
+ *
6
+ * `true`/`1` turn it on; unset, empty, `false` and `0` leave it off —
7
+ * case-insensitively, after trimming. Any other value refuses the boot with a
8
+ * message naming the variable, never the value. On with no `DATABASE_URL`
9
+ * refuses the boot too: strict mode with no registry would refuse every call.
10
+ */
11
+
12
+ // Mock dotenv so a developer's local .env never leaks into the config module.
13
+ jest.mock('dotenv', () => ({
14
+ config: jest.fn(),
15
+ }));
16
+
17
+ const mockLoggerError = jest.fn();
18
+ const mockLoggerWarn = jest.fn();
19
+ jest.mock('@working-theory/observability', () => ({
20
+ logger: {
21
+ error: (...args: unknown[]) => mockLoggerError(...args),
22
+ warn: (...args: unknown[]) => mockLoggerWarn(...args),
23
+ info: jest.fn(),
24
+ debug: jest.fn(),
25
+ },
26
+ }));
27
+
28
+ describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
29
+ const originalEnv = process.env;
30
+
31
+ beforeEach(() => {
32
+ jest.resetModules();
33
+ process.env = { ...originalEnv };
34
+ delete process.env.AILK_HUB_REQUIRE_SPOKE_KEY;
35
+ process.env.DATABASE_URL = 'postgresql://test:test@localhost/test';
36
+ jest.clearAllMocks();
37
+ });
38
+
39
+ afterEach(() => {
40
+ process.env = originalEnv;
41
+ jest.restoreAllMocks();
42
+ });
43
+
44
+ it.each(['true', 'TRUE', '1', ' True '])('%j parses on', (raw) => {
45
+ process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
46
+ const { config } = require('../index');
47
+ expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(true);
48
+ });
49
+
50
+ it.each([undefined, '', ' ', 'false', 'FALSE', '0'])('%j parses off', (raw) => {
51
+ if (raw !== undefined) process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
52
+ const { config } = require('../index');
53
+ expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(false);
54
+ });
55
+
56
+ it('any other value refuses the boot, naming the variable and never the value', () => {
57
+ process.env.AILK_HUB_REQUIRE_SPOKE_KEY = 'yes-sentinel-value';
58
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
59
+ throw new Error('Process exit called');
60
+ });
61
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
62
+
63
+ expect(() => require('../index')).toThrow('Process exit called');
64
+ expect(exitSpy).toHaveBeenCalledWith(1);
65
+
66
+ const logged = JSON.stringify([mockLoggerError.mock.calls, consoleSpy.mock.calls]);
67
+ expect(logged).toContain('AILK_HUB_REQUIRE_SPOKE_KEY');
68
+ expect(logged).not.toContain('yes-sentinel-value');
69
+ });
70
+
71
+ it('on with DATABASE_URL unset refuses the boot', () => {
72
+ process.env.AILK_HUB_REQUIRE_SPOKE_KEY = 'true';
73
+ delete process.env.DATABASE_URL;
74
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
75
+ throw new Error('Process exit called');
76
+ });
77
+ jest.spyOn(console, 'error').mockImplementation(() => undefined);
78
+
79
+ expect(() => require('../index')).toThrow('Process exit called');
80
+ expect(exitSpy).toHaveBeenCalledWith(1);
81
+ expect(JSON.stringify(mockLoggerError.mock.calls)).toContain(
82
+ 'AILK_HUB_REQUIRE_SPOKE_KEY',
83
+ );
84
+ });
85
+
86
+ it('off with DATABASE_URL unset still boots database-less, as today', () => {
87
+ delete process.env.DATABASE_URL;
88
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
89
+ throw new Error('Process exit called');
90
+ });
91
+
92
+ const { config } = require('../index');
93
+ expect(exitSpy).not.toHaveBeenCalled();
94
+ expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(false);
95
+ });
96
+ });
@@ -102,6 +102,23 @@ export interface Config {
102
102
  // nothing (§ D2 row 1) — the same fail-closed posture STRIPE_WEBHOOK_SECRET
103
103
  // unset already has.
104
104
  CAL_WEBHOOK_SECRET?: string;
105
+
106
+ // Per-workspace scheduling connections (#7533 D2) — the AES-256-GCM key
107
+ // services/scheduling/connections.ts encrypts each connection's provider
108
+ // credential and webhook secret under. Canonical standard base64 of exactly
109
+ // 32 random bytes (`openssl rand -base64 32`), checked at startup by
110
+ // resolveSchedulingCredentialKey() below; a refused value stops the API.
111
+ // Optional: unset (or empty) means connections cannot be written, and the
112
+ // env-configured CAL_API_KEY path works exactly as before. Held decoded,
113
+ // never as the raw string, and never logged.
114
+ SCHEDULING_CREDENTIAL_KEY?: Buffer;
115
+ // Hub strict mode (#7532 D7). A hub serving several spokes sets it so a
116
+ // request to a spoke-aware public surface (meeting types, the three lead
117
+ // writes, booking, the public content reads) that carries no
118
+ // `x-ailk-spoke-key` resolves to nothing. Off (the default) keeps today's
119
+ // header-less resolution, byte for byte. Parsed by parseHubRequireSpokeKey()
120
+ // below; read by services/public-site-resolver.ts.
121
+ AILK_HUB_REQUIRE_SPOKE_KEY: boolean;
105
122
  }
106
123
 
107
124
  /** Why an AILK_TRUSTED_ORIGINS entry was refused (#7395 D2). */
@@ -201,6 +218,47 @@ export function resolveTrustedOrigins(env: {
201
218
  return { origins: Object.freeze([...new Set(origins)]), refused, primaryUnusable };
202
219
  }
203
220
 
221
+ /** Why a SCHEDULING_CREDENTIAL_KEY value was refused (#7533 D2). */
222
+ export type SchedulingCredentialKeyRefusal = 'not-canonical-base64' | 'length';
223
+
224
+ /**
225
+ * The scheduling-connection key (#7533 D2, SEC-7533-6). Pure;
226
+ * `validateConfig()` calls it once.
227
+ *
228
+ * Accepts only CANONICAL standard base64 — `Buffer.from(v, 'base64')
229
+ * .toString('base64') === v` — of exactly 32 bytes. Base64 decoding silently
230
+ * skips characters outside its alphabet, so without the round-trip a
231
+ * base64url value, or a passphrase that happens to decode to 32 bytes, would
232
+ * be taken as a key. `''` and unset are both `unset`. A refusal carries the
233
+ * reason kind only, never any part of the value.
234
+ */
235
+ export function resolveSchedulingCredentialKey(
236
+ raw: string | undefined,
237
+ ):
238
+ | { kind: 'unset' }
239
+ | { kind: 'ok'; key: Buffer }
240
+ | { kind: 'refused'; reason: SchedulingCredentialKeyRefusal } {
241
+ if (raw === undefined || raw === '') return { kind: 'unset' };
242
+ const key = Buffer.from(raw, 'base64');
243
+ if (key.toString('base64') !== raw) {
244
+ return { kind: 'refused', reason: 'not-canonical-base64' };
245
+ }
246
+ if (key.length !== 32) return { kind: 'refused', reason: 'length' };
247
+ return { kind: 'ok', key };
248
+ }
249
+
250
+ /**
251
+ * `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7): `true`/`1` → on; unset, empty,
252
+ * `false`, `0` → off — case-insensitive, after trimming. `null` for any other
253
+ * value, which `validateConfig()` refuses at boot.
254
+ */
255
+ export function parseHubRequireSpokeKey(raw: string | undefined): boolean | null {
256
+ const value = (raw ?? '').trim().toLowerCase();
257
+ if (value === 'true' || value === '1') return true;
258
+ if (value === '' || value === 'false' || value === '0') return false;
259
+ return null;
260
+ }
261
+
204
262
  /** Enabled modules whose state lives in the database (see validateConfig). */
205
263
  const DATABASE_MODULES = [
206
264
  'commerce',
@@ -267,6 +325,40 @@ function validateConfig(): Config {
267
325
  );
268
326
  }
269
327
 
328
+ // #7533 D2 — a set-but-refused SCHEDULING_CREDENTIAL_KEY stops the API in
329
+ // every environment, as a refused AILK_TRUSTED_ORIGINS entry does: a key
330
+ // taken in some other shape would encrypt every connection under a value
331
+ // the operator did not mean. The reason kind only, never the value.
332
+ const schedulingKey = resolveSchedulingCredentialKey(process.env.SCHEDULING_CREDENTIAL_KEY);
333
+ if (schedulingKey.kind === 'refused') {
334
+ const message =
335
+ '❌ SCHEDULING_CREDENTIAL_KEY must be canonical standard base64 of exactly 32 bytes (openssl rand -base64 32)';
336
+ logger.error({ reason: schedulingKey.reason }, message);
337
+ console.error(message, JSON.stringify({ reason: schedulingKey.reason }));
338
+ process.exit(1);
339
+ }
340
+
341
+ // #7532 D7 — the hub strict-mode flag. An unparseable value stops the API:
342
+ // the variable is opt-in, and a typo read as "off" would silently leave the
343
+ // hub open to header-less callers. The message names the variable, never
344
+ // its value. On with no DATABASE_URL stops it too: strict mode with no
345
+ // registry to look a key up in would refuse every call.
346
+ const hubRequireSpokeKey = parseHubRequireSpokeKey(process.env.AILK_HUB_REQUIRE_SPOKE_KEY);
347
+ if (hubRequireSpokeKey === null) {
348
+ const message =
349
+ '❌ AILK_HUB_REQUIRE_SPOKE_KEY must be true, 1, false, 0 or unset';
350
+ logger.error(message);
351
+ console.error(message);
352
+ process.exit(1);
353
+ }
354
+ if (hubRequireSpokeKey && !process.env.DATABASE_URL) {
355
+ const message =
356
+ '❌ AILK_HUB_REQUIRE_SPOKE_KEY is on but DATABASE_URL is not set — the spoke registry lives in the database';
357
+ logger.error(message);
358
+ console.error(message);
359
+ process.exit(1);
360
+ }
361
+
270
362
  return {
271
363
  NODE_ENV: nodeEnv,
272
364
  PORT: parseInt(process.env.PORT || '3002', 10),
@@ -334,6 +426,11 @@ function validateConfig(): Config {
334
426
  ...(process.env.CAL_WEBHOOK_SECRET !== undefined && {
335
427
  CAL_WEBHOOK_SECRET: process.env.CAL_WEBHOOK_SECRET,
336
428
  }),
429
+
430
+ ...(schedulingKey.kind === 'ok' && {
431
+ SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
432
+ }),
433
+ AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey === true,
337
434
  };
338
435
  }
339
436
 
@@ -43,3 +43,41 @@ function enabledModules(): Set<string> {
43
43
  export function isModuleEnabled(moduleName: string): boolean {
44
44
  return enabledModules().has(moduleName);
45
45
  }
46
+
47
+ /**
48
+ * The recognized optional-module keys, sorted — the vocabulary the root index
49
+ * may report (#7501). Filtering to this list is what keeps the anonymous
50
+ * `GET /` from echoing whatever text an operator typed into
51
+ * `AILK_ENABLED_MODULES`. `docs` is deliberately absent: no apps/api route
52
+ * registers under it, so reporting it would break "modules this process
53
+ * registered routes for".
54
+ */
55
+ export const OPTIONAL_MODULES = [
56
+ "commerce",
57
+ "content-write",
58
+ "multi-tenant",
59
+ "scheduling",
60
+ "signal-collectors",
61
+ "workspace",
62
+ ] as const;
63
+
64
+ /**
65
+ * The optional modules whose routes this process registers, sorted. Called
66
+ * once at build time by `server.ts`, so the answer matches what registered
67
+ * even if the env changes afterwards.
68
+ *
69
+ * `content-write` counts only alongside a tenancy module (`workspace` or
70
+ * `multi-tenant`) — the exact predicate `server.ts` registers the editor's
71
+ * content routes under. That gate stays a literal there (the
72
+ * slice-registration lint reads `isModuleEnabled("X")` guards by text), so the
73
+ * predicate is repeated here and `root-modules.test.ts` asserts they agree.
74
+ */
75
+ export function servedModules(): string[] {
76
+ return OPTIONAL_MODULES.filter((name) => {
77
+ if (!isModuleEnabled(name)) return false;
78
+ if (name === "content-write") {
79
+ return isModuleEnabled("workspace") || isModuleEnabled("multi-tenant");
80
+ }
81
+ return true;
82
+ });
83
+ }
@@ -161,6 +161,11 @@ export const prisma = {
161
161
  upsert: jest.fn(),
162
162
  deleteMany: jest.fn(),
163
163
  },
164
+ // `findUnique` — the public meetings read (`lib/public-meetings-config.ts`),
165
+ // which a spoke's meeting-types read and booking gate reach (#7532 D3/D4).
166
+ workspaceMeetings: {
167
+ findUnique: jest.fn(),
168
+ },
164
169
  // ── Inbox namespace (Lead, Subscriber, Callback) ─────────────────────────
165
170
  lead: {
166
171
  create: jest.fn(),
@@ -355,6 +360,33 @@ export const prisma = {
355
360
  findMany: jest.fn(),
356
361
  updateMany: jest.fn(),
357
362
  },
363
+ // ── Config namespace (SchedulingConnection — #7533) ─────────────────────
364
+ // Every call lives in services/scheduling/connections.ts: `findUnique` by
365
+ // `workspaceId` (the provider read + the summary) or by `webhookToken` (the
366
+ // per-connection webhook); `upsert` keyed on the tenant's workspace (the
367
+ // connect/replace write); `deleteMany` keyed the same way (the revoke).
368
+ schedulingConnection: {
369
+ findUnique: jest.fn(),
370
+ upsert: jest.fn(),
371
+ deleteMany: jest.fn(),
372
+ },
373
+ // ── #7532 — the hub's spoke registry (auth) and the booking origin (inbox) ─
374
+ // `spoke.findUnique` — the resolver's one lookup by credential digest;
375
+ // `create`/`updateMany`/`findMany` — the operator CLI's register, revoke and
376
+ // list (`lib/spoke-store.ts`). `appointmentOrigin.createMany` — the bind's
377
+ // ON CONFLICT DO NOTHING insert; `findUnique` — the origin read the bind and
378
+ // the webhook make (`services/scheduling/appointments.ts`).
379
+ spoke: {
380
+ findUnique: jest.fn(),
381
+ findFirst: jest.fn(),
382
+ findMany: jest.fn(),
383
+ create: jest.fn(),
384
+ updateMany: jest.fn(),
385
+ },
386
+ appointmentOrigin: {
387
+ findUnique: jest.fn(),
388
+ createMany: jest.fn(),
389
+ },
358
390
  // ── Content namespace (UsageEvent, UsagePeriod — FEAT-047 P1.S1/#3709) ──
359
391
  usageEvent: {
360
392
  create: jest.fn(),
@@ -385,6 +417,9 @@ export const prisma = {
385
417
  // `mockTransaction` helper models commit/rollback so atomicity is asserted,
386
418
  // not assumed.
387
419
  $transaction: jest.fn(),
420
+ // `$executeRaw` — the per-booking `pg_advisory_xact_lock` (#7532 D5),
421
+ // taken inside `$transaction`. Suites on that path arm both.
422
+ $executeRaw: jest.fn(),
388
423
  $connect: jest.fn(),
389
424
  $disconnect: jest.fn(),
390
425
  };
@@ -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
+ }