create-ailk 0.14.0 → 0.16.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 (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -60,8 +60,14 @@
60
60
  * userData.create/updateMany (auth) + siteSetting.findUnique (config) — the
61
61
  * dunning record's compare-and-swap write and the per-tenant grace-policy
62
62
  * read (#6034), used by dunning-policy.test.ts and stripe-dunning.test.ts.
63
+ *
64
+ * person (inbox) + flowCheckout.findFirst — the Person step every lead
65
+ * submit, appointment create and hub checkout now runs (#852 D13).
63
66
  */
64
67
 
68
+ /** The Person every lead written through this mock is stamped with (#852 D13). */
69
+ export const MOCK_PERSON_ID = "person_00000000-0000-7000-8000-000000000852";
70
+
65
71
  export const prisma = {
66
72
  // ── Auth namespace ──────────────────────────────────────────────────────────
67
73
  user: {
@@ -70,12 +76,30 @@ export const prisma = {
70
76
  // #3591 — `lib/agent-store.ts`'s list names each agent's creator by id.
71
77
  findMany: jest.fn(),
72
78
  update: jest.fn(),
79
+ // #7803 — `activateWorkspace` records the remembered workspace on the
80
+ // caller's own row (tenant-context.ts, spec D3).
81
+ updateMany: jest.fn(),
82
+ create: jest.fn(),
83
+ },
84
+ // #7803 — `activateWorkspace` writes the session's active-workspace pointer
85
+ // in one `$transaction` with the `user.updateMany` above.
86
+ session: {
87
+ update: jest.fn(),
88
+ // #7894 — the hub-sign-in suites assert the token route NEVER mints a
89
+ // Better Auth session (`create`), and that the preHandler's cookie path
90
+ // sees none for a hub credential (`findUnique`).
73
91
  create: jest.fn(),
92
+ findUnique: jest.fn(),
74
93
  },
75
94
  apiToken: {
76
95
  create: jest.fn(),
77
96
  findMany: jest.fn(),
78
97
  updateMany: jest.fn(),
98
+ // #7894 — the auth preHandler's bearer lookup and its best-effort
99
+ // `lastUsedAt` bump; the hub-sign-in route suite sends a personal token to
100
+ // the codes route to prove it mints nothing.
101
+ findUnique: jest.fn(),
102
+ update: jest.fn(),
79
103
  },
80
104
  userData: {
81
105
  findUnique: jest.fn(),
@@ -210,6 +234,8 @@ export const prisma = {
210
234
  findFirst: jest.fn(),
211
235
  findMany: jest.fn(),
212
236
  upsert: jest.fn(),
237
+ // #7844 D7 — `copyFlowDefinition`'s one create-only write.
238
+ create: jest.fn(),
213
239
  },
214
240
  // `deleteMany` — used by the FlowSession retention/cleanup sweep
215
241
  // (flow-engine.ts's `cleanupAbandonedSessions`, #3974 AC3).
@@ -253,6 +279,58 @@ export const prisma = {
253
279
  updateMany: jest.fn(),
254
280
  findUnique: jest.fn(),
255
281
  findMany: jest.fn(),
282
+ // #852 D6 — `recordCheckoutPerson`'s read of the row this session completed.
283
+ findFirst: jest.fn(),
284
+ },
285
+ // ── Inbox namespace (Person — #852 D13) ──────────────────────────────────
286
+ // Every call lives in services/person.ts. `findFirst`'s DEFAULT resolves
287
+ // one fixed `person_` row, so every suite that reaches `persistToDb`
288
+ // through this mock stamps `MOCK_PERSON_ID` on its lead without arranging
289
+ // anything. A suite that wants another answer overrides it per test.
290
+ person: {
291
+ findFirst: jest.fn(async () => ({
292
+ id: MOCK_PERSON_ID,
293
+ email: "mock@example.com",
294
+ name: null,
295
+ userId: null,
296
+ billingCustomerId: null,
297
+ })),
298
+ findMany: jest.fn(),
299
+ createMany: jest.fn(async () => ({ count: 0 })),
300
+ updateMany: jest.fn(async () => ({ count: 0 })),
301
+ count: jest.fn(),
302
+ },
303
+ // #7877 D17 — the member area's pieces and outbound messages. Calls live in
304
+ // lib/tenant-db.ts (`memberArea`), lib/member-store.ts and, for
305
+ // `interaction.count`, lib/asset-store.ts. Each defaults to an empty result.
306
+ piece: {
307
+ findMany: jest.fn(async () => []),
308
+ findFirst: jest.fn(async () => null),
309
+ create: jest.fn(),
310
+ update: jest.fn(),
311
+ updateMany: jest.fn(async () => ({ count: 0 })),
312
+ upsert: jest.fn(),
313
+ count: jest.fn(async () => 0),
314
+ },
315
+ interaction: {
316
+ findMany: jest.fn(async () => []),
317
+ findFirst: jest.fn(async () => null),
318
+ create: jest.fn(),
319
+ update: jest.fn(),
320
+ updateMany: jest.fn(async () => ({ count: 0 })),
321
+ upsert: jest.fn(),
322
+ count: jest.fn(async () => 0),
323
+ },
324
+ // ── Config namespace (WorkspacePieceStages — #7877 D17) ──────────────────
325
+ workspacePieceStages: {
326
+ findMany: jest.fn(async () => []),
327
+ findFirst: jest.fn(async () => null),
328
+ findUnique: jest.fn(async () => null),
329
+ create: jest.fn(),
330
+ update: jest.fn(),
331
+ updateMany: jest.fn(async () => ({ count: 0 })),
332
+ upsert: jest.fn(),
333
+ count: jest.fn(async () => 0),
256
334
  },
257
335
  // ── Config namespace (SiteListingConfig — #5033) ─────────────────────────
258
336
  // `findUnique` — the ONLY read path (`listingConfigFor`), keyed on siteId.
@@ -389,6 +467,27 @@ export const prisma = {
389
467
  findUnique: jest.fn(),
390
468
  createMany: jest.fn(),
391
469
  },
470
+ // ── #7894 — the hub signs in its spokes (auth) ─────────────────────────────
471
+ // Every call lives in `lib/hub-sign-in-store.ts`: `oAuthClient` — a spoke's
472
+ // sign-in client (read by the codes/token/revoke service; upsert/updateMany
473
+ // from the operator CLI); `oAuthAuthorization` — the credential grant
474
+ // (upsert on exchange, updateMany on revoke, findUnique in the preHandler);
475
+ // `verification` — the short-lived authorization-code rows.
476
+ oAuthClient: {
477
+ findUnique: jest.fn(),
478
+ upsert: jest.fn(),
479
+ updateMany: jest.fn(),
480
+ },
481
+ oAuthAuthorization: {
482
+ findUnique: jest.fn(),
483
+ upsert: jest.fn(),
484
+ updateMany: jest.fn(),
485
+ },
486
+ verification: {
487
+ create: jest.fn(),
488
+ findFirst: jest.fn(),
489
+ deleteMany: jest.fn(),
490
+ },
392
491
  // ── #3591 — agents (auth) ────────────────────────────────────────────────
393
492
  // Every call lives in `lib/agent-store.ts`: `findUnique` by credential
394
493
  // digest (the auth preHandler's one lookup) or by id; `update` — the
@@ -13,7 +13,10 @@
13
13
  * transaction, and refuses (not found) a workspace of another
14
14
  * organization or a person with no membership there (SEC-3591-15);
15
15
  * - re-keying and switching are scoped by organization: another
16
- * organization's agent is "not found", and nothing is written.
16
+ * organization's agent is "not found", and nothing is written;
17
+ * - setting grants (#7864 D4, AC-4) collapses duplicates into D1 order,
18
+ * refuses a grant its person's role does not hold, and is scoped by
19
+ * organization like the other writes.
17
20
  *
18
21
  * Test fixtures never hold a key-shaped literal: every `ailk_agt_` value is
19
22
  * built at run time by `mintAgentKey()`, or the repo-wide secret scan flags it.
@@ -36,6 +39,7 @@ import {
36
39
  replaceServedAgentKey,
37
40
  setAgentEnabled,
38
41
  setServedAgentEnabled,
42
+ setServedAgentGrants,
39
43
  touchAgentLastActive,
40
44
  } from "../agent-store.js";
41
45
  import { prisma } from "../prisma.js";
@@ -71,6 +75,7 @@ const AGENT_ROW = {
71
75
  workspaceId: WS_A1,
72
76
  responsibleMembershipId: PERSON_MEMBERSHIP,
73
77
  permissionLevel: "member",
78
+ grants: [] as string[],
74
79
  enabled: true,
75
80
  lastActiveAt: null,
76
81
  createdAt: new Date("2026-10-04T00:00:00Z"),
@@ -129,6 +134,7 @@ describe("findAgentByKeyHash", () => {
129
134
  workspaceId: WS_A1,
130
135
  organizationId: ORG_A,
131
136
  permissionLevel: "member",
137
+ grants: ["content-edit"],
132
138
  enabled: true,
133
139
  keyIssuedToMembershipId: OWNER_MEMBERSHIP,
134
140
  lastActiveAt: null,
@@ -146,6 +152,7 @@ describe("findAgentByKeyHash", () => {
146
152
  workspaceId: true,
147
153
  organizationId: true,
148
154
  permissionLevel: true,
155
+ grants: true,
149
156
  enabled: true,
150
157
  keyIssuedToMembershipId: true,
151
158
  lastActiveAt: true,
@@ -157,6 +164,7 @@ describe("findAgentByKeyHash", () => {
157
164
  workspaceId: WS_A1,
158
165
  organizationId: ORG_A,
159
166
  permissionLevel: "member",
167
+ grants: ["content-edit"],
160
168
  enabled: true,
161
169
  keyIssuedToMembershipId: OWNER_MEMBERSHIP,
162
170
  lastActiveAt: null,
@@ -399,6 +407,110 @@ describe("the served writes (route-facing)", () => {
399
407
  });
400
408
  });
401
409
 
410
+ describe("setServedAgentGrants (#7864 D4, AC-4)", () => {
411
+ /** The row the transaction reads: the agent with its person's role. */
412
+ function agentWithRole(role: string, grants: string[] = []) {
413
+ return { organizationId: ORG_A, grants, responsible: { role } };
414
+ }
415
+ let listGrants: string[];
416
+
417
+ beforeEach(() => {
418
+ listGrants = [];
419
+ (prisma.agent.updateMany as jest.Mock).mockImplementation(
420
+ ({ data }: { data: { grants: string[] } }) => {
421
+ listGrants = data.grants;
422
+ return Promise.resolve({ count: 1 });
423
+ },
424
+ );
425
+ (prisma.agent.findMany as jest.Mock).mockImplementation(() =>
426
+ Promise.resolve([
427
+ {
428
+ ...AGENT_ROW,
429
+ grants: listGrants,
430
+ responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
431
+ keyIssuedTo: { userId: OWNER, user: { name: "Olive" } },
432
+ },
433
+ ]),
434
+ );
435
+ (prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: OWNER, name: "Olive" }]);
436
+ });
437
+
438
+ it("writes the set in D1 order with duplicates collapsed, in one transaction, and serves the list row", async () => {
439
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(agentWithRole("admin", ["assets-write"]));
440
+
441
+ const result = await setServedAgentGrants({
442
+ organizationId: ORG_A,
443
+ agentId: AGENT,
444
+ grants: ["magnets-manage", "content-edit", "magnets-manage"],
445
+ });
446
+
447
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1);
448
+ expect(prisma.agent.findUnique).toHaveBeenCalledWith({
449
+ where: { id: AGENT, organizationId: ORG_A },
450
+ select: { organizationId: true, grants: true, responsible: { select: { role: true } } },
451
+ });
452
+ expect(prisma.agent.updateMany).toHaveBeenCalledWith({
453
+ where: { id: AGENT, organizationId: ORG_A },
454
+ data: { grants: ["content-edit", "magnets-manage"] },
455
+ });
456
+ expect(result).toMatchObject({
457
+ ok: true,
458
+ agent: { id: AGENT, grants: ["content-edit", "magnets-manage"] },
459
+ previous: ["assets-write"],
460
+ });
461
+ });
462
+
463
+ it("an empty set removes every grant", async () => {
464
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(
465
+ agentWithRole("owner", ["content-edit", "content-publish"]),
466
+ );
467
+ const result = await setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: [] });
468
+ expect(prisma.agent.updateMany).toHaveBeenCalledWith({
469
+ where: { id: AGENT, organizationId: ORG_A },
470
+ data: { grants: [] },
471
+ });
472
+ expect(result).toMatchObject({ ok: true, agent: { grants: [] }, previous: ["content-edit", "content-publish"] });
473
+ });
474
+
475
+ it("refuses a grant its person's role does not hold, and writes nothing", async () => {
476
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(agentWithRole("member"));
477
+ await expect(
478
+ setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
479
+ ).resolves.toEqual({ ok: false, reason: "exceeds-person" });
480
+ expect(prisma.agent.updateMany).not.toHaveBeenCalled();
481
+ expect(prisma.agent.findMany).not.toHaveBeenCalled();
482
+ });
483
+
484
+ it("is not-found for an unknown agent or one of another organization, and writes nothing", async () => {
485
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValueOnce(null);
486
+ await expect(
487
+ setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
488
+ ).resolves.toEqual({ ok: false, reason: "not-found" });
489
+
490
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValueOnce({
491
+ ...agentWithRole("owner"),
492
+ organizationId: ORG_B,
493
+ });
494
+ await expect(
495
+ setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
496
+ ).resolves.toEqual({ ok: false, reason: "not-found" });
497
+ expect(prisma.agent.updateMany).not.toHaveBeenCalled();
498
+ });
499
+
500
+ it("the list row serves only the five, in D1 order, whatever the row holds", async () => {
501
+ (prisma.agent.findMany as jest.Mock).mockResolvedValue([
502
+ {
503
+ ...AGENT_ROW,
504
+ grants: ["meeting-types-manage", "content-edit", "agents-manage"],
505
+ responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
506
+ keyIssuedTo: null,
507
+ },
508
+ ]);
509
+ const [row] = await listAgentsForOrganization(ORG_A);
510
+ expect(row?.grants).toEqual(["content-edit", "meeting-types-manage"]);
511
+ });
512
+ });
513
+
402
514
  describe("listAgentsForOrganization", () => {
403
515
  beforeEach(() => {
404
516
  (prisma.agent.findMany as jest.Mock).mockResolvedValue([
@@ -423,6 +535,7 @@ describe("listAgentsForOrganization", () => {
423
535
  workspaceId: WS_A1,
424
536
  responsible: { userId: PERSON, name: "Pat", email: "pat@example.test" },
425
537
  permissionLevel: "member",
538
+ grants: [],
426
539
  enabled: true,
427
540
  lastActiveAt: null,
428
541
  createdAt: AGENT_ROW.createdAt,
@@ -21,20 +21,46 @@ const ASSET_ID = "asset_00000000-0000-7000-8000-0000000000a1";
21
21
  describe("findDeliverableAssetIdByKey", () => {
22
22
  beforeEach(() => {
23
23
  jest.clearAllMocks();
24
+ (prisma.interaction.count as jest.Mock).mockResolvedValue(0);
24
25
  });
25
26
 
26
27
  it("queries with exactly { workspaceId, key }", async () => {
27
- (prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: ASSET_ID });
28
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: ASSET_ID, tags: [] });
28
29
 
29
30
  const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "q3-report.pdf");
30
31
 
31
32
  expect(prisma.asset.findFirst).toHaveBeenCalledWith({
32
33
  where: { workspaceId: WORKSPACE_ID, key: "q3-report.pdf" },
33
- select: { id: true },
34
+ select: { id: true, tags: true },
34
35
  });
35
36
  expect(result).toBe(ASSET_ID);
36
37
  });
37
38
 
39
+ // SEC-7877-A: a client file is never a magnet deliverable — a `private` key
40
+ // resolves to nothing, the same as a miss (#7877 security review SR-7877-1).
41
+ it("returns null for a private-tagged asset", async () => {
42
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({
43
+ id: ASSET_ID,
44
+ tags: ["private"],
45
+ });
46
+
47
+ const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "member/scan.pdf");
48
+
49
+ expect(result).toBeNull();
50
+ });
51
+
52
+ it("returns null for an asset a message attaches", async () => {
53
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: ASSET_ID, tags: [] });
54
+ (prisma.interaction.count as jest.Mock).mockResolvedValue(1);
55
+
56
+ const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "member/scan.pdf");
57
+
58
+ expect(prisma.interaction.count).toHaveBeenCalledWith({
59
+ where: { assetId: ASSET_ID, workspaceId: WORKSPACE_ID },
60
+ });
61
+ expect(result).toBeNull();
62
+ });
63
+
38
64
  it("does not return a same-key row scoped to a different workspace", async () => {
39
65
  // The mock proves the CALL is scoped correctly; a real DB enforces the
40
66
  // rest. This asserts the where-clause never widens to a foreign
@@ -48,7 +74,7 @@ describe("findDeliverableAssetIdByKey", () => {
48
74
 
49
75
  expect(prisma.asset.findFirst).toHaveBeenCalledWith({
50
76
  where: { workspaceId: OTHER_WORKSPACE_ID, key: "q3-report.pdf" },
51
- select: { id: true },
77
+ select: { id: true, tags: true },
52
78
  });
53
79
  expect(result).toBeNull();
54
80
  });
@@ -65,6 +91,7 @@ describe("findDeliverableAssetIdByKey", () => {
65
91
  describe("readDeliverableAsset", () => {
66
92
  beforeEach(() => {
67
93
  jest.clearAllMocks();
94
+ (prisma.interaction.count as jest.Mock).mockResolvedValue(0);
68
95
  });
69
96
 
70
97
  it("queries with exactly { id, workspaceId } and selects filename/contentType/bytes", async () => {
@@ -72,13 +99,14 @@ describe("readDeliverableAsset", () => {
72
99
  filename: "q3-report.pdf",
73
100
  contentType: "application/pdf",
74
101
  bytes: Buffer.from("PDF-BYTES"),
102
+ tags: [],
75
103
  });
76
104
 
77
105
  const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
78
106
 
79
107
  expect(prisma.asset.findFirst).toHaveBeenCalledWith({
80
108
  where: { id: ASSET_ID, workspaceId: WORKSPACE_ID },
81
- select: { filename: true, contentType: true, bytes: true },
109
+ select: { filename: true, contentType: true, bytes: true, tags: true },
82
110
  });
83
111
  expect(result).toEqual({
84
112
  filename: "q3-report.pdf",
@@ -95,12 +123,45 @@ describe("readDeliverableAsset", () => {
95
123
  expect(result).toBeNull();
96
124
  });
97
125
 
126
+ // SEC-7877-A: a grant minted before the asset became a client file still
127
+ // never serves it (#7877 security review SR-7877-1).
128
+ it("returns null for a private-tagged asset", async () => {
129
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({
130
+ filename: "scan.pdf",
131
+ contentType: "application/pdf",
132
+ bytes: Buffer.from("PDF-BYTES"),
133
+ tags: ["private"],
134
+ });
135
+
136
+ const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
137
+
138
+ expect(result).toBeNull();
139
+ });
140
+
141
+ it("returns null for an asset a message attaches", async () => {
142
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({
143
+ filename: "scan.pdf",
144
+ contentType: "application/pdf",
145
+ bytes: Buffer.from("PDF-BYTES"),
146
+ tags: [],
147
+ });
148
+ (prisma.interaction.count as jest.Mock).mockResolvedValue(1);
149
+
150
+ const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
151
+
152
+ expect(prisma.interaction.count).toHaveBeenCalledWith({
153
+ where: { assetId: ASSET_ID, workspaceId: WORKSPACE_ID },
154
+ });
155
+ expect(result).toBeNull();
156
+ });
157
+
98
158
  it("wraps the returned bytes as a Buffer view, not a copy", async () => {
99
159
  const raw = new Uint8Array([1, 2, 3, 4]);
100
160
  (prisma.asset.findFirst as jest.Mock).mockResolvedValue({
101
161
  filename: "f.pdf",
102
162
  contentType: "application/pdf",
103
163
  bytes: raw,
164
+ tags: [],
104
165
  });
105
166
 
106
167
  const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
@@ -116,7 +177,7 @@ describe("module boundary contract (§1.14 / D16)", () => {
116
177
  // The boundary's entire contract is "two named functions, nothing else
117
178
  // reads prisma.asset" — asserted at the module surface so a future edit
118
179
  // adding a third export or a raw prisma re-export fails this test.
119
- // eslint-disable-next-line @typescript-eslint/no-var-requires -- asserting the module's own export surface
180
+
120
181
  const mod = require("../deliverable-asset-store");
121
182
  expect(Object.keys(mod).sort()).toEqual(
122
183
  ["findDeliverableAssetIdByKey", "readDeliverableAsset"].sort(),
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @file hub-sign-in-store.test.ts
3
+ *
4
+ * The sign-in-client store's operator half (#7894, spec
5
+ * docs/specs/7894-the-hub-signs-in-its-spec.md D3; AC-5): the callback-URL
6
+ * validator and `registerSignInClient`, which the `spokes sign-in-client` CLI
7
+ * calls. The code, grant and lookup functions are exercised through the
8
+ * service and preHandler suites, which run them end to end.
9
+ *
10
+ * External dependency boundary mocked:
11
+ * - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
12
+ */
13
+
14
+ // 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.
15
+ jest.mock("../prisma.js");
16
+
17
+ import {
18
+ installHubSignInFakeDb,
19
+ seedSpoke,
20
+ type FakeHubDb,
21
+ } from "../../__tests__/support/hub-sign-in-fake-db.js";
22
+ import {
23
+ canonicalRedirectUri,
24
+ disableSignInClient,
25
+ HUB_CLIENT_SECRET_PREFIX,
26
+ mintClientSecret,
27
+ mintCode,
28
+ mintCredential,
29
+ registerSignInClient,
30
+ sha256Hex,
31
+ } from "../hub-sign-in-store.js";
32
+ import { prisma } from "../prisma.js";
33
+
34
+ const db = jest.mocked(prisma);
35
+
36
+ let state: FakeHubDb;
37
+
38
+ beforeEach(() => {
39
+ jest.resetAllMocks();
40
+ state = installHubSignInFakeDb(db);
41
+ });
42
+
43
+ describe("minting", () => {
44
+ it("mints the three secrets as 32 random bytes in base64url, each with its own prefix", () => {
45
+ expect(mintCode()).toMatch(/^[A-Za-z0-9_-]{43}$/);
46
+ expect(mintCredential()).toMatch(/^ailk_hst_[A-Za-z0-9_-]{43}$/);
47
+ expect(mintClientSecret()).toMatch(/^ailk_hcs_[A-Za-z0-9_-]{43}$/);
48
+ expect(new Set(Array.from({ length: 50 }, mintCode)).size).toBe(50);
49
+ });
50
+ });
51
+
52
+ describe("canonicalRedirectUri (D3)", () => {
53
+ it.each([
54
+ [
55
+ "https://site.example.com/api/hub-session/callback",
56
+ "https://site.example.com/api/hub-session/callback",
57
+ ],
58
+ [
59
+ "https://SITE.example.com:443/api/hub-session/callback",
60
+ "https://site.example.com/api/hub-session/callback",
61
+ ],
62
+ [
63
+ "http://localhost:3000/api/hub-session/callback",
64
+ "http://localhost:3000/api/hub-session/callback",
65
+ ],
66
+ [
67
+ "http://127.0.0.1:3000/api/hub-session/callback",
68
+ "http://127.0.0.1:3000/api/hub-session/callback",
69
+ ],
70
+ ])("accepts %s", (raw, canonical) => {
71
+ expect(canonicalRedirectUri(raw)).toBe(canonical);
72
+ });
73
+
74
+ it.each([
75
+ [
76
+ "http: on a non-loopback host",
77
+ "http://site.example.com/api/hub-session/callback",
78
+ ],
79
+ ["another path", "https://site.example.com/api/hub-session/other"],
80
+ ["a trailing slash", "https://site.example.com/api/hub-session/callback/"],
81
+ ["a query", "https://site.example.com/api/hub-session/callback?x=1"],
82
+ ["an empty query", "https://site.example.com/api/hub-session/callback?"],
83
+ ["a fragment", "https://site.example.com/api/hub-session/callback#frag"],
84
+ ["an empty fragment", "https://site.example.com/api/hub-session/callback#"],
85
+ ["userinfo", "https://user:pass@site.example.com/api/hub-session/callback"],
86
+ [
87
+ "a username alone",
88
+ "https://user@site.example.com/api/hub-session/callback",
89
+ ],
90
+ ["a non-http scheme", "javascript:alert(1)"],
91
+ ["a relative URL", "/api/hub-session/callback"],
92
+ ["no URL at all", ""],
93
+ ])("refuses %s", (_name, raw) => {
94
+ expect(canonicalRedirectUri(raw)).toBeNull();
95
+ });
96
+ });
97
+
98
+ describe("registerSignInClient (the `spokes sign-in-client` CLI)", () => {
99
+ const goodUri = "https://new.example.com/api/hub-session/callback";
100
+
101
+ it("creates the client for the spoke, binds it by id, and returns the secret once", async () => {
102
+ const { spoke } = seedSpoke(state);
103
+ state.clients.clear();
104
+
105
+ const result = await registerSignInClient({
106
+ spokeId: spoke.id,
107
+ redirectUri: goodUri,
108
+ });
109
+
110
+ expect(result.ok).toBe(true);
111
+ if (!result.ok) return;
112
+ expect(result.secret.startsWith(HUB_CLIENT_SECRET_PREFIX)).toBe(true);
113
+
114
+ const client = state.clients.get(spoke.id);
115
+ // The client's public id IS the spoke's id (D3), and the secret is a digest.
116
+ expect(client?.clientId).toBe(spoke.id);
117
+ expect(client?.clientSecret).toBe(sha256Hex(result.secret));
118
+ expect(JSON.stringify(client)).not.toContain(result.secret);
119
+ expect(client?.redirectUris).toEqual([goodUri]);
120
+ expect(client?.enabled).toBe(true);
121
+ expect(db.oAuthClient.upsert).toHaveBeenCalledWith(
122
+ expect.objectContaining({
123
+ where: { clientId: spoke.id },
124
+ create: expect.objectContaining({
125
+ name: spoke.name,
126
+ scopes: ["account"],
127
+ trusted: true,
128
+ enabled: true,
129
+ }),
130
+ }),
131
+ );
132
+ });
133
+
134
+ it("re-running rotates the secret and replaces the callback", async () => {
135
+ const { spoke } = seedSpoke(state);
136
+ const first = await registerSignInClient({
137
+ spokeId: spoke.id,
138
+ redirectUri: goodUri,
139
+ });
140
+ const second = await registerSignInClient({
141
+ spokeId: spoke.id,
142
+ redirectUri: "https://other.example.com/api/hub-session/callback",
143
+ });
144
+
145
+ expect(first.ok && second.ok).toBe(true);
146
+ if (!first.ok || !second.ok) return;
147
+ expect(second.secret).not.toBe(first.secret);
148
+ const client = state.clients.get(spoke.id);
149
+ expect(client?.clientSecret).toBe(sha256Hex(second.secret));
150
+ expect(client?.redirectUris).toEqual([
151
+ "https://other.example.com/api/hub-session/callback",
152
+ ]);
153
+ expect(state.clients.size).toBe(1);
154
+ });
155
+
156
+ it("re-enables a client that was disabled", async () => {
157
+ const { spoke, client } = seedSpoke(state);
158
+ client.enabled = false;
159
+ const result = await registerSignInClient({
160
+ spokeId: spoke.id,
161
+ redirectUri: goodUri,
162
+ });
163
+ expect(result.ok).toBe(true);
164
+ expect(state.clients.get(spoke.id)?.enabled).toBe(true);
165
+ });
166
+
167
+ it("refuses an invalid redirect URI before reading the spoke or writing anything", async () => {
168
+ const { spoke } = seedSpoke(state);
169
+ const before = JSON.stringify([...state.clients.values()]);
170
+ const result = await registerSignInClient({
171
+ spokeId: spoke.id,
172
+ redirectUri: "http://site.example.com/api/hub-session/callback",
173
+ });
174
+ expect(result).toEqual({ ok: false, reason: "invalid-redirect-uri" });
175
+ expect(db.oAuthClient.upsert).not.toHaveBeenCalled();
176
+ expect(JSON.stringify([...state.clients.values()])).toBe(before);
177
+ });
178
+
179
+ it("refuses an unknown spoke and a revoked one", async () => {
180
+ expect(
181
+ await registerSignInClient({
182
+ spokeId: "spoke_nope",
183
+ redirectUri: goodUri,
184
+ }),
185
+ ).toEqual({
186
+ ok: false,
187
+ reason: "spoke-not-found",
188
+ });
189
+
190
+ const { spoke } = seedSpoke(state);
191
+ spoke.revokedAt = new Date();
192
+ expect(
193
+ await registerSignInClient({ spokeId: spoke.id, redirectUri: goodUri }),
194
+ ).toEqual({
195
+ ok: false,
196
+ reason: "spoke-revoked",
197
+ });
198
+ expect(db.oAuthClient.upsert).not.toHaveBeenCalled();
199
+ });
200
+ });
201
+
202
+ describe("disableSignInClient", () => {
203
+ it("switches the client off and reports whether it had one", async () => {
204
+ const { spoke, client } = seedSpoke(state);
205
+ expect(await disableSignInClient(spoke.id)).toBe(true);
206
+ expect(client.enabled).toBe(false);
207
+ expect(await disableSignInClient("spoke_nope")).toBe(false);
208
+ });
209
+ });