create-ailk 0.13.0 → 0.15.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 (188) hide show
  1. package/README.md +12 -4
  2. package/component-catalog.json +250 -36
  3. package/component-catalog.md +10 -6
  4. package/dist/add-module.d.ts +7 -0
  5. package/dist/add-module.js +34 -2
  6. package/dist/add-route.js +18 -2
  7. package/dist/apply-design.d.ts +32 -2
  8. package/dist/apply-design.js +155 -22
  9. package/dist/cli.js +24 -3
  10. package/dist/derive-architecture.d.ts +15 -2
  11. package/dist/derive-architecture.js +34 -6
  12. package/dist/design-compile.d.ts +70 -2
  13. package/dist/design-compile.js +97 -1
  14. package/dist/design-home.d.ts +74 -0
  15. package/dist/design-home.js +256 -0
  16. package/dist/design-package.d.ts +39 -2
  17. package/dist/design-package.js +224 -9
  18. package/dist/design-presets.d.ts +17 -1
  19. package/dist/design-presets.js +17 -5
  20. package/dist/design-scaffold.d.ts +6 -25
  21. package/dist/design-scaffold.js +26 -74
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +1 -1
  24. package/dist/lib/chrome-readers.d.ts +21 -0
  25. package/dist/lib/chrome-readers.js +100 -0
  26. package/dist/lib/design-checks.d.ts +12 -1
  27. package/dist/lib/design-checks.js +38 -2
  28. package/dist/lib/extract-module-bundle.d.ts +11 -0
  29. package/dist/lib/extract-module-bundle.js +36 -6
  30. package/dist/lib/license-key-files.d.ts +6 -1
  31. package/dist/lib/license-key-files.js +9 -4
  32. package/dist/lib/module-license-gate.d.ts +9 -0
  33. package/dist/lib/module-license-gate.js +18 -0
  34. package/dist/module-architecture.d.ts +9 -0
  35. package/dist/module-architecture.js +6 -0
  36. package/dist/parse-args.d.ts +22 -0
  37. package/dist/parse-args.js +73 -0
  38. package/dist/programmatic.d.ts +20 -0
  39. package/dist/programmatic.js +180 -35
  40. package/dist/site-chrome.d.ts +42 -0
  41. package/dist/site-chrome.js +140 -2
  42. package/dist/site-fonts.d.ts +21 -0
  43. package/dist/site-fonts.js +54 -0
  44. package/dist/site-manifest.d.ts +12 -5
  45. package/dist/site-manifest.js +25 -11
  46. package/dist/site-roster.d.ts +16 -0
  47. package/dist/site-roster.js +81 -0
  48. package/dist/starter-grid.js +15 -0
  49. package/dist/surfaces.js +12 -0
  50. package/dist/sync-routes.js +11 -2
  51. package/dist/team-profiles.d.ts +29 -0
  52. package/dist/team-profiles.js +43 -0
  53. package/dist/topology.d.ts +131 -0
  54. package/dist/topology.js +533 -0
  55. package/package.json +3 -3
  56. package/release-scope.json +1 -1
  57. package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
  58. package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
  59. package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
  60. package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
  61. package/site-templates/designs/minimalism/DESIGN.md +166 -36
  62. package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
  63. package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
  64. package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
  65. package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
  66. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
  68. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
  69. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
  70. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  71. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
  72. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
  73. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  74. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
  75. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
  76. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  77. package/templates/README.md +2 -0
  78. package/templates/apps/api/CLAUDE.md +6 -1
  79. package/templates/apps/api/package.json +1 -0
  80. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  81. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  82. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  83. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  84. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  85. package/templates/apps/api/src/lib/site.ts +37 -0
  86. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  87. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  88. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  89. package/templates/apps/api/src/middleware/auth.ts +3 -0
  90. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  91. package/templates/apps/api/src/openapi/spec.ts +38 -2
  92. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  93. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  94. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  95. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  96. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  97. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  98. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  99. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  100. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  101. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  102. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  103. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  104. package/templates/apps/mcp/package.json +1 -1
  105. package/templates/apps/mcp/src/index.ts +6 -4
  106. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  107. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  108. package/templates/apps/web/CLAUDE.md +12 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
  112. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  113. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  115. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  116. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
  117. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  118. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
  119. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
  120. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
  121. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
  122. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
  123. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
  124. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
  125. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
  126. package/templates/apps/web/app/[locale]/layout.tsx +212 -57
  127. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  128. package/templates/apps/web/app/[locale]/page.tsx +37 -0
  129. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  132. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
  134. package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
  135. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
  137. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
  138. package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
  139. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
  140. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
  141. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  142. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
  143. package/templates/apps/web/app/not-found.tsx +47 -0
  144. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  145. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  146. package/templates/apps/web/instrumentation.ts +7 -4
  147. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  148. package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
  149. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  150. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  151. package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
  152. package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
  153. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  154. package/templates/apps/web/lib/profile-back-link.ts +51 -0
  155. package/templates/apps/web/lib/relay.ts +206 -2
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +15 -0
  158. package/templates/apps/web/lib/site-fonts.ts +15 -0
  159. package/templates/apps/web/lib/site-logo.tsx +39 -1
  160. package/templates/apps/web/lib/site-theme-head.ts +33 -0
  161. package/templates/apps/web/lib/site-theme.ts +13 -1
  162. package/templates/apps/web/lib/topology.generated.ts +23 -0
  163. package/templates/apps/web/messages/en.json +7 -1
  164. package/templates/apps/web/package.json +1 -1
  165. package/templates/database/CHANGELOG.md +32 -0
  166. package/templates/database/__tests__/agents.db.test.ts +65 -0
  167. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  168. package/templates/database/auth/schema.prisma +23 -0
  169. package/templates/database/inbox/schema.prisma +13 -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/package.json +1 -1
  175. package/versions.json +35 -34
  176. package/site-templates/designs/minimalism/sample/README.md +0 -48
  177. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
  178. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
  179. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
  180. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
  181. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
  182. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
  183. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
  184. package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
  185. package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
  186. package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
  187. package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
  188. package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
@@ -263,6 +263,24 @@ _Code route — no content, no sections._
263
263
  _Detail route over `content/en/PortfolioItem/` — 4 seed pages: `sample-four.mdx`, `sample-one.mdx`, `sample-three.mdx`, `sample-two.mdx`._
264
264
  <!-- generated:end -->
265
265
 
266
+ `/team`:
267
+
268
+ <!-- generated:begin page=/team -->
269
+
270
+ | # | Section | Component | Copy (first line) |
271
+ | --- | --------------------------------------- | --------- | ----------------------------- |
272
+ | 1 | `hero` (default · eyebrow "{Team}") | `Hero` | {The people behind the work.} |
273
+ | 2 | `team` (with-large-images · members ×3) | `Team` | {First team member's name} |
274
+
275
+ <!-- generated:end -->
276
+
277
+ `/team/[slug]`:
278
+
279
+ <!-- generated:begin page=/team/[slug] -->
280
+
281
+ _Detail route over `content/en/TeamMember/` — 3 seed pages: `first-member.mdx`, `second-member.mdx`, `third-member.mdx`._
282
+ <!-- generated:end -->
283
+
266
284
  ### 3a. Differences from the standard `organization-service` derivation
267
285
 
268
286
  The standard page set for this shape (`derive-roster.py --shape organization-service --primary-goal generate-leads --registry packages/schema/src/registry/entries.ts`, harness commit `7bdaf148e1b0e639cd90a80008955e55cc4aa2b5`) is 33 rows, `mainEntity: Organization`. This starter's roster is that set minus the standard rows the table below drops, plus the rows the table after it adds, each with a reason.
@@ -271,7 +289,6 @@ The standard page set for this shape (`derive-roster.py --shape organization-ser
271
289
 
272
290
  | Standard row | Reason |
273
291
  | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
274
- | `TeamList` · `TeamMember` | a placeholder starter has no team to list; `/about` carries the people |
275
292
  | `CareersList` · `JobPosting` | a placeholder starter has no open roles |
276
293
  | `PartnersList` · `Partner` | a placeholder starter has no partners to list |
277
294
  | `ProjectsList` · `Project` | the portfolio pair (`/portfolio`) is the proof of past work; `/projects` is the signal-collectors sample listing |
@@ -289,7 +306,7 @@ The standard page set for this shape (`derive-roster.py --shape organization-ser
289
306
  | `BlogList` | every Free scaffold's nav links `/blog` unconditionally (`renderNavConfig`, operator ruling 2026-09-23); the Free teaser serves it, as on organization-product (§ 1.8) |
290
307
  | `LandingPage` | the Free `/meet` meeting-types hub is a LandingPage seed, the same generic `[slug]` route serves it through (#6897 D3); the four `/meet/[slug]` BookCall detail seeds ride the `BookCall` type already in the standard roster |
291
308
 
292
- `|S − D| = 13` and `|R| = 18`.
309
+ `|S − D| = 15` and `|R| = 20`.
293
310
 
294
311
  `/sign-in` and `/sign-up` are also rostered (#6797) — code routes (`kind: route`) with no page type, so they sit outside this set equation: the locale layout's header links both, and the roster of record lists every page the scaffold serves.
295
312
 
@@ -326,6 +343,8 @@ organization-service:
326
343
  - { route: "/meet/[slug]", kind: page, content: ["content/en/BookCall/book-a-call.mdx", "content/en/BookCall/onsite-estimate.mdx", "content/en/BookCall/start-project.mdx", "content/en/BookCall/onsite-service.mdx"] }
327
344
  - { route: "/portfolio", kind: page, content: ["content/en/PortfolioList/portfolio.mdx"] }
328
345
  - { route: "/portfolio/[slug]", kind: page, contentDir: "content/en/PortfolioItem" }
346
+ - { route: "/team", kind: page, content: ["content/en/TeamList/team.mdx"] }
347
+ - { route: "/team/[slug]", kind: page, contentDir: "content/en/TeamMember" }
329
348
  ```
330
349
 
331
350
  <!-- generated:end -->
@@ -345,10 +364,10 @@ Every row is module-less: `requires` is `[marketing]` alone, so `mergeRoster` co
345
364
 
346
365
  <!-- generated:begin inventory -->
347
366
 
348
- | Tier | Used by this template |
349
- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
350
- | Shell | `MarketingShell` |
351
- | Chrome | `Nav`, `Footer`, `Banner` (+ `BannerConsentIsland`, `BannerDismissIsland`) |
352
- | Sections | `BlogList`, `ContentBlock`, `CTA`, `DocumentChassis`, `FAQ`, `GlossaryIndex`, `Hero`, `Items`, `LogoCloud`, `Testimonials` |
367
+ | Tier | Used by this template |
368
+ | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
369
+ | Shell | `MarketingShell` |
370
+ | Chrome | `Nav`, `Footer`, `Banner` (+ `BannerConsentIsland`, `BannerDismissIsland`) |
371
+ | Sections | `BlogList`, `ContentBlock`, `CTA`, `DocumentChassis`, `FAQ`, `GlossaryIndex`, `Hero`, `Items`, `LogoCloud`, `Team`, `Testimonials` |
353
372
 
354
373
  <!-- generated:end -->
@@ -6,5 +6,5 @@
6
6
  "provenance.json": ".ailk-template.json",
7
7
  "TEMPLATE-MANIFEST.json": ".ailk-site-manifest.json"
8
8
  },
9
- "postInstall": "Starter organization-service installed. Its /faq, /contact, /blog, /meet and its four /meet/<slug> seed pages came with the always-copied overlay; /glossary, /newsletter, /privacy and /terms with the base layer it extends (content/en/). This tree's own /, /about, /services (+ three engagements), /purchase-policy and /portfolio pages are its own compositions. Every line of copy is a placeholder \u2014 replace it before you launch."
9
+ "postInstall": "Starter organization-service installed. Its /faq, /contact, /blog, /meet and its four /meet/<slug> seed pages came with the always-copied overlay; /glossary, /newsletter, /privacy, /terms, /team and its three /team/<slug> profiles with the base layer it extends (content/en/). This tree's own /, /about, /services (+ three engagements), /purchase-policy and /portfolio pages are its own compositions. Every line of copy is a placeholder \u2014 replace it before you launch."
10
10
  }
@@ -66,6 +66,8 @@ The pages of the Free `organization-product` shape, and where each one's copy li
66
66
  | `/meet/[slug]` | `content/en/BookCall/book-a-call.mdx`, `content/en/BookCall/onsite-estimate.mdx`, `content/en/BookCall/start-project.mdx`, `content/en/BookCall/onsite-service.mdx` | every scaffold |
67
67
  | `/portfolio` | `content/en/PortfolioList/portfolio.mdx` | `--shape organization-product` |
68
68
  | `/portfolio/[slug]` | — (a code route) | `--shape organization-product` |
69
+ | `/team` | `content/en/TeamList/team.mdx` | `--shape organization-product` |
70
+ | `/team/[slug]` | — (a code route) | `--shape organization-product` |
69
71
 
70
72
  <!-- generated:end -->
71
73
 
@@ -55,6 +55,7 @@ Routes are versioned under `/v1/*`:
55
55
  - `/v1/workspaces/:workspaceId/submissions` (GET, `?limit=&state=unhandled&magnetId=`), `/v1/workspaces/:workspaceId/submissions/:leadId` (GET, PATCH) — the Inbox submissions API (#7535, `src/routes/workspaces/submissions.ts` over `src/services/inbox-submissions.ts`): every flow completion and waitlist signup of the caller's active workspace, newest first and bounded (`?limit=` 1–200, default 50), the item read (handled or not), and the one-way idempotent handled stamp (`{ "handled": true }` only, `.strict()`; a second PATCH returns the first `handledAt`). Same registration, scope and tenant guard as the messages bullet above. A submission is a `Lead` whose `source` is a fixed submission source (`SUBMISSION_FIXED_SOURCE_LIST` in `@working-theory/validation`) or `flow:<slug>` for a NON-newsletter magnet of THIS workspace — decided only in `tenantDb(tenant).leads`, never in the route; a `flow:` row naming no magnet of the workspace never lists. The list row is seven keys — the message row's six plus `magnet: { slug, title } | null` — and never `payload`. `?magnetId=` narrows to one magnet (limit 1–50) and adds `answers`, `answersTruncated` and `deliverableSentAt` per row; the item read serves the same three. `answers` is `payload` narrowed by `toAnswers`: server-owned keys dropped, control and bidi characters stripped, every key, value and the entry count bounded. `?state=unhandled` filters `handledAt IS NULL` in the query (the Admin's For-you read); any other `state` is a 400. Every miss on the item read and the PATCH — a malformed, absent or foreign id, a message, an application, a newsletter-magnet lead — is the SAME `404 No such submission`; a `?magnetId=` that is absent, foreign or a newsletter magnet is `404 No such magnet`. Every success body is re-parsed against its own `.strict()` schema before it is sent. Role floor: `inbox-read` on the GETs and `inbox-act` on PATCH (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included), checked before any read or write. The handlers and the service never log a lead id, email, name or answer. No anonymous MCP twin — identity-bound tenant routes. Each route's twin is a signed-in tool on `/mcp/workspace` (#3591 D13): `list_inbox_submissions` (list), `get_inbox_submission` (item read) and `mark_inbox_submission_done` (PATCH).
56
56
  - `/v1/workspaces/:workspaceId/subscribers` (GET, `?limit=`) — the workspace's newsletter signups, read-only (#7535, `src/routes/workspaces/subscribers.ts`): `{ count, items }`, where `count` is every signup and `items` the newest (1–200, default 50), each `{ id, email, source, subscribedAt, magnet: { slug, title } | null }`. A signup is a `Subscriber` row of this workspace or the `Lead` a newsletter-kind magnet of this workspace wrote, both from `tenantDb(tenant).subscribers`, called directly. No item route and no write: a subscriber is never an item to open or mark handled. Same registration, scope, tenant guard and `inbox-read` floor as the submissions bullet above. No MCP twin — identity-bound tenant route (#3591 exception).
57
57
  - `/v1/workspaces/:workspaceId/members` (GET) — the org's member roster (`userId`, `name`, `email`, `role`), the Pro `workspace` module's Team tab (#5371). Read-only by design: granting a Membership is the invite flow, which is its own spec with a mandatory security review (§2.3), so no POST/PUT/DELETE is routed at this path. Scoped to `request.tenant.organizationId` — Membership is org-level — server-resolved from the validated session. Role floor: `members-read` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)): the roster carries every member's email, and who the other members are is not a member's job. No MCP twin — identity-bound tenant route (#3591 exception).
58
+ - `/v1/workspaces/:workspaceId/agents` (GET `?userId=`, POST), `/v1/workspaces/:workspaceId/agents/:agentId/key` (POST), `/v1/workspaces/:workspaceId/agents/:agentId` (PATCH `{ enabled }`), `PUT /v1/workspaces/:workspaceId/agents/:agentId/grants` (`{ grants }`, #7864), and `/v1/agent/context` (GET) — the agent seat (#3591, `src/routes/workspaces/agents.ts` over `src/lib/agent-store.ts`, `// oss: false`). An agent is a stored delegate of one person, bound to one workspace. The five management routes refuse an agent caller outright (403), check `agents-manage` (owner, admin) before any read, and scope every read and write to `tenant.organizationId`, never a body or query value; an agent of another organization is the same 404 as a missing one. Create returns 201 with the key shown once; re-key returns the new key once. Each successful create, re-key, switch and grant change writes an `agents.*` audit record. **Grants (#7864, `docs/specs/7864-agent-key-grants-spec.md`).** An agent acts at `member`, plus any of five permissions an owner or admin granted it one by one — `content-edit`, `content-publish`, `assets-write`, `magnets-manage`, `meeting-types-manage` (`@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, held to that set by the `agents_grants_allowed` CHECK). The grant PUT replaces the whole set (an empty list removes every grant) and refuses a grant the agent's person's own role does not hold, `409 grant_exceeds_person`. The clamp is one function both tenant guards call (`services/default-tenant.ts`'s `agentTenant`): an agent holds a stored grant only while its person's role holds it, so a demoted person's agent keeps the row but not the permission. The routes for the five check `allows(tenant, "<permission>")`, which reads the grants; every other check keeps `can(tenant.role, …)`, so no other owner or admin permission is reachable. Every agent write on content, assets and meeting types writes one `recordAgentWrite` row naming the agent and its person (magnets already did); a person's writes there stay unaudited. No grant raises the ceiling. `/v1/agent/context` is the agent's own read, unchanged by grants, and needs no permission beyond the workspace guard. Same registration scope as the routes above (`workspace || multi-tenant`).
58
59
  - `/v1/workspaces/:workspaceId/content` (GET, POST), `/v1/workspaces/:workspaceId/content/:type/:slug` (GET, PATCH), `/v1/workspaces/:workspaceId/content/:type/:slug/{publish,unpublish}` (POST) — the tenant-guarded content editor's write surface (#5853, `src/routes/workspaces/content.ts`): list, create, read/save the draft tip (under `If-Match`), and publish/unpublish, all over `Artifact`/`ArtifactVersion`. `:type` is validated against the page-type registry (`z.enum(SCHEMA_ORG_TYPES)`) rather than accepted as a free string on every addressed operation — an unregistered type is a 400 before any lookup, since it is echoed back and passed to `notifyRevalidate` as the site's cache tag. Role floors from `@working-theory/validation`'s permission table (#7636 D2): publish and unpublish need `content-publish`, and create, read and save need `content-edit` (both owner, admin — an admin builds, a member operates). The list serves `content-edit` the editor's full view and a caller holding only `docs-read` (a member) a published-only projection (D4): draft rows dropped, every row `status: "published"`, `version: null`, `valid: true`, and a `published_with_draft` page's `type`/`tags` read from its PUBLISHED version — applied before every filter, so a member's `?status=draft` or a draft tip's `?tag=` matches nothing. **The GET list is a UNION of page artifacts and DOCUMENT assets** (#6093, D1/D9 — "one store, two views"): every row carries `tags: string[]` (never null, read from the draft tip's stored content for a page, from `Asset.tags` for a file) and a DERIVED `kind: "page" | "post" | "file"` (`BlogPosting` alone maps to `"post"`; an asset row is always `"file"`) — an optional `?tag=<slug>` filters the union AFTER it is built, so a file is returned under either its own tag or a shared one. The asset half of the union (`DOCUMENT_CONTENT_TYPES`, i.e. `application/pdf`) is skipped entirely when `locale`/`type` is supplied or `status` names anything but `"published"`, since a file row can never satisfy those. A foreign `:workspaceId` costs zero artifact **and** zero asset queries. No MCP twin — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes).
59
60
  - `/v1/workspaces/:workspaceId/assets` (GET, POST), `/v1/workspaces/:workspaceId/assets/:assetId` (PATCH, DELETE), `/v1/assets/:assetId` (GET, public/anonymous) — the workspace asset store (#5854 upload/read; #6093 list/PATCH/DELETE, `src/routes/assets/{index,upload,read,manage}.ts`). Upload accepts an allowlisted union of three named classes (`src/routes/assets/index.ts`): five RASTER images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`), one VECTOR image (`image/svg+xml`, admitted under the operator's 2026-09-18 `allow-svg-assets` ruling — issue #6093 comment `5731573595` — superseding #5854's original SVG exclusion) and one DOCUMENT type (`application/pdf`), all under the existing 20 MB cap; `?tags=<slug>[,<slug>…]` stores tags on the row. The public read serves a RASTER type `inline` exactly as before; every OTHER admitted type (SVG, PDF) is served `attachment`, with the filename's extension pinned to the stored content type (closing extension spoofing) and a five-directive `Content-Security-Policy` (`default-src 'none'; sandbox; frame-ancestors 'none'; form-action 'none'; base-uri 'none'`) that makes the bytes never a document on this origin — this is what makes admitting SVG safe with NO server-side sanitizer. `GET …/assets` (`assets-read` — every role, a member included) lists a workspace's assets, metadata only, never `bytes`, with an optional `?tag=<slug>` filter; this is what narrows the store's prior "nothing lists assets" premise to its precise form — no ANONYMOUS and no CROSS-TENANT enumeration, both still true. `PATCH …/assets/:assetId` updates `title`/`description`/`tags` (`assets-write`, owner and admin, the upload's permission too; `null` clears a text field to SQL `NULL`, `""` stores empty; the asset's `id` and public URL are untouched). `DELETE …/assets/:assetId` (`assets-write`) removes the row and the bytes at the ORIGIN only — already-cached copies elsewhere are not recallable under the year-long `immutable` promise, and no reference check protects a page that still embeds the URL; absent and foreign ids both take the same 404. Every tenant route here is identity-bound; no MCP twin (#3591 exception). The public read has no MCP twin either — it serves bytes to an `<img>`/download, the same boundary `upload.ts` records for its own sibling.
60
61
  - `/v1/billing` (GET), `/v1/billing/portal-session` (POST) — org-scoped billing read + Stripe customer-portal session creation. The read stays open to every member (the Admin's plan gate reads it); the portal needs `billing-manage` — the owner alone (`@working-theory/validation`'s permission table (#7636 D2); an admin is refused before any Stripe call). `/v1/billing/usage` (GET) needs `billing-read` (owner, admin).
@@ -161,7 +162,11 @@ sweep's own DB constraint (never a caller-side lock).
161
162
  - Don't put session state in `apps/api`. It's stateless.
162
163
  - Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
163
164
 
164
- Last updated: 2026-10-04 — doc-sync: the agent seat, step E (#7684) — the messages and submissions bullets now name each route's signed-in MCP twin on `/mcp/workspace` (#3591 D13) in place of the retired no-twin exception — verified against `apps/api/src/routes/workspaces/{messages,submissions}.ts`, `apps/mcp/src/workspace-server.ts` and `tests/parity/api-parity/kinds/site.ts`.
165
+ Last updated: 2026-10-08 — agent key grants (#7864): the agent-seat bullet names `PUT /v1/workspaces/:workspaceId/agents/:agentId/grants`, the five grantable permissions and the person clamp — verified against `apps/api/src/routes/workspaces/agents.ts`, `apps/api/src/services/default-tenant.ts` and `packages/validation/src/schemas/permissions.ts`.
166
+
167
+ Prior: 2026-10-05 — doc-sync: added the agent seat routes (#3591, #7678) to Endpoint conventions — verified against `apps/api/src/routes/workspaces/agents.ts`.
168
+
169
+ Prior: 2026-10-04 — doc-sync: the agent seat, step E (#7684) — the messages and submissions bullets now name each route's signed-in MCP twin on `/mcp/workspace` (#3591 D13) in place of the retired no-twin exception — verified against `apps/api/src/routes/workspaces/{messages,submissions}.ts`, `apps/mcp/src/workspace-server.ts` and `tests/parity/api-parity/kinds/site.ts`.
165
170
 
166
171
  Prior: 2026-10-04 — doc-sync: roles by job (#7636 PR-A, #7653) — every role floor on the routes above now names its row of `@working-theory/validation`'s permission table (`can(tenant.role, "<permission>")`, no route-local role constant): a member reads and acts on the inbox family, appointments, the waitlist dashboard and listing decisions, reads live-only docs, magnets and meeting types and the asset list, and is refused draft content, workspace settings (the config PUT gained its first check) and agent sessions; an admin manages the calendar connection (audited, other owners emailed) and is refused the billing portal — verified against `apps/api/src/routes/{workspaces,magnets,assets,billing,waitlist-experiments,project-listings}/*.ts`, `apps/api/src/routes/schedule/appointments.ts` and `apps/api/src/services/{leads-inbox,inbox-messages,inbox-submissions,waitlist-dashboard,waitlist-operator-reads,tenant-context}.ts`.
167
172
 
@@ -15,6 +15,7 @@
15
15
  "deliverable:resend": "tsx src/bin/deliverable-resend.ts",
16
16
  "seed:presets": "tsx src/bin/seed-presets.ts",
17
17
  "spokes": "tsx src/bin/spokes.ts",
18
+ "flows:copy": "tsx src/bin/flows-copy.ts",
18
19
  "build": "tsc",
19
20
  "build:vercel": "cd ../../database && pnpm exec prisma generate && cd ../.. && pnpm --filter @working-theory/validation build && pnpm --filter @working-theory/api build && mkdir -p apps/api/public && echo '{}' > apps/api/public/.vercel",
20
21
  "start": "node dist/cli.js",
@@ -27,13 +27,15 @@ export interface AgentCaller {
27
27
  workspaceId: string;
28
28
  /** The person's own role; the guard clamps the agent to `member`. */
29
29
  role: "owner" | "admin" | "member";
30
+ /** The agent's stored grants (#7864); the guard keeps those `role` holds. */
31
+ grants: string[];
30
32
  headers: { authorization: string };
31
33
  }
32
34
 
33
35
  export function buildAgentCaller(
34
36
  scope: { organizationId: string; workspaceId: string },
35
37
  overrides: Partial<
36
- Pick<AgentCaller, "agentId" | "userId" | "membershipId" | "role">
38
+ Pick<AgentCaller, "agentId" | "userId" | "membershipId" | "role" | "grants">
37
39
  > = {},
38
40
  ): AgentCaller & { key: string } {
39
41
  const key = mintAgentKey();
@@ -42,6 +44,7 @@ export function buildAgentCaller(
42
44
  userId: "user_00000000-0000-7000-8000-00000000a9e2",
43
45
  membershipId: "member_00000000-0000-7000-8000-00000000a9e3",
44
46
  role: "member",
47
+ grants: [],
45
48
  ...scope,
46
49
  ...overrides,
47
50
  key,
@@ -64,6 +67,7 @@ export function agentKeyLookup(
64
67
  workspaceId: caller.workspaceId,
65
68
  organizationId: caller.organizationId,
66
69
  permissionLevel: "member",
70
+ grants: caller.grants,
67
71
  enabled: true,
68
72
  keyIssuedToMembershipId: caller.membershipId,
69
73
  lastActiveAt: null,
@@ -70,8 +70,16 @@ export const prisma = {
70
70
  // #3591 — `lib/agent-store.ts`'s list names each agent's creator by id.
71
71
  findMany: jest.fn(),
72
72
  update: jest.fn(),
73
+ // #7803 — `activateWorkspace` records the remembered workspace on the
74
+ // caller's own row (tenant-context.ts, spec D3).
75
+ updateMany: jest.fn(),
73
76
  create: jest.fn(),
74
77
  },
78
+ // #7803 — `activateWorkspace` writes the session's active-workspace pointer
79
+ // in one `$transaction` with the `user.updateMany` above.
80
+ session: {
81
+ update: jest.fn(),
82
+ },
75
83
  apiToken: {
76
84
  create: jest.fn(),
77
85
  findMany: jest.fn(),
@@ -210,6 +218,8 @@ export const prisma = {
210
218
  findFirst: jest.fn(),
211
219
  findMany: jest.fn(),
212
220
  upsert: jest.fn(),
221
+ // #7844 D7 — `copyFlowDefinition`'s one create-only write.
222
+ create: jest.fn(),
213
223
  },
214
224
  // `deleteMany` — used by the FlowSession retention/cleanup sweep
215
225
  // (flow-engine.ts's `cleanupAbandonedSessions`, #3974 AC3).
@@ -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,
@@ -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
+ });
@@ -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,
@@ -20,6 +20,14 @@
20
20
  * `currentSiteId()` async would edit every one of its dozen non-test call
21
21
  * sites across routes/services/bins, including the two route files NFR1
22
22
  * requires to stay byte-unchanged.
23
+ *
24
+ * The Checkout site stamp (#7868, spec D1/D2): `SITE_STAMP_KEY`,
25
+ * `siteStamp()` and `isForeignSiteStamp()` are the one definition of the
26
+ * `metadata.site` key every hub-created Checkout session carries and the
27
+ * Stripe webhook reads. The stamp is `currentSiteId()`, so writer and reader
28
+ * cannot disagree inside one deployment. A session is foreign only when its
29
+ * stamp is a non-empty string other than `currentSiteId()`; an unstamped
30
+ * session is the hub's own (roadmap D4).
23
31
  */
24
32
  import { resolvedAdapterKind } from "@working-theory/content-adapters";
25
33
  import {
@@ -35,6 +43,35 @@ export function currentSiteId(): string {
35
43
  return process.env.AILK_SITE_ID ?? "default";
36
44
  }
37
45
 
46
+ /** The Checkout session metadata key naming the site a session belongs to (#7868 D1). */
47
+ export const SITE_STAMP_KEY = "site";
48
+
49
+ /**
50
+ * The metadata a hub-created Checkout session carries (#7868 D1/D2):
51
+ * `{ [SITE_STAMP_KEY]: currentSiteId() }`, read per call. Empty when the alias
52
+ * is `""` (an `AILK_SITE_ID` set to the empty string), so Stripe is never sent
53
+ * an empty value (SEC-7868-5) — and the webhook treats that deployment's
54
+ * sessions as unstamped, exactly as today.
55
+ */
56
+ export function siteStamp(): Record<string, string> {
57
+ const site = currentSiteId();
58
+ return site === "" ? {} : { [SITE_STAMP_KEY]: site };
59
+ }
60
+
61
+ /**
62
+ * True only when `metadata[SITE_STAMP_KEY]` is a non-empty string that is not
63
+ * `currentSiteId()` — a session another site on the shared Stripe account
64
+ * opened (#7868 D2). An absent, empty or non-string stamp is the hub's own
65
+ * (roadmap D4: Payment Links, invoices, the portal and pre-stamp sessions
66
+ * carry none). Exact, case-sensitive comparison, as the spokes' own check.
67
+ */
68
+ export function isForeignSiteStamp(
69
+ metadata: Record<string, string> | null | undefined,
70
+ ): boolean {
71
+ const stamp: unknown = metadata?.[SITE_STAMP_KEY];
72
+ return typeof stamp === "string" && stamp !== "" && stamp !== currentSiteId();
73
+ }
74
+
38
75
  /**
39
76
  * The resolved workspace id, memoized after the first SUCCESSFUL resolution
40
77
  * ("resolves the alias once at startup", ownership brief § Layer 2 / #6155