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.
- package/README.md +12 -4
- package/component-catalog.json +250 -36
- package/component-catalog.md +10 -6
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +34 -2
- package/dist/add-route.js +18 -2
- package/dist/apply-design.d.ts +32 -2
- package/dist/apply-design.js +155 -22
- package/dist/cli.js +24 -3
- package/dist/derive-architecture.d.ts +15 -2
- package/dist/derive-architecture.js +34 -6
- package/dist/design-compile.d.ts +70 -2
- package/dist/design-compile.js +97 -1
- package/dist/design-home.d.ts +74 -0
- package/dist/design-home.js +256 -0
- package/dist/design-package.d.ts +39 -2
- package/dist/design-package.js +224 -9
- package/dist/design-presets.d.ts +17 -1
- package/dist/design-presets.js +17 -5
- package/dist/design-scaffold.d.ts +6 -25
- package/dist/design-scaffold.js +26 -74
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/design-checks.d.ts +12 -1
- package/dist/lib/design-checks.js +38 -2
- package/dist/lib/extract-module-bundle.d.ts +11 -0
- package/dist/lib/extract-module-bundle.js +36 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-license-gate.d.ts +9 -0
- package/dist/lib/module-license-gate.js +18 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +6 -0
- package/dist/parse-args.d.ts +22 -0
- package/dist/parse-args.js +73 -0
- package/dist/programmatic.d.ts +20 -0
- package/dist/programmatic.js +180 -35
- package/dist/site-chrome.d.ts +42 -0
- package/dist/site-chrome.js +140 -2
- package/dist/site-fonts.d.ts +21 -0
- package/dist/site-fonts.js +54 -0
- package/dist/site-manifest.d.ts +12 -5
- package/dist/site-manifest.js +25 -11
- package/dist/site-roster.d.ts +16 -0
- package/dist/site-roster.js +81 -0
- package/dist/starter-grid.js +15 -0
- package/dist/surfaces.js +12 -0
- package/dist/sync-routes.js +11 -2
- package/dist/team-profiles.d.ts +29 -0
- package/dist/team-profiles.js +43 -0
- package/dist/topology.d.ts +131 -0
- package/dist/topology.js +533 -0
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
- package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
- package/site-templates/designs/minimalism/DESIGN.md +166 -36
- package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
- package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
- package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
- package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/templates/README.md +2 -0
- package/templates/apps/api/CLAUDE.md +6 -1
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +18 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/lead-routing.ts +148 -29
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/package.json +1 -1
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/CLAUDE.md +12 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/layout.tsx +212 -57
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/page.tsx +37 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
- package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/not-found.tsx +47 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
- package/templates/apps/web/app/openapi.json/route.ts +69 -1
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/profile-back-link.ts +51 -0
- package/templates/apps/web/lib/relay.ts +206 -2
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +15 -0
- package/templates/apps/web/lib/site-fonts.ts +15 -0
- package/templates/apps/web/lib/site-logo.tsx +39 -1
- package/templates/apps/web/lib/site-theme-head.ts +33 -0
- package/templates/apps/web/lib/site-theme.ts +13 -1
- package/templates/apps/web/lib/topology.generated.ts +23 -0
- package/templates/apps/web/messages/en.json +7 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +32 -0
- package/templates/database/__tests__/agents.db.test.ts +65 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/auth/schema.prisma +23 -0
- package/templates/database/inbox/schema.prisma +13 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/package.json +1 -1
- package/versions.json +35 -34
- package/site-templates/designs/minimalism/sample/README.md +0 -48
- package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
- 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| =
|
|
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 /
|
|
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
|
}
|
package/templates/README.md
CHANGED
|
@@ -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-
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|