create-ailk 0.12.1 → 0.14.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 (163) hide show
  1. package/README.md +12 -4
  2. package/component-catalog.json +247 -33
  3. package/component-catalog.md +10 -6
  4. package/dist/apply-design.d.ts +21 -2
  5. package/dist/apply-design.js +120 -19
  6. package/dist/cli.js +1 -0
  7. package/dist/design-compile.d.ts +70 -2
  8. package/dist/design-compile.js +97 -1
  9. package/dist/design-home.d.ts +74 -0
  10. package/dist/design-home.js +256 -0
  11. package/dist/design-package.d.ts +39 -2
  12. package/dist/design-package.js +223 -9
  13. package/dist/design-presets.d.ts +17 -1
  14. package/dist/design-presets.js +17 -5
  15. package/dist/design-scaffold.d.ts +6 -25
  16. package/dist/design-scaffold.js +26 -74
  17. package/dist/index.d.ts +1 -0
  18. package/dist/lib/design-checks.d.ts +12 -1
  19. package/dist/lib/design-checks.js +38 -2
  20. package/dist/lib/module-license-gate.d.ts +9 -0
  21. package/dist/lib/module-license-gate.js +18 -0
  22. package/dist/module-architecture.js +27 -0
  23. package/dist/programmatic.js +70 -26
  24. package/dist/site-chrome.d.ts +38 -0
  25. package/dist/site-chrome.js +109 -2
  26. package/dist/site-fonts.d.ts +21 -0
  27. package/dist/site-fonts.js +54 -0
  28. package/dist/site-roster.d.ts +16 -0
  29. package/dist/site-roster.js +81 -0
  30. package/dist/starter-grid.js +15 -0
  31. package/dist/surfaces.js +12 -0
  32. package/dist/team-profiles.d.ts +29 -0
  33. package/dist/team-profiles.js +43 -0
  34. package/package.json +3 -3
  35. package/release-scope.json +1 -1
  36. package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
  37. package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
  38. package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
  39. package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
  40. package/site-templates/designs/minimalism/DESIGN.md +164 -34
  41. package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
  42. package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
  43. package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
  44. package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
  45. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
  46. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
  47. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
  48. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
  49. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  50. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
  51. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
  52. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  53. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
  54. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
  55. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  56. package/templates/.env.example +23 -9
  57. package/templates/README.md +2 -0
  58. package/templates/apps/api/CLAUDE.md +24 -17
  59. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
  60. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
  61. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
  62. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
  63. package/templates/apps/api/src/config/index.ts +35 -20
  64. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
  65. package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
  66. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
  67. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
  68. package/templates/apps/api/src/lib/agent-store.ts +521 -0
  69. package/templates/apps/api/src/lib/spoke-store.ts +13 -2
  70. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
  71. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
  72. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
  73. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
  74. package/templates/apps/api/src/middleware/auth.ts +219 -5
  75. package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
  76. package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
  77. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
  78. package/templates/apps/api/src/routes/leads/index.ts +14 -6
  79. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
  80. package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
  81. package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
  82. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
  83. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
  84. package/templates/apps/api/src/server.ts +27 -0
  85. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
  86. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
  87. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
  88. package/templates/apps/api/src/services/audit-log.ts +278 -47
  89. package/templates/apps/api/src/services/default-tenant.ts +55 -5
  90. package/templates/apps/api/src/services/leads-inbox.ts +12 -21
  91. package/templates/apps/api/src/services/project-listings.ts +1 -21
  92. package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
  93. package/templates/apps/mcp/CLAUDE.md +44 -5
  94. package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
  95. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
  96. package/templates/apps/mcp/package.json +1 -1
  97. package/templates/apps/mcp/src/http-transport.ts +107 -1
  98. package/templates/apps/mcp/src/workspace-server.ts +193 -0
  99. package/templates/apps/web/.env.example +64 -37
  100. package/templates/apps/web/CLAUDE.md +11 -0
  101. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
  102. package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
  104. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  105. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  106. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
  107. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +69 -0
  108. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +64 -0
  109. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
  110. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
  111. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
  112. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
  113. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
  114. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
  115. package/templates/apps/web/app/[locale]/layout.tsx +156 -53
  116. package/templates/apps/web/app/[locale]/page.tsx +37 -0
  117. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  118. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
  119. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
  120. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
  121. package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
  122. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
  123. package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
  124. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
  125. package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
  126. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
  127. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
  128. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
  129. package/templates/apps/web/app/not-found.tsx +47 -0
  130. package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
  131. package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
  132. package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
  133. package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
  134. package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
  135. package/templates/apps/web/lib/profile-back-link.ts +51 -0
  136. package/templates/apps/web/lib/relay.ts +279 -0
  137. package/templates/apps/web/lib/site-chrome.ts +12 -0
  138. package/templates/apps/web/lib/site-fonts.ts +15 -0
  139. package/templates/apps/web/lib/site-logo.tsx +39 -1
  140. package/templates/apps/web/lib/site-theme-head.ts +33 -0
  141. package/templates/apps/web/lib/site-theme.ts +13 -1
  142. package/templates/apps/web/messages/en.json +7 -1
  143. package/templates/apps/web/middleware.ts +5 -4
  144. package/templates/apps/web/next.config.mjs +6 -0
  145. package/templates/database/CHANGELOG.md +22 -0
  146. package/templates/database/__tests__/agents.db.test.ts +235 -0
  147. package/templates/database/auth/schema.prisma +61 -0
  148. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  149. package/templates/database/package.json +1 -1
  150. package/versions.json +35 -35
  151. package/site-templates/designs/minimalism/sample/README.md +0 -48
  152. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
  153. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
  154. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
  155. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
  156. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
  157. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
  158. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
  159. package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
  160. package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
  161. package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
  162. package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
  163. package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
@@ -112,15 +112,21 @@ export interface Config {
112
112
  // env-configured CAL_API_KEY path works exactly as before. Held decoded,
113
113
  // never as the raw string, and never logged.
114
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;
115
+ // What a hub does with a request to a spoke-aware public surface (meeting
116
+ // types, the three lead writes, booking, availability, event meta, the
117
+ // public content reads) that carries no `x-ailk-spoke-key` (#7532 D7,
118
+ // #7633 D2). `"auto"` (unset, the default) refuses it once the spoke
119
+ // registry holds a row, and keeps the keyless answer on a deployment with
120
+ // no spoke; `"on"` always refuses it; `"off"` never does — an emergency
121
+ // opt-out, warned at boot. A mode, never a boolean: every value is a truthy
122
+ // string, so no reader may test it by truthiness. Parsed by
123
+ // parseHubRequireSpokeKey() below; read by services/public-site-resolver.ts.
124
+ AILK_HUB_REQUIRE_SPOKE_KEY: HubSpokeKeyMode;
122
125
  }
123
126
 
127
+ /** `AILK_HUB_REQUIRE_SPOKE_KEY`'s three states (#7633 D2). */
128
+ export type HubSpokeKeyMode = 'auto' | 'on' | 'off';
129
+
124
130
  /** Why an AILK_TRUSTED_ORIGINS entry was refused (#7395 D2). */
125
131
  export type TrustedOriginRefusal =
126
132
  | 'wildcard'
@@ -248,14 +254,16 @@ export function resolveSchedulingCredentialKey(
248
254
  }
249
255
 
250
256
  /**
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.
257
+ * `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7, #7633 D2): unset, empty or blank →
258
+ * `"auto"`; `true`/`1` → `"on"`; `false`/`0` → `"off"` — case-insensitive,
259
+ * after trimming. `null` for any other value, which `validateConfig()`
260
+ * refuses at boot.
254
261
  */
255
- export function parseHubRequireSpokeKey(raw: string | undefined): boolean | null {
262
+ export function parseHubRequireSpokeKey(raw: string | undefined): HubSpokeKeyMode | null {
256
263
  const value = (raw ?? '').trim().toLowerCase();
257
- if (value === 'true' || value === '1') return true;
258
- if (value === '' || value === 'false' || value === '0') return false;
264
+ if (value === '') return 'auto';
265
+ if (value === 'true' || value === '1') return 'on';
266
+ if (value === 'false' || value === '0') return 'off';
259
267
  return null;
260
268
  }
261
269
 
@@ -338,11 +346,11 @@ function validateConfig(): Config {
338
346
  process.exit(1);
339
347
  }
340
348
 
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.
349
+ // #7532 D7, #7633 D2 — the hub's spoke-key mode. An unparseable value stops
350
+ // the API: a typo read as "off" would silently leave the hub open to
351
+ // header-less callers. The message names the variable, never its value.
352
+ // "on" with no DATABASE_URL stops it too: with no registry to look a key up
353
+ // in it would refuse every call. "auto" and "off" boot without a database.
346
354
  const hubRequireSpokeKey = parseHubRequireSpokeKey(process.env.AILK_HUB_REQUIRE_SPOKE_KEY);
347
355
  if (hubRequireSpokeKey === null) {
348
356
  const message =
@@ -351,13 +359,20 @@ function validateConfig(): Config {
351
359
  console.error(message);
352
360
  process.exit(1);
353
361
  }
354
- if (hubRequireSpokeKey && !process.env.DATABASE_URL) {
362
+ if (hubRequireSpokeKey === 'on' && !process.env.DATABASE_URL) {
355
363
  const message =
356
364
  '❌ AILK_HUB_REQUIRE_SPOKE_KEY is on but DATABASE_URL is not set — the spoke registry lives in the database';
357
365
  logger.error(message);
358
366
  console.error(message);
359
367
  process.exit(1);
360
368
  }
369
+ // The emergency opt-out is never silent: it reopens keyless access on every
370
+ // spoke-aware surface of a hub (#7633 D2, D6).
371
+ if (hubRequireSpokeKey === 'off') {
372
+ logger.warn(
373
+ 'AILK_HUB_REQUIRE_SPOKE_KEY=false — a public request with no spoke key is accepted even when spokes are registered',
374
+ );
375
+ }
361
376
 
362
377
  return {
363
378
  NODE_ENV: nodeEnv,
@@ -430,7 +445,7 @@ function validateConfig(): Config {
430
445
  ...(schedulingKey.kind === 'ok' && {
431
446
  SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
432
447
  }),
433
- AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey === true,
448
+ AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey,
434
449
  };
435
450
  }
436
451
 
@@ -8,6 +8,9 @@ const MockPrismaClient = jest.fn().mockImplementation((options) => ({
8
8
  user: {},
9
9
  apiToken: {},
10
10
  userData: {},
11
+ // #7633 D8 — the public resolver's "is this a hub" read. An empty registry,
12
+ // so a suite that reaches this stub keeps today's keyless answer.
13
+ spoke: { findFirst: jest.fn(async () => null) },
11
14
  }));
12
15
 
13
16
  // #4663 D6 — flow-engine.ts imports the real `Prisma` runtime namespace (a
@@ -67,6 +67,8 @@ export const prisma = {
67
67
  user: {
68
68
  findUnique: jest.fn(),
69
69
  findFirst: jest.fn(),
70
+ // #3591 — `lib/agent-store.ts`'s list names each agent's creator by id.
71
+ findMany: jest.fn(),
70
72
  update: jest.fn(),
71
73
  create: jest.fn(),
72
74
  },
@@ -387,6 +389,18 @@ export const prisma = {
387
389
  findUnique: jest.fn(),
388
390
  createMany: jest.fn(),
389
391
  },
392
+ // ── #3591 — agents (auth) ────────────────────────────────────────────────
393
+ // Every call lives in `lib/agent-store.ts`: `findUnique` by credential
394
+ // digest (the auth preHandler's one lookup) or by id; `update` — the
395
+ // best-effort `lastActiveAt` bump; `create`/`updateMany`/`findMany` — the
396
+ // management functions, each scoped by the caller's organization id.
397
+ agent: {
398
+ findUnique: jest.fn(),
399
+ findMany: jest.fn(),
400
+ create: jest.fn(),
401
+ update: jest.fn(),
402
+ updateMany: jest.fn(),
403
+ },
390
404
  // ── Content namespace (UsageEvent, UsagePeriod — FEAT-047 P1.S1/#3709) ──
391
405
  usageEvent: {
392
406
  create: jest.fn(),
@@ -0,0 +1,468 @@
1
+ /**
2
+ * @file agent-store.test.ts
3
+ *
4
+ * `lib/agent-store.ts` — the agents table's ONE data-access boundary (#3591,
5
+ * spec docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md D1, D2, D12).
6
+ *
7
+ * Each function against the Prisma mock. What it pins:
8
+ * - the key: `ailk_agt_` + 43 base64url characters, a fresh one per call,
9
+ * stored only as its SHA-256 digest plus a 17-character prefix;
10
+ * - every read is keyed by a key's digest, an agent id, or an organization
11
+ * id the CALLER passes — never a scope of its own choosing;
12
+ * - `createAgent` derives all three references from its own reads, in one
13
+ * transaction, and refuses (not found) a workspace of another
14
+ * organization or a person with no membership there (SEC-3591-15);
15
+ * - re-keying and switching are scoped by organization: another
16
+ * organization's agent is "not found", and nothing is written.
17
+ *
18
+ * Test fixtures never hold a key-shaped literal: every `ailk_agt_` value is
19
+ * built at run time by `mintAgentKey()`, or the repo-wide secret scan flags it.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+
23
+ // 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.
24
+ jest.mock("../prisma.js");
25
+
26
+ import {
27
+ AGENT_KEY_PREFIX,
28
+ createAgent,
29
+ createServedAgent,
30
+ findAgentByKeyHash,
31
+ findAgentContext,
32
+ hashAgentKey,
33
+ listAgentsForOrganization,
34
+ mintAgentKey,
35
+ replaceAgentKey,
36
+ replaceServedAgentKey,
37
+ setAgentEnabled,
38
+ setServedAgentEnabled,
39
+ touchAgentLastActive,
40
+ } from "../agent-store.js";
41
+ import { prisma } from "../prisma.js";
42
+
43
+ const ORG_A = "org_00000000-0000-7000-8000-000000000aaa";
44
+ const ORG_B = "org_00000000-0000-7000-8000-000000000bbb";
45
+ const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
46
+ const WS_A2 = "wksp_00000000-0000-7000-8000-0000000000a2";
47
+ const WS_B1 = "wksp_00000000-0000-7000-8000-0000000000b1";
48
+ const OWNER = "user_00000000-0000-7000-8000-0000000000f1";
49
+ const PERSON = "user_00000000-0000-7000-8000-0000000000f2";
50
+ const OWNER_MEMBERSHIP = "member_00000000-0000-7000-8000-0000000000f1";
51
+ const PERSON_MEMBERSHIP = "member_00000000-0000-7000-8000-0000000000f2";
52
+ const AGENT = "agent_00000000-0000-7000-8000-0000000000c1";
53
+
54
+ const KEY_SHAPE = /^ailk_agt_[A-Za-z0-9_-]{43}$/;
55
+ const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
56
+
57
+ const WORKSPACES: Record<string, { id: string; organizationId: string }> = {
58
+ [WS_A1]: { id: WS_A1, organizationId: ORG_A },
59
+ [WS_A2]: { id: WS_A2, organizationId: ORG_A },
60
+ [WS_B1]: { id: WS_B1, organizationId: ORG_B },
61
+ };
62
+ const MEMBERSHIPS = [
63
+ { id: OWNER_MEMBERSHIP, organizationId: ORG_A, userId: OWNER },
64
+ { id: PERSON_MEMBERSHIP, organizationId: ORG_A, userId: PERSON },
65
+ ];
66
+
67
+ const AGENT_ROW = {
68
+ id: AGENT,
69
+ name: "Inbox helper",
70
+ organizationId: ORG_A,
71
+ workspaceId: WS_A1,
72
+ responsibleMembershipId: PERSON_MEMBERSHIP,
73
+ permissionLevel: "member",
74
+ enabled: true,
75
+ lastActiveAt: null,
76
+ createdAt: new Date("2026-10-04T00:00:00Z"),
77
+ credentialPrefix: "ailk_agt_abcdefgh",
78
+ createdByUserId: OWNER,
79
+ keyIssuedToMembershipId: OWNER_MEMBERSHIP,
80
+ };
81
+
82
+ function installTenancy(): void {
83
+ (prisma.workspace.findUnique as jest.Mock).mockImplementation(
84
+ ({ where }: { where: { id: string } }) => Promise.resolve(WORKSPACES[where.id] ?? null),
85
+ );
86
+ (prisma.membership.findUnique as jest.Mock).mockImplementation(
87
+ ({ where }: { where: { organizationId_userId?: { organizationId: string; userId: string } } }) => {
88
+ const key = where.organizationId_userId;
89
+ return Promise.resolve(
90
+ MEMBERSHIPS.find(
91
+ (m) => key && m.organizationId === key.organizationId && m.userId === key.userId,
92
+ ) ?? null,
93
+ );
94
+ },
95
+ );
96
+ // The interactive transaction runs its callback against the same mock.
97
+ (prisma.$transaction as jest.Mock).mockImplementation((fn: (tx: typeof prisma) => unknown) =>
98
+ fn(prisma),
99
+ );
100
+ }
101
+
102
+ beforeEach(() => {
103
+ jest.clearAllMocks();
104
+ installTenancy();
105
+ });
106
+
107
+ describe("the key (D2)", () => {
108
+ it("mints ailk_agt_ plus 32 random bytes in base64url, fresh every call", () => {
109
+ const a = mintAgentKey();
110
+ const b = mintAgentKey();
111
+ expect(a).toMatch(KEY_SHAPE);
112
+ expect(b).toMatch(KEY_SHAPE);
113
+ expect(a).not.toBe(b);
114
+ expect(AGENT_KEY_PREFIX).toBe("ailk_agt_");
115
+ });
116
+
117
+ it("hashes the TRIMMED key to lowercase hex SHA-256", () => {
118
+ const key = mintAgentKey();
119
+ expect(hashAgentKey(key)).toBe(sha256(key));
120
+ expect(hashAgentKey(` ${key}\n`)).toBe(sha256(key));
121
+ expect(hashAgentKey(key)).toMatch(/^[0-9a-f]{64}$/);
122
+ });
123
+ });
124
+
125
+ describe("findAgentByKeyHash", () => {
126
+ it("is one findUnique by digest, selecting what the guard needs and never the digest", async () => {
127
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue({
128
+ id: AGENT,
129
+ workspaceId: WS_A1,
130
+ organizationId: ORG_A,
131
+ permissionLevel: "member",
132
+ enabled: true,
133
+ keyIssuedToMembershipId: OWNER_MEMBERSHIP,
134
+ lastActiveAt: null,
135
+ responsible: { id: PERSON_MEMBERSHIP, userId: PERSON },
136
+ });
137
+ const hash = sha256("x");
138
+
139
+ const row = await findAgentByKeyHash(hash);
140
+
141
+ expect(prisma.agent.findUnique).toHaveBeenCalledTimes(1);
142
+ expect(prisma.agent.findUnique).toHaveBeenCalledWith({
143
+ where: { credentialHash: hash },
144
+ select: {
145
+ id: true,
146
+ workspaceId: true,
147
+ organizationId: true,
148
+ permissionLevel: true,
149
+ enabled: true,
150
+ keyIssuedToMembershipId: true,
151
+ lastActiveAt: true,
152
+ responsible: { select: { id: true, userId: true } },
153
+ },
154
+ });
155
+ expect(row).toEqual({
156
+ id: AGENT,
157
+ workspaceId: WS_A1,
158
+ organizationId: ORG_A,
159
+ permissionLevel: "member",
160
+ enabled: true,
161
+ keyIssuedToMembershipId: OWNER_MEMBERSHIP,
162
+ lastActiveAt: null,
163
+ responsible: { id: PERSON_MEMBERSHIP, userId: PERSON },
164
+ });
165
+ });
166
+
167
+ it("is null for an unknown digest", async () => {
168
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(null);
169
+ await expect(findAgentByKeyHash(sha256("nope"))).resolves.toBeNull();
170
+ });
171
+ });
172
+
173
+ describe("touchAgentLastActive", () => {
174
+ it("stamps lastActiveAt on the one agent, by id", async () => {
175
+ (prisma.agent.update as jest.Mock).mockResolvedValue({});
176
+ const at = new Date("2026-10-05T12:00:00Z");
177
+ await touchAgentLastActive(AGENT, at);
178
+ expect(prisma.agent.update).toHaveBeenCalledWith({
179
+ where: { id: AGENT },
180
+ data: { lastActiveAt: at },
181
+ select: { id: true },
182
+ });
183
+ });
184
+ });
185
+
186
+ describe("createAgent (D12 — the references always agree)", () => {
187
+ beforeEach(() => {
188
+ (prisma.agent.create as jest.Mock).mockImplementation(({ data }: { data: Record<string, unknown> }) =>
189
+ Promise.resolve({ ...AGENT_ROW, ...data, id: AGENT }),
190
+ );
191
+ });
192
+
193
+ it("binds to the target workspace, the person's membership and the creator's, and returns the key once", async () => {
194
+ const result = await createAgent({
195
+ organizationId: ORG_A,
196
+ workspaceId: WS_A2,
197
+ responsibleUserId: PERSON,
198
+ createdByUserId: OWNER,
199
+ name: "Inbox helper",
200
+ });
201
+
202
+ expect(result.ok).toBe(true);
203
+ if (!result.ok) return;
204
+ expect(result.key).toMatch(KEY_SHAPE);
205
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1);
206
+ const { data, select } = (prisma.agent.create as jest.Mock).mock.calls[0][0];
207
+ expect(data).toEqual({
208
+ organizationId: ORG_A,
209
+ workspaceId: WS_A2,
210
+ responsibleMembershipId: PERSON_MEMBERSHIP,
211
+ keyIssuedToMembershipId: OWNER_MEMBERSHIP,
212
+ createdByUserId: OWNER,
213
+ name: "Inbox helper",
214
+ credentialHash: sha256(result.key),
215
+ credentialPrefix: result.key.slice(0, 17),
216
+ });
217
+ // The digest is written, never selected back; the key is never stored.
218
+ expect(select).not.toHaveProperty("credentialHash");
219
+ expect(JSON.stringify(data)).not.toContain(result.key);
220
+ expect(result.agent.workspaceId).toBe(WS_A2);
221
+ });
222
+
223
+ it("reads the organization from the workspace row, never from the caller alone", async () => {
224
+ const result = await createAgent({
225
+ organizationId: ORG_A,
226
+ workspaceId: WS_B1,
227
+ responsibleUserId: PERSON,
228
+ createdByUserId: OWNER,
229
+ name: "Intruder",
230
+ });
231
+ expect(result).toEqual({ ok: false, reason: "not-found" });
232
+ expect(prisma.agent.create).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it.each([
236
+ ["an unknown workspace", { workspaceId: "wksp_00000000-0000-7000-8000-00000000dead" }],
237
+ ["a person with no membership in the organization", { responsibleUserId: "user_00000000-0000-7000-8000-00000000dead" }],
238
+ ["a creator with no membership in the organization", { createdByUserId: "user_00000000-0000-7000-8000-00000000dead" }],
239
+ ])("is not-found for %s, and writes nothing", async (_label, override) => {
240
+ const result = await createAgent({
241
+ organizationId: ORG_A,
242
+ workspaceId: WS_A1,
243
+ responsibleUserId: PERSON,
244
+ createdByUserId: OWNER,
245
+ name: "Helper",
246
+ ...override,
247
+ });
248
+ expect(result).toEqual({ ok: false, reason: "not-found" });
249
+ expect(prisma.agent.create).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("looks the person up by the organization's compound key, not by any id the caller names", async () => {
253
+ await createAgent({
254
+ organizationId: ORG_A,
255
+ workspaceId: WS_A1,
256
+ responsibleUserId: PERSON,
257
+ createdByUserId: OWNER,
258
+ name: "Helper",
259
+ });
260
+ expect(prisma.membership.findUnique).toHaveBeenCalledWith(
261
+ expect.objectContaining({
262
+ where: { organizationId_userId: { organizationId: ORG_A, userId: PERSON } },
263
+ }),
264
+ );
265
+ });
266
+ });
267
+
268
+ describe("replaceAgentKey (D2)", () => {
269
+ it("writes a new digest and prefix and records who was shown the key, scoped by organization", async () => {
270
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
271
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(AGENT_ROW);
272
+
273
+ const result = await replaceAgentKey({ organizationId: ORG_A, agentId: AGENT, issuedByUserId: OWNER });
274
+
275
+ expect(result.ok).toBe(true);
276
+ if (!result.ok) return;
277
+ expect(result.key).toMatch(KEY_SHAPE);
278
+ expect(prisma.agent.updateMany).toHaveBeenCalledWith({
279
+ where: { id: AGENT, organizationId: ORG_A },
280
+ data: {
281
+ credentialHash: sha256(result.key),
282
+ credentialPrefix: result.key.slice(0, 17),
283
+ keyIssuedToMembershipId: OWNER_MEMBERSHIP,
284
+ },
285
+ });
286
+ expect(result.agent.id).toBe(AGENT);
287
+ });
288
+
289
+ it("is not-found for another organization's agent", async () => {
290
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
291
+ const result = await replaceAgentKey({ organizationId: ORG_B, agentId: AGENT, issuedByUserId: OWNER });
292
+ expect(result).toEqual({ ok: false, reason: "not-found" });
293
+ });
294
+
295
+ it("is not-found when the re-keying person holds no membership in the organization", async () => {
296
+ const result = await replaceAgentKey({
297
+ organizationId: ORG_A,
298
+ agentId: AGENT,
299
+ issuedByUserId: "user_00000000-0000-7000-8000-00000000dead",
300
+ });
301
+ expect(result).toEqual({ ok: false, reason: "not-found" });
302
+ expect(prisma.agent.updateMany).not.toHaveBeenCalled();
303
+ });
304
+ });
305
+
306
+ describe("setAgentEnabled", () => {
307
+ it("switches the agent, scoped by organization", async () => {
308
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
309
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue({ ...AGENT_ROW, enabled: false });
310
+
311
+ const agent = await setAgentEnabled({ organizationId: ORG_A, agentId: AGENT, enabled: false });
312
+
313
+ expect(prisma.agent.updateMany).toHaveBeenCalledWith({
314
+ where: { id: AGENT, organizationId: ORG_A },
315
+ data: { enabled: false },
316
+ });
317
+ expect(agent?.enabled).toBe(false);
318
+ });
319
+
320
+ it("is null for another organization's agent", async () => {
321
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
322
+ await expect(
323
+ setAgentEnabled({ organizationId: ORG_B, agentId: AGENT, enabled: false }),
324
+ ).resolves.toBeNull();
325
+ expect(prisma.agent.findUnique).not.toHaveBeenCalled();
326
+ });
327
+ });
328
+
329
+ // ── The management routes' writes — the row they serve, from one transaction ──
330
+
331
+ describe("the served writes (route-facing)", () => {
332
+ const LIST_ROW = {
333
+ ...AGENT_ROW,
334
+ responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
335
+ keyIssuedTo: { userId: OWNER, user: { name: "Olive" } },
336
+ };
337
+
338
+ beforeEach(() => {
339
+ (prisma.agent.findMany as jest.Mock).mockResolvedValue([LIST_ROW]);
340
+ (prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: OWNER, name: "Olive" }]);
341
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
342
+ (prisma.agent.create as jest.Mock).mockImplementation(({ data }: { data: Record<string, unknown> }) =>
343
+ Promise.resolve({ ...AGENT_ROW, ...data, id: AGENT }),
344
+ );
345
+ });
346
+
347
+ it("each runs its write and its list-row read in one transaction, and serves the list row", async () => {
348
+ const created = await createServedAgent({
349
+ organizationId: ORG_A,
350
+ workspaceId: WS_A1,
351
+ responsibleUserId: PERSON,
352
+ createdByUserId: OWNER,
353
+ name: "Inbox helper",
354
+ });
355
+ const rekeyed = await replaceServedAgentKey({
356
+ organizationId: ORG_A,
357
+ agentId: AGENT,
358
+ issuedByUserId: OWNER,
359
+ });
360
+ const switched = await setServedAgentEnabled({
361
+ organizationId: ORG_A,
362
+ agentId: AGENT,
363
+ enabled: false,
364
+ });
365
+
366
+ expect(prisma.$transaction).toHaveBeenCalledTimes(3);
367
+ for (const result of [created, rekeyed, switched]) {
368
+ expect(result).toMatchObject({
369
+ ok: true,
370
+ agent: { id: AGENT, responsible: { userId: PERSON, email: "pat@example.test" } },
371
+ });
372
+ }
373
+ expect(created.ok && created.key).toMatch(KEY_SHAPE);
374
+ expect(rekeyed.ok && rekeyed.key).toMatch(KEY_SHAPE);
375
+ for (const call of (prisma.agent.findMany as jest.Mock).mock.calls) {
376
+ expect(call[0].where).toEqual({ id: AGENT, organizationId: ORG_A });
377
+ }
378
+ });
379
+
380
+ it("re-key and switch match the agent by organization and id only, whichever workspace it is bound to", async () => {
381
+ await replaceServedAgentKey({ organizationId: ORG_A, agentId: AGENT, issuedByUserId: OWNER });
382
+ await setServedAgentEnabled({ organizationId: ORG_A, agentId: AGENT, enabled: true });
383
+
384
+ for (const call of (prisma.agent.updateMany as jest.Mock).mock.calls) {
385
+ expect(call[0].where).toEqual({ id: AGENT, organizationId: ORG_A });
386
+ }
387
+ });
388
+
389
+ it("another organization's agent is not-found and no row is read", async () => {
390
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
391
+
392
+ await expect(
393
+ replaceServedAgentKey({ organizationId: ORG_B, agentId: AGENT, issuedByUserId: OWNER }),
394
+ ).resolves.toEqual({ ok: false, reason: "not-found" });
395
+ await expect(
396
+ setServedAgentEnabled({ organizationId: ORG_B, agentId: AGENT, enabled: false }),
397
+ ).resolves.toEqual({ ok: false, reason: "not-found" });
398
+ expect(prisma.agent.findMany).not.toHaveBeenCalled();
399
+ });
400
+ });
401
+
402
+ describe("listAgentsForOrganization", () => {
403
+ beforeEach(() => {
404
+ (prisma.agent.findMany as jest.Mock).mockResolvedValue([
405
+ {
406
+ ...AGENT_ROW,
407
+ responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
408
+ keyIssuedTo: { userId: OWNER, user: { name: "Olive" } },
409
+ },
410
+ ]);
411
+ (prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: OWNER, name: "Olive" }]);
412
+ });
413
+
414
+ it("lists the organization's agents, never selecting the digest", async () => {
415
+ const rows = await listAgentsForOrganization(ORG_A);
416
+ const args = (prisma.agent.findMany as jest.Mock).mock.calls[0][0];
417
+ expect(args.where).toEqual({ organizationId: ORG_A });
418
+ expect(args.select).not.toHaveProperty("credentialHash");
419
+ expect(rows).toEqual([
420
+ {
421
+ id: AGENT,
422
+ name: "Inbox helper",
423
+ workspaceId: WS_A1,
424
+ responsible: { userId: PERSON, name: "Pat", email: "pat@example.test" },
425
+ permissionLevel: "member",
426
+ enabled: true,
427
+ lastActiveAt: null,
428
+ createdAt: AGENT_ROW.createdAt,
429
+ keyPrefix: "ailk_agt_abcdefgh",
430
+ createdBy: { userId: OWNER, name: "Olive" },
431
+ keyIssuedTo: { userId: OWNER, name: "Olive" },
432
+ },
433
+ ]);
434
+ });
435
+
436
+ it("narrows to one person's agents inside the same organization", async () => {
437
+ await listAgentsForOrganization(ORG_A, PERSON);
438
+ expect((prisma.agent.findMany as jest.Mock).mock.calls[0][0].where).toEqual({
439
+ organizationId: ORG_A,
440
+ responsible: { userId: PERSON },
441
+ });
442
+ });
443
+ });
444
+
445
+ describe("findAgentContext", () => {
446
+ it("reads one agent by id with its person's name and address", async () => {
447
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue({
448
+ id: AGENT,
449
+ name: "Inbox helper",
450
+ workspaceId: WS_A1,
451
+ permissionLevel: "member",
452
+ responsible: { user: { name: "Pat", email: "pat@example.test" } },
453
+ });
454
+
455
+ const context = await findAgentContext(AGENT);
456
+
457
+ expect((prisma.agent.findUnique as jest.Mock).mock.calls[0][0].where).toEqual({ id: AGENT });
458
+ expect(context).toEqual({
459
+ agent: { id: AGENT, name: "Inbox helper", workspaceId: WS_A1, permissionLevel: "member" },
460
+ responsible: { name: "Pat", email: "pat@example.test" },
461
+ });
462
+ });
463
+
464
+ it("is null for an unknown agent", async () => {
465
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(null);
466
+ await expect(findAgentContext(AGENT)).resolves.toBeNull();
467
+ });
468
+ });
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * The hub's spoke registry boundary (#7532, spec
5
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.
6
+ * its digest, the two reads the resolver makes (the key lookup, and whether
7
+ * the deployment is a hub — #7633 D1), and the three operator functions the
8
+ * `spokes` CLI calls.
8
9
  *
9
10
  * External dependency boundary mocked:
10
11
  * - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
@@ -19,6 +20,7 @@ import { prisma } from "../prisma.js";
19
20
  import {
20
21
  SPOKE_KEY_PREFIX,
21
22
  findSpokeByKeyHash,
23
+ hasRegisteredSpoke,
22
24
  hashSpokeKey,
23
25
  listSpokes,
24
26
  mintSpokeKey,
@@ -77,6 +79,34 @@ describe("findSpokeByKeyHash (D3 step 5)", () => {
77
79
  });
78
80
  });
79
81
 
82
+ describe("hasRegisteredSpoke (#7633 D1, AC-5)", () => {
83
+ it("is true when the registry holds a row, revoked or not — one findFirst with no where", async () => {
84
+ db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
85
+
86
+ await expect(hasRegisteredSpoke()).resolves.toBe(true);
87
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(1);
88
+ expect(db.spoke.findFirst).toHaveBeenCalledWith({ select: { id: true } });
89
+ });
90
+
91
+ it("is false when the registry is empty", async () => {
92
+ db.spoke.findFirst.mockResolvedValue(null);
93
+ await expect(hasRegisteredSpoke()).resolves.toBe(false);
94
+ expect(db.spoke.findFirst).toHaveBeenCalledWith({ select: { id: true } });
95
+ });
96
+
97
+ it("caches nothing: two calls are two reads", async () => {
98
+ db.spoke.findFirst.mockResolvedValue(null);
99
+ await hasRegisteredSpoke();
100
+ await hasRegisteredSpoke();
101
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(2);
102
+ });
103
+
104
+ it("propagates a failed read — never answers \"not a hub\"", async () => {
105
+ db.spoke.findFirst.mockRejectedValue(new Error("connection reset"));
106
+ await expect(hasRegisteredSpoke()).rejects.toThrow("connection reset");
107
+ });
108
+ });
109
+
80
110
  describe("registerSpoke (D7, AC-5)", () => {
81
111
  it("stores the key's digest and a 17-character prefix, never the key", async () => {
82
112
  db.workspace.findUnique.mockResolvedValue({ id: W1 } as never);