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
@@ -17,6 +17,9 @@
17
17
  * (#6228): the assertion's first arm now skips on any OTHER adapter, so a
18
18
  * case that means to exercise the REAL resolution must say which adapter it
19
19
  * is booting under rather than inherit whatever the ambient env holds.
20
+ *
21
+ * `describe("site stamp (#7868)")` covers the Checkout `metadata.site` stamp
22
+ * and the webhook's foreign-site test (spec D2/D7).
20
23
  */
21
24
 
22
25
  // 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.
@@ -28,7 +31,9 @@ import {
28
31
  __resetSiteWorkspaceCacheForTests,
29
32
  assertSiteAliasResolves,
30
33
  currentSiteId,
34
+ isForeignSiteStamp,
31
35
  resolveSiteWorkspaceId,
36
+ siteStamp,
32
37
  } from "../site.js";
33
38
 
34
39
  const DEFAULT_ORG_ID = "org_00000000-0000-7000-8000-000000000001";
@@ -236,3 +241,52 @@ describe("assertSiteAliasResolves — the #6228 content-adapter carve-out (arm 0
236
241
  expect(prisma.workspace.findUnique).toHaveBeenCalled();
237
242
  });
238
243
  });
244
+
245
+ describe("site stamp (#7868)", () => {
246
+ // Stripe types metadata values as strings; a non-string arrives only from a
247
+ // malformed payload, so the fixture is cast at this one boundary.
248
+ const nonStringStamp: Record<string, unknown> = { site: 42 };
249
+
250
+ it("siteStamp() with AILK_SITE_ID unset stamps the default alias", () => {
251
+ delete process.env.AILK_SITE_ID;
252
+ expect(siteStamp()).toEqual({ site: "default" });
253
+ });
254
+
255
+ it("siteStamp() follows AILK_SITE_ID, read per call", () => {
256
+ process.env.AILK_SITE_ID = PINNED_WORKSPACE_ID;
257
+ expect(siteStamp()).toEqual({ site: PINNED_WORKSPACE_ID });
258
+ });
259
+
260
+ it("siteStamp() is empty when AILK_SITE_ID is the empty string — Stripe is never sent an empty value (SEC-7868-5)", () => {
261
+ process.env.AILK_SITE_ID = "";
262
+ expect(siteStamp()).toEqual({});
263
+ });
264
+
265
+ it.each([
266
+ ["undefined metadata", undefined],
267
+ ["null metadata", null],
268
+ ["no stamp", {}],
269
+ ["an empty stamp", { site: "" }],
270
+ ["the own stamp", { site: "default" }],
271
+ ["a non-string stamp", nonStringStamp as Record<string, string>],
272
+ ])("isForeignSiteStamp is false for %s (unstamped is own, roadmap D4)", (_label, metadata) => {
273
+ delete process.env.AILK_SITE_ID;
274
+ expect(isForeignSiteStamp(metadata)).toBe(false);
275
+ });
276
+
277
+ it("isForeignSiteStamp is true for another site's stamp", () => {
278
+ delete process.env.AILK_SITE_ID;
279
+ expect(isForeignSiteStamp({ site: "samhenry" })).toBe(true);
280
+ });
281
+
282
+ it("isForeignSiteStamp compares exactly — a case-folded own alias is foreign", () => {
283
+ delete process.env.AILK_SITE_ID;
284
+ expect(isForeignSiteStamp({ site: "Default" })).toBe(true);
285
+ });
286
+
287
+ it("isForeignSiteStamp reads the alias per call, not once at import", () => {
288
+ process.env.AILK_SITE_ID = PINNED_WORKSPACE_ID;
289
+ expect(isForeignSiteStamp({ site: PINNED_WORKSPACE_ID })).toBe(false);
290
+ expect(isForeignSiteStamp({ site: "default" })).toBe(true);
291
+ });
292
+ });
@@ -19,6 +19,7 @@ import { createHash } from "node:crypto";
19
19
  import { prisma } from "../prisma.js";
20
20
  import {
21
21
  SPOKE_KEY_PREFIX,
22
+ findSpokeById,
22
23
  findSpokeByKeyHash,
23
24
  hasRegisteredSpoke,
24
25
  hashSpokeKey,
@@ -79,6 +80,25 @@ describe("findSpokeByKeyHash (D3 step 5)", () => {
79
80
  });
80
81
  });
81
82
 
83
+ describe("findSpokeById (#7894 D3, D5)", () => {
84
+ it("is one findUnique by id, selecting the row the sign-in client needs", async () => {
85
+ const row = { id: S1, workspaceId: W1, revokedAt: null, name: "Site" };
86
+ db.spoke.findUnique.mockResolvedValue(row as never);
87
+
88
+ await expect(findSpokeById(S1)).resolves.toEqual(row);
89
+ expect(db.spoke.findUnique).toHaveBeenCalledTimes(1);
90
+ expect(db.spoke.findUnique).toHaveBeenCalledWith({
91
+ where: { id: S1 },
92
+ select: { id: true, workspaceId: true, revokedAt: true, name: true },
93
+ });
94
+ });
95
+
96
+ it("answers null for an unknown id", async () => {
97
+ db.spoke.findUnique.mockResolvedValue(null);
98
+ await expect(findSpokeById(S1)).resolves.toBeNull();
99
+ });
100
+ });
101
+
82
102
  describe("hasRegisteredSpoke (#7633 D1, AC-5)", () => {
83
103
  it("is true when the registry holds a row, revoked or not — one findFirst with no where", async () => {
84
104
  db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
@@ -27,13 +27,25 @@
27
27
  * `findAgentByKeyHash` and `touchAgentLastActive` only;
28
28
  * - the management routes (`routes/workspaces/agents.ts`) call the rest.
29
29
  *
30
- * Nothing caches a row: a switch-off, a re-key or a removed membership takes
31
- * effect on the next request.
30
+ * GRANTS (#7864, spec docs/specs/7864-agent-key-grants-spec.md D2–D4). An
31
+ * agent's row carries the permissions an owner or admin granted it one by one
32
+ * — only the five `AGENT_GRANTABLE_PERMISSIONS`. `setServedAgentGrants` is
33
+ * the column's only writer, and refuses a grant its person's own role does
34
+ * not hold; the tenant guards apply the same rule on every request, so a
35
+ * grant stored before a demotion is held to the person's current role.
36
+ *
37
+ * Nothing caches a row: a switch-off, a re-key, a grant change or a removed
38
+ * membership takes effect on the next request.
32
39
  */
33
40
  import { createHash, randomBytes } from "node:crypto";
34
41
 
35
42
  import type { Id } from "@working-theory/ids";
36
- import type { AgentPermissionLevel } from "@working-theory/validation";
43
+ import {
44
+ AGENT_GRANTABLE_PERMISSIONS,
45
+ can,
46
+ type AgentGrantablePermission,
47
+ type AgentPermissionLevel,
48
+ } from "@working-theory/validation";
37
49
 
38
50
  import { prisma } from "./prisma.js";
39
51
 
@@ -71,6 +83,21 @@ export interface AgentGrant {
71
83
  organizationId: Id<"organization">;
72
84
  responsibleMembershipId: Id<"membership">;
73
85
  permissionLevel: AgentPermissionLevel;
86
+ /**
87
+ * The grants stored on the row (#7864 D3). The tenant guards keep only
88
+ * those its person's role also holds (`agentTenant`).
89
+ */
90
+ grants: readonly string[];
91
+ }
92
+
93
+ /**
94
+ * A stored grant set in `AGENT_GRANTABLE_PERMISSIONS` order, without
95
+ * duplicates and without anything outside the five.
96
+ */
97
+ function orderedGrants(
98
+ grants: readonly string[],
99
+ ): AgentGrantablePermission[] {
100
+ return AGENT_GRANTABLE_PERMISSIONS.filter((p) => grants.includes(p));
74
101
  }
75
102
 
76
103
  // ── The auth preHandler's three functions ────────────────────────────────────
@@ -80,6 +107,8 @@ export interface AgentKeyRow {
80
107
  workspaceId: string;
81
108
  organizationId: string;
82
109
  permissionLevel: AgentPermissionLevel;
110
+ /** `undefined` only from a test double; the auth preHandler reads it as none. */
111
+ grants: string[] | undefined;
83
112
  enabled: boolean;
84
113
  keyIssuedToMembershipId: string | null;
85
114
  lastActiveAt: Date | null;
@@ -97,6 +126,7 @@ export async function findAgentByKeyHash(
97
126
  workspaceId: true,
98
127
  organizationId: true,
99
128
  permissionLevel: true,
129
+ grants: true,
100
130
  enabled: true,
101
131
  keyIssuedToMembershipId: true,
102
132
  lastActiveAt: true,
@@ -180,6 +210,12 @@ interface SetAgentEnabledParams {
180
210
  enabled: boolean;
181
211
  }
182
212
 
213
+ interface SetAgentGrantsParams {
214
+ organizationId: string;
215
+ agentId: string;
216
+ grants: readonly AgentGrantablePermission[];
217
+ }
218
+
183
219
  /**
184
220
  * Add an agent for `responsibleUserId`, bound to `workspaceId`. The returned
185
221
  * `key` is the only copy that will ever exist: the caller shows it once.
@@ -280,6 +316,58 @@ export async function setServedAgentEnabled(
280
316
  });
281
317
  }
282
318
 
319
+ /** A grant set the agent's person's own role does not wholly hold. */
320
+ export type AgentGrantExceedsPerson = { ok: false; reason: "exceeds-person" };
321
+ export type ServedAgentGrants = ServedAgent & {
322
+ /** The grants stored before this write, in D1 order — for the audit row. */
323
+ previous: AgentGrantablePermission[];
324
+ };
325
+
326
+ /**
327
+ * Replace an agent's grants with `grants` (#7864 D4) and answer with its list
328
+ * row. One transaction: the agent is read by id inside the caller's
329
+ * organization, with its responsible person's role; any requested grant that
330
+ * role does not hold refuses the whole set (`exceeds-person`) and writes
331
+ * nothing. An agent of another organization, or none, is `not-found`.
332
+ */
333
+ export async function setServedAgentGrants(
334
+ params: SetAgentGrantsParams,
335
+ ): Promise<ServedAgentGrants | AgentNotFound | AgentGrantExceedsPerson> {
336
+ return prisma.$transaction(async (tx) => {
337
+ const current = await tx.agent.findUnique({
338
+ where: agentScope(params),
339
+ select: {
340
+ organizationId: true,
341
+ grants: true,
342
+ responsible: { select: { role: true } },
343
+ },
344
+ });
345
+ if (!current || current.organizationId !== params.organizationId) {
346
+ return { ok: false, reason: "not-found" } as const;
347
+ }
348
+ const grants = orderedGrants(params.grants);
349
+ if (!grants.every((grant) => can(current.responsible.role, grant))) {
350
+ return { ok: false, reason: "exceeds-person" } as const;
351
+ }
352
+ const { count } = await tx.agent.updateMany({
353
+ where: agentScope(params),
354
+ data: { grants },
355
+ });
356
+ if (count !== 1) return { ok: false, reason: "not-found" } as const;
357
+ const [agent] = await readAgentListRows(
358
+ { id: params.agentId, organizationId: params.organizationId },
359
+ tx,
360
+ );
361
+ return agent
362
+ ? ({
363
+ ok: true,
364
+ agent,
365
+ previous: orderedGrants(current.grants),
366
+ } as const)
367
+ : ({ ok: false, reason: "not-found" } as const);
368
+ });
369
+ }
370
+
283
371
  /** The list row of a just-written agent, read on the write's own client. */
284
372
  async function servedWith(
285
373
  db: AgentDb,
@@ -392,6 +480,8 @@ export interface AgentListRow {
392
480
  workspaceId: string;
393
481
  responsible: { userId: string; name: string | null; email: string };
394
482
  permissionLevel: AgentPermissionLevel;
483
+ /** The stored grants (#7864 D4), in `AGENT_GRANTABLE_PERMISSIONS` order. */
484
+ grants: AgentGrantablePermission[];
395
485
  enabled: boolean;
396
486
  lastActiveAt: Date | null;
397
487
  createdAt: Date;
@@ -431,6 +521,7 @@ async function readAgentListRows(
431
521
  name: true,
432
522
  workspaceId: true,
433
523
  permissionLevel: true,
524
+ grants: true,
434
525
  enabled: true,
435
526
  lastActiveAt: true,
436
527
  createdAt: true,
@@ -466,6 +557,7 @@ async function readAgentListRows(
466
557
  email: r.responsible.user.email,
467
558
  },
468
559
  permissionLevel: r.permissionLevel,
560
+ grants: orderedGrants(r.grants),
469
561
  enabled: r.enabled,
470
562
  lastActiveAt: r.lastActiveAt,
471
563
  createdAt: r.createdAt,
@@ -23,12 +23,29 @@
23
23
  * (D15), so a rewritten or mis-set grant can never serve another
24
24
  * workspace's bytes.
25
25
  *
26
+ * Neither ever resolves a client file (SEC-7877-A, #7877): an asset tagged
27
+ * `private`, or one a member-area message attaches, is the same `null` a
28
+ * miss is — so a magnet pointed at a client file's key mints nothing and a
29
+ * grant minted before the file became one serves nothing.
30
+ *
26
31
  * No write, no `findMany`, no list, no unscoped read — both functions take
27
32
  * a `workspaceId` the caller resolved server-side, never a client-supplied
28
33
  * value, so neither can enumerate or cross a tenant boundary (NFR3).
29
34
  */
35
+ import { PRIVATE_ASSET_TAG } from "@working-theory/validation";
36
+
30
37
  import { prisma } from "./prisma.js";
31
38
 
39
+ /** A client file (SEC-7877-A) — `private`-tagged, or attached to a message. */
40
+ async function isClientFile(
41
+ assetId: string,
42
+ workspaceId: string,
43
+ tags: string[],
44
+ ): Promise<boolean> {
45
+ if (tags.includes(PRIVATE_ASSET_TAG)) return true;
46
+ return (await prisma.interaction.count({ where: { assetId, workspaceId } })) > 0;
47
+ }
48
+
32
49
  /** What the mint-time resolution needs, and nothing more. */
33
50
  export async function findDeliverableAssetIdByKey(
34
51
  workspaceId: string,
@@ -36,9 +53,10 @@ export async function findDeliverableAssetIdByKey(
36
53
  ): Promise<string | null> {
37
54
  const row = await prisma.asset.findFirst({
38
55
  where: { workspaceId, key },
39
- select: { id: true },
56
+ select: { id: true, tags: true },
40
57
  });
41
- return row?.id ?? null;
58
+ if (!row || (await isClientFile(row.id, workspaceId, row.tags))) return null;
59
+ return row.id;
42
60
  }
43
61
 
44
62
  /** What the serve path needs, and deliberately nothing more (no key, no workspace). */
@@ -61,12 +79,13 @@ export async function readDeliverableAsset(
61
79
 
62
80
  const row = await prisma.asset.findFirst({
63
81
  where: { id: assetId, workspaceId },
64
- select: { filename: true, contentType: true, bytes: true },
82
+ select: { filename: true, contentType: true, bytes: true, tags: true },
65
83
  });
66
- if (!row) return null;
84
+ if (!row || (await isClientFile(assetId, workspaceId, row.tags))) return null;
67
85
 
68
86
  return {
69
- ...row,
87
+ filename: row.filename,
88
+ contentType: row.contentType,
70
89
  // Prisma maps a `Bytes` column to `Uint8Array`, and Fastify treats only
71
90
  // a `Buffer` as a raw payload — this wraps rather than copies
72
91
  // (`Buffer.from(buffer, byteOffset, length)` is a view), mirroring