create-ailk 0.12.1 → 0.13.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/dist/module-architecture.js +27 -0
- package/package.json +3 -3
- package/templates/.env.example +23 -9
- package/templates/apps/api/CLAUDE.md +21 -17
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
- package/templates/apps/api/src/config/index.ts +35 -20
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
- package/templates/apps/api/src/lib/agent-store.ts +521 -0
- package/templates/apps/api/src/lib/spoke-store.ts +13 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
- package/templates/apps/api/src/middleware/auth.ts +219 -5
- package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
- package/templates/apps/api/src/routes/leads/index.ts +14 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
- package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
- package/templates/apps/api/src/server.ts +27 -0
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
- package/templates/apps/api/src/services/audit-log.ts +278 -47
- package/templates/apps/api/src/services/default-tenant.ts +55 -5
- package/templates/apps/api/src/services/leads-inbox.ts +12 -21
- package/templates/apps/api/src/services/project-listings.ts +1 -21
- package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
- package/templates/apps/mcp/CLAUDE.md +44 -5
- package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
- package/templates/apps/mcp/src/http-transport.ts +107 -1
- package/templates/apps/mcp/src/workspace-server.ts +193 -0
- package/templates/apps/web/.env.example +64 -37
- package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
- package/templates/apps/web/lib/relay.ts +279 -0
- package/templates/apps/web/middleware.ts +5 -4
- package/templates/apps/web/next.config.mjs +6 -0
- package/templates/database/CHANGELOG.md +15 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +1 -1
- package/versions.json +34 -34
|
@@ -806,6 +806,19 @@ export const MODULE_SLICES = {
|
|
|
806
806
|
// `__tests__/tenancy-slice-resolution.test.ts` holds the whole gate set.
|
|
807
807
|
"apps/api/src/routes/workspaces/general.ts",
|
|
808
808
|
"apps/api/src/routes/workspaces/members.ts",
|
|
809
|
+
// #3591 PR-B — the agent seat's routes, a sibling of `members.ts` under
|
|
810
|
+
// the same `workspace || multi-tenant` gate, so co-owned with
|
|
811
|
+
// `workspace` for the #7399 reason above.
|
|
812
|
+
"apps/api/src/routes/workspaces/agents.ts",
|
|
813
|
+
// #3591 PR-E — the signed-in workspace MCP tools that twin the agent
|
|
814
|
+
// context, message and submission routes above. Each registers when
|
|
815
|
+
// its twin route does (`workspace || multi-tenant`), so co-owned with
|
|
816
|
+
// `workspace` for the same reason; the application tools' file is
|
|
817
|
+
// co-owned with `signal-collectors` instead, its routes' gate.
|
|
818
|
+
"apps/mcp/src/tools/agent_context.ts",
|
|
819
|
+
"apps/mcp/src/tools/inbox_messages.ts",
|
|
820
|
+
"apps/mcp/src/tools/inbox_submissions.ts",
|
|
821
|
+
"apps/mcp/src/tools/inbox_applications.ts",
|
|
809
822
|
"apps/api/src/lib/tenant-db.ts",
|
|
810
823
|
"apps/api/src/lib/ws-token.ts",
|
|
811
824
|
// #6157 D16 — the magnet Admin authoring surface, co-owned with
|
|
@@ -910,6 +923,16 @@ export const MODULE_SLICES = {
|
|
|
910
923
|
// register under the workspace||multi-tenant gate.
|
|
911
924
|
"apps/api/src/routes/workspaces/general.ts",
|
|
912
925
|
"apps/api/src/routes/workspaces/members.ts",
|
|
926
|
+
// Co-owned with `multi-tenant` (#3591 PR-B), for the reason the two
|
|
927
|
+
// lines above carry.
|
|
928
|
+
"apps/api/src/routes/workspaces/agents.ts",
|
|
929
|
+
// Co-owned with `multi-tenant` (#3591 PR-E): the signed-in MCP tools
|
|
930
|
+
// that twin the agent context, message and submission routes register
|
|
931
|
+
// under the same `workspace || multi-tenant` disjunction as those
|
|
932
|
+
// routes.
|
|
933
|
+
"apps/mcp/src/tools/agent_context.ts",
|
|
934
|
+
"apps/mcp/src/tools/inbox_messages.ts",
|
|
935
|
+
"apps/mcp/src/tools/inbox_submissions.ts",
|
|
913
936
|
// Co-owned with `multi-tenant` (#6216) — see that slice's entry: the
|
|
914
937
|
// route registers under the SAME workspace||multi-tenant gate as the
|
|
915
938
|
// two lines above, and its service function imports `lib/tenant-db.ts`
|
|
@@ -1370,6 +1393,10 @@ export const MODULE_SLICES = {
|
|
|
1370
1393
|
"apps/api/src/bin/seed-waitlist-experiments.ts",
|
|
1371
1394
|
"apps/mcp/src/tools/get_flow.ts",
|
|
1372
1395
|
"apps/mcp/src/tools/submit_flow_step.ts",
|
|
1396
|
+
// #3591 PR-E — the signed-in MCP tools that twin the tenant-scoped
|
|
1397
|
+
// project-listing routes, CO-OWNED with multi-tenant: they register
|
|
1398
|
+
// under the routes' own `multi-tenant || signal-collectors` gate.
|
|
1399
|
+
"apps/mcp/src/tools/inbox_applications.ts",
|
|
1373
1400
|
// ── The review surface (#5677) — CO-OWNED with multi-tenant ──────────
|
|
1374
1401
|
//
|
|
1375
1402
|
// Every path below is ALSO a multi-tenant `ownedFiles` entry and stays
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-ailk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"tar": "^7.5.21"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@working-theory/schema": "0.
|
|
31
|
-
"@working-theory/templates": "0.
|
|
30
|
+
"@working-theory/schema": "0.35.0",
|
|
31
|
+
"@working-theory/templates": "0.35.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
package/templates/.env.example
CHANGED
|
@@ -489,7 +489,8 @@ CONTENT_ADAPTER=mdx
|
|
|
489
489
|
# server-to-server through AILK_API_URL, every visitor of a spoke arrives at
|
|
490
490
|
# the hub from the spoke's one egress IP. A spoke holding a hub-issued key
|
|
491
491
|
# forwards the visitor's IP on its three lead writes (contact, newsletter,
|
|
492
|
-
# callback)
|
|
492
|
+
# callback) and, through its same-origin relay, on availability and booking
|
|
493
|
+
# (#7633), and the hub counts each visitor separately. Runbook:
|
|
493
494
|
# docs/runbooks/site-configuration.md § Hub and spokes: per-visitor lead
|
|
494
495
|
# limits. All four unset = the behaviour before #7394.
|
|
495
496
|
#
|
|
@@ -507,6 +508,13 @@ CONTENT_ADAPTER=mdx
|
|
|
507
508
|
# AILK_RATE_LIMIT_LEADS_PER_MINUTE=10
|
|
508
509
|
# AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE=300
|
|
509
510
|
#
|
|
511
|
+
# HUB (apps/api). The same two limits for the schedule scope (#7633):
|
|
512
|
+
# `GET /v1/schedule/availability` and `POST /v1/schedule/book` count each keyed
|
|
513
|
+
# visitor separately, and each key's ceiling is lower than the leads one
|
|
514
|
+
# because a booking is a real booking. Defaults: 10 and 60.
|
|
515
|
+
# AILK_RATE_LIMIT_SCHEDULE_PER_MINUTE=10
|
|
516
|
+
# AILK_RATE_LIMIT_SCHEDULE_PER_KEY_PER_MINUTE=60
|
|
517
|
+
#
|
|
510
518
|
# HUB (apps/api). The listicle directory read's per-IP limit per minute
|
|
511
519
|
# (#7521) — its own scope, not the leads one, because the web server renders
|
|
512
520
|
# it from one egress address. A whole number from 1 to 999999. Default: 120.
|
|
@@ -527,15 +535,21 @@ CONTENT_ADAPTER=mdx
|
|
|
527
535
|
# workspace. Runbook: docs/runbooks/site-configuration.md § Hub and spokes:
|
|
528
536
|
# registering a spoke. Both unset = the behaviour before #7532.
|
|
529
537
|
#
|
|
530
|
-
# HUB (apps/api).
|
|
531
|
-
#
|
|
538
|
+
# HUB (apps/api). Leave it UNSET: that is the refusing default (#7633). Once
|
|
539
|
+
# the hub's spoke registry holds a row, a header-less call to those surfaces
|
|
540
|
+
# is refused exactly as an unknown key is — no key, no workspace. A deployment
|
|
541
|
+
# with no spoke registered, or no DATABASE_URL, answers keyless calls as
|
|
542
|
+
# before. `true` (or `1`) refuses them even before any spoke is registered.
|
|
543
|
+
# `false` (or `0`) is the EMERGENCY switch that reopens keyless access on
|
|
544
|
+
# every spoke-aware surface; it warns at every boot, and is never a setup or
|
|
545
|
+
# upgrade step (runbook § Incident: a hub refuses a site it should not). Any
|
|
532
546
|
# other value stops the API at boot, and so does `true` with no DATABASE_URL.
|
|
533
|
-
#
|
|
534
|
-
#
|
|
535
|
-
#
|
|
536
|
-
#
|
|
537
|
-
#
|
|
538
|
-
#
|
|
547
|
+
# Before upgrading a hub, every site — the hub's own apps/web and apps/mcp
|
|
548
|
+
# included — runs the same-origin relay (#7644) and carries its own
|
|
549
|
+
# AILK_SPOKE_KEY (runbook § Before you upgrade a hub). On a hub, AILK_SITE_ID
|
|
550
|
+
# must name the PLATFORM OPERATOR's own workspace, never a spoke's: it is
|
|
551
|
+
# where the hub's own site's key is bound, and where a lead lands when the
|
|
552
|
+
# hub cannot yet tell whether it is a hub.
|
|
539
553
|
# AILK_HUB_REQUIRE_SPOKE_KEY=
|
|
540
554
|
#
|
|
541
555
|
# SPOKE (apps/web and apps/mcp, server-only — never NEXT_PUBLIC_). The key the
|
|
@@ -38,36 +38,36 @@ Routes are versioned under `/v1/*`:
|
|
|
38
38
|
|
|
39
39
|
- `/v1/leads/{submit,subscribe,callback}` — lead capture, newsletter subscription, callback request. Where a lead goes is `src/lib/lead-delivery.ts` (#6730): `DATABASE_URL` set → the database (the inbox); no database but `RESEND_API_KEY` + `AILK_LEAD_NOTIFICATION_TO` + `AILK_LEAD_NOTIFICATION_FROM` → the owner email IS the delivery (`persisted: "email"`, a refused send is `503 delivery_failed`); neither outside production → the local file fallback; neither in production → `503 lead_capture_not_configured` before any write. A newsletter signup needs a database (or the local file). A lead is never reported captured when nothing stored or delivered it.
|
|
40
40
|
- `/v1/leads/capture` (GET) — `{ ok: true, data: { contact, newsletter } }`, two booleans saying whether a submit / a subscribe would be accepted (#6730). The web contact page reads it to show the form or the site's contact email. Never names the delivery mode, an address or an env var. Registered at the top level, outside `leadsScope`'s strict per-IP limit. No MCP twin — a recorded exception on the same footing as the anonymous form-lifecycle routes: an agent needs no preflight, because the three lead-write tools already return the 503 when capture is not accepted.
|
|
41
|
-
- `/v1/leads` (GET, `?cursor=&limit=`) — the leads INBOX read (#5678): the caller's OWN sites' leads, newest first, cursor-paginated (`limit` defaults to 50, max 200; `(createdAt desc, id desc)`; `cursor` is the previous page's last row id, and one that does not resolve within the caller's scope is a 400, never a silent page 1 — the API keeps that contract, and the web inbox deliberately softens it on its own side by re-reading page one when a URL-supplied cursor 400s, which is a rendering choice and not a change to this response). Five fields per row and nothing more — `id`, `name` (lifted from `payload.name` when the site's form captured one), `email`, `source`, `createdAt`; `payload`'s other keys, `siteId`, `promotedUserId` and `promotedAt` are never selected (`src/services/leads-inbox.ts` is the only read path). Registered in `authScope` only (auth, no tenant guard) and — alone in that scope — **unconditionally**, with no `isModuleEnabled()` gate: lead capture is a never-gate surface at every level, so reading a lead back cannot be gated either. Identity is `request.userId`; there is no site, org or workspace parameter, so the scope cannot be widened from the wire. It is resolved from the caller's own memberships by `src/services/owner-workspaces.ts`'s `resolveOwnerWorkspaces` — the ONE site→workspace resolution in this app, shared with `lead-promotion.ts` rather than written a second time — and every read filters `siteId ∈ workspaceIds`. Role floor `
|
|
41
|
+
- `/v1/leads` (GET, `?cursor=&limit=`) — the leads INBOX read (#5678): the caller's OWN sites' leads, newest first, cursor-paginated (`limit` defaults to 50, max 200; `(createdAt desc, id desc)`; `cursor` is the previous page's last row id, and one that does not resolve within the caller's scope is a 400, never a silent page 1 — the API keeps that contract, and the web inbox deliberately softens it on its own side by re-reading page one when a URL-supplied cursor 400s, which is a rendering choice and not a change to this response). Five fields per row and nothing more — `id`, `name` (lifted from `payload.name` when the site's form captured one), `email`, `source`, `createdAt`; `payload`'s other keys, `siteId`, `promotedUserId` and `promotedAt` are never selected (`src/services/leads-inbox.ts` is the only read path). Registered in `authScope` only (auth, no tenant guard) and — alone in that scope — **unconditionally**, with no `isModuleEnabled()` gate: lead capture is a never-gate surface at every level, so reading a lead back cannot be gated either. Identity is `request.userId`; there is no site, org or workspace parameter, so the scope cannot be widened from the wire. It is resolved from the caller's own memberships by `src/services/owner-workspaces.ts`'s `resolveOwnerWorkspaces` — the ONE site→workspace resolution in this app, shared with `lead-promotion.ts` rather than written a second time — and every read filters `siteId ∈ workspaceIds`. Role floor: the `inbox-read` permission in `@working-theory/validation`'s permission table (#7636 D2) — every role, a member included (the operator's 2026-10-04 decision; contact PII is the inbox's job); the scope is the UNION of every workspace in the caller's organizations whose role holds `inbox-read`, not one active tenant, because this route registers outside the tenant scope. **Two deployment preconditions decide whether it returns anything, and both fail EMPTY rather than wide:** on a build with no `multi-tenant` module `resolveOwnerWorkspaces` resolves none (a deliberate scope limit since #5677), so there the scope is the default workspace, granted only through `services/default-tenant.ts`'s `resolveDefaultTenantForOperator` — `AILK_OPERATOR_EMAIL` set, the caller's verified email equal to it, a one-workspace deployment, a role holding `inbox-read` — and every other caller gets the empty page (#6730); and the `siteId` the leads were WRITTEN under must equal a workspace id. That `siteId` has TWO sources, and `currentSiteId()` is not the one on the primary path: the three POST capture routes read it from the REQUEST BODY (`routes/leads/index.ts`), which the capture sections declare in content and default to `"default"` (`ContactForm`/`Newsletter`/`StepFlow`, `ext.siteId ?? "default"`), while the recommender's capture path (`routes/flows/recommender.ts`) is the one that uses `currentSiteId()` (`lib/site.ts`, `AILK_SITE_ID ?? "default"`). A workspace id is `wksp_<uuidv7>`, so `siteId ∈ workspaceIds` matches only when the content-declared value — and, for recommender leads, `AILK_SITE_ID` — is set to it (the same convention `waitlist-dashboard.ts` already relies on). The rendered `name` is lifted from `payload.name` and truncated at 200 characters: the write side bounds `payload` at 64 keys and no per-value length, and `POST /v1/leads/submit` is anonymous with no ownership check on `siteId`. No MCP twin — identity-bound (#3591 exception, the same shape as `/v1/workspaces/*` and `/v1/billing/*`).
|
|
42
42
|
- `/v1/flows/:slug` (GET), `/v1/flows/:slug/steps` (POST) — serve a flow config + submit a step's answers; server-side per-step partial-state persistence (#3956). The GET serves the stored config **minus `completionEmail`** (#7519 D5, SEC-7519-I1): `serveFlow` projects the owner's completion-email copy out, while completion reads the stored snapshot and sends it (`sendCompletionEmail`, for a flow with no `deliverables`). Module: `signal-collectors` (D-48, #5366) — registers only when the module is enabled; MCP twins `get_flow`/`submit_flow_step` carry the same `module` tag (D7).
|
|
43
43
|
- `/v1/flows/:slug/aggregate` (GET) — anonymous, site-scoped per-question response counts across all completed respondents for the results reveal (#4771): respondents are completed `FlowSession` rows (not `Lead` rows — a recommending-mode respondent has no Lead until the email moment), question metadata + the chart flag come from the `WaitlistExperiment` register, and only `resultsReveal`-flagged `single`/`multi` questions are returned (`src/services/flow-aggregate.ts`). No individual answer, email, name, session id, or score in the response; no auth. Module: `signal-collectors`. No MCP twin — recorded exception (an agent walking a flow already gets everything it needs from `submit_flow_step`'s own response; "how everyone else answered" changes none of it).
|
|
44
44
|
- `/v1/deliverables/:grantId` (GET) — the capability-token download route for gated PDFs and reports, including client deliverables (#3961 D5); the token itself is the sole gate, no auth preHandler applies (`src/routes/deliverables/index.ts`). Module: `signal-collectors` (D-48, #5366). No MCP twin — identity-bound capability-link download (#3591 exception, the same shape as `/v1/waitlist-signups/me`). **Three grant classes since #6310**, keyed on `grant.assetId`'s ADR-0031 prefix: `playbook:` (unchanged, the FEAT-074 U4b virtual asset); `asset_` — a workspace `Asset`, resolved through the named boundary `src/lib/deliverable-asset-store.ts`, scoped by `grant.workspaceId` (a serve-time tenancy re-check restored for this class — D15) — served `attachment` with an extension pinned to the stored content type, `nosniff`, a non-raster `Content-Security-Policy` (for EVERY content type, including raster — never `inline`), and `Cache-Control: private, no-store` so a deleted asset stops downloading immediately; `dasset_` — the LEGACY `DeliverableAsset` store (unchanged, tried only when a magnet's `assetKey` does not resolve against `Asset`). Every miss on the `asset_` class — a NULL `workspaceId`, no row, a foreign-workspace row — folds into the SAME uniform 404 as every other class; there is deliberately no redirect to the public `GET /v1/assets/:assetId` (that route's year-long `immutable` cache would defeat deletion as a revocation lever, and would collapse every lead's capability onto one workspace-canonical id — spec #6310 §9 SEC-001/SEC-002).
|
|
45
45
|
- `/v1/flows/:slug/save` (POST), `/v1/flows/:slug/checkout` (POST) — the recommender's email moment (#4841): `save` creates the FlowSession + Lead (+ the T+0 DeliverableGrant) for the permalink's answers; `checkout` does the same, records the `FlowCheckout` buyer row at a SERVER-recomputed `price()` (the body carries no amount — `flowCheckoutInputSchema` is strict), and opens a Stripe TEST-mode Checkout Session whose metadata carries the permalink + offer key (`src/routes/flows/recommender.ts`, `src/services/recommender-capture.ts`, `packages/stripe-client`'s `createQuotedCheckoutSession`). Anonymous, rate-limited; a non-`sk_test_` key is refused before any side effect. No MCP twin — recorded exception: an agent walking a flow has no email to give and no card to pay with; the anonymous MCP surface is identity-blind.
|
|
46
|
-
- `/v1/workspaces/:workspaceId/magnets` (GET, POST), `/v1/workspaces/:workspaceId/magnets/:id` (GET, PATCH), `/v1/workspaces/:workspaceId/magnets/:id/signups` (GET), `/v1/workspaces/:workspaceId/magnets/:id/subscribers` (GET), `/v1/workspaces/:workspaceId/magnets/:id/messages` (GET) — the tenant-scoped magnet Admin authoring surface over the flow engine's `FlowDefinition` rows (#6157 S5c, `src/routes/magnets/index.ts`): list (cursor-paginated, fixed 50-row page), create, get, update, and the three per-magnet captured-lead reads (#6246 S6h). Reads and writes only through `tenantDb(tenant).flows` — never `prisma.flowDefinition`, `currentSiteId()`, or `upsertFlowDefinition` (the PUBLIC/seed path below, which the admin path never calls). No DELETE — the reversible alternative is `status: "draft"` (#6156), which stops a NEW walk from starting while an in-flight walk finishes on its pinned snapshot. `slug` lives only inside `config.slug` and is immutable through this surface after create (`400 slug_immutable` on a mismatched PATCH). The two operator-writable config URLs (`abandonEmail.resumeUrl`, `followUps[].cta.href`) pass an exact-origin gate against `FRONTEND_URL` before any write, the same discipline the recommender's checkout redirect already applies (#4912). Role
|
|
46
|
+
- `/v1/workspaces/:workspaceId/magnets` (GET, POST), `/v1/workspaces/:workspaceId/magnets/:id` (GET, PATCH), `/v1/workspaces/:workspaceId/magnets/:id/signups` (GET), `/v1/workspaces/:workspaceId/magnets/:id/subscribers` (GET), `/v1/workspaces/:workspaceId/magnets/:id/messages` (GET) — the tenant-scoped magnet Admin authoring surface over the flow engine's `FlowDefinition` rows (#6157 S5c, `src/routes/magnets/index.ts`): list (cursor-paginated, fixed 50-row page), create, get, update, and the three per-magnet captured-lead reads (#6246 S6h). Reads and writes only through `tenantDb(tenant).flows` — never `prisma.flowDefinition`, `currentSiteId()`, or `upsertFlowDefinition` (the PUBLIC/seed path below, which the admin path never calls). No DELETE — the reversible alternative is `status: "draft"` (#6156), which stops a NEW walk from starting while an in-flight walk finishes on its pinned snapshot. `slug` lives only inside `config.slug` and is immutable through this surface after create (`400 slug_immutable` on a mismatched PATCH). The two operator-writable config URLs (`abandonEmail.resumeUrl`, `followUps[].cta.href`) pass an exact-origin gate against `FRONTEND_URL` before any write, the same discipline the recommender's checkout redirect already applies (#4912). Role floors from `@working-theory/validation`'s permission table (#7636 D2) (D5): list and get serve the full row to `magnets-manage` (owner, admin) and, to a caller holding only `magnets-read` (a member), a LIVE-only summary of exactly `{ id, slug, kind, title, landingPage }` — a member's list is read to completion (at most 20 pages of 50, a `warn` at the cap) and answers `nextCursor: null`, a member's `?cursor=` is a 400, and a draft's get is the same 404 as an absent id — because a magnet's `config` carries `scoring` thresholds, `offers`/`rateTable` and `deliverables` asset keys and no draft is a member's; POST and PATCH need `magnets-manage`; the per-magnet captured-people lists need `inbox-read` (every role), and for a caller without `magnets-manage` a magnet that is not live is the same 404. Both writes declare a route-level `bodyLimit` (256 KiB) and each records one `recordAgentAction` audit row. Module: `multi-tenant || signal-collectors`, the same gate the review-surface dashboard (`/v1/waitlist-experiments/*`, `/v1/project-listings/*`) already registers under. No MCP twin on any of the seven — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes); the public `/v1/flows/*` routes and their two MCP tools are unchanged, pinned by `src/routes/flows/__tests__/parity.test.ts`. **The three new list routes (#6246 S6h)** join every magnet through `Lead.source = 'flow:' + <the resolved magnet's slug>` — the literal string `flow-engine.ts`'s `completeSubmission` stamps on every flow-engine `Lead` row regardless of kind, including `newsletter`/`waitlist` (`Subscriber` is written only by the separate, pre-flow-engine `subscribe_newsletter` path, unrelated to a magnet) — via ONE new `tenantDb(tenant).leads.listBySource(source, { limit })` accessor, an exact-source-equality sibling of #6204's allowlist-scoped `listUnhandledMessages`, served by a new `(workspaceId, source)` composite index on `Lead`. `/signups` expects `kind: "waitlist"`, `/subscribers` expects `"newsletter"`, `/messages` expects `"contact"`; a magnet id absent from the caller's workspace OR resolved to the WRONG kind for the called route is `404 not_found`, indistinguishably (mirrors the existing GET's absent-or-foreign fold). `/signups` and `/subscribers` serve four keys (`id`, `name`, `email`, `createdAt`); `/messages` serves those four plus `handledAt`. Bounded `?limit=` (default 50, max 200), newest-first — mirrors `workspaces/messages.ts`'s own query schema. Applications (Listicle) is deliberately OUT of this surface — `ProjectListing` carries no reverse link to the listicle-kind `FlowDefinition` that captured it, so "scoped to that magnet" cannot be honored without a schema change (split to #6314).
|
|
47
47
|
- `/v1/content/{types,:type,:type/:slug}` — public content reads
|
|
48
48
|
- `/v1/auth/tokens`, `/v1/user/{profile,data/:key}` — local auth + user data
|
|
49
49
|
- `/v1/checkout/sessions` — start a Stripe checkout
|
|
50
|
-
- `/v1/workspaces/:workspaceId/{theme,identity,seo}` (GET/PUT) — per-workspace site config (multi-tenant module)
|
|
51
|
-
- `/v1/workspaces/:workspaceId/meetings` (GET/PUT) — the operator's enabled-slug gate for meeting types (#6183 S9b, `src/routes/workspaces/meetings.ts`). Beside the three siblings above in URL shape and tenant-isolation posture, but registered in its OWN scope, gated on `isModuleEnabled("scheduling")` ALONE — not the `workspace`/`multi-tenant` scope its siblings share, because its GET reaches `services/scheduling/` (see below), which a scaffold without that module does not carry (`tools/lint-slice-registration.ts`, #5701).
|
|
52
|
-
- `/v1/workspaces/:workspaceId/meetings/connection` (GET, PUT, DELETE) — the workspace's own scheduling connection (#7533, `src/routes/workspaces/meetings.ts`; data access and the cipher in `src/services/scheduling/connections.ts`, the only file that touches `prisma.schedulingConnection`). Same registration, scope and tenant guard as the meetings route above. GET (
|
|
53
|
-
- `/v1/workspaces/:workspaceId/general` (GET/PUT) — the workspace's own display name, the Pro `workspace` module's general-settings surface (#5371). Registered under `isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")` alongside the site-config routes above, behind the same tenant guard; the scope is always `request.tenant.workspaceId`, never the path param (the guard 403s a mismatch before any handler runs). PUT takes a strict `{ name }` body (1–200 chars, trimmed) and answers `{ name }`; GET answers `{ name, emailFrom }` (#7519 D6), where `emailFrom` is the deployment's sender (`deliverableSenderAddress()` — `AILK_DELIVERABLE_EMAIL_FROM ?? AILK_LEAD_NOTIFICATION_FROM`, the resolver the fulfillment service sends with) or `null` when neither is set, so the Admin's magnet wizard can show it read-only. Role floor on PUT `owner
|
|
54
|
-
- `/v1/workspaces/:workspaceId/messages` (GET, `?limit=`), `/v1/workspaces/:workspaceId/messages/:leadId` (GET, PATCH) — the Inbox message API (#6216 S7e, `src/routes/workspaces/messages.ts`): the workspace's unhandled messages newest first, the item read (resolves a message handled or not, serving `handledAt`), and the one-way idempotent handled stamp over #6204's `markMessageHandled`. Registered under `isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")`, the same scope as the two routes above. A message is a `Lead` whose `source` is in #6204's closed `MESSAGE_LEAD_SOURCE_LIST` allowlist — checked only in `tenantDb(tenant).leads`, never in this file; a non-message lead is a 404 on the item read and the PATCH and absent from the list, the SAME 404 body as an absent id, another workspace's id, or an unbackfilled `workspaceId`. **One served row across all three routes** — `id`, `name`, `email`, `source`, `createdAt`, `handledAt` — and nothing else: no `payload`, no message body (operator ruling, 2026-09-19; `leads-inbox.ts`'s PII boundary is unchanged, not widened). PATCH takes `{ "handled": true }` only, `.strict()`; there is no un-handle path (#6204 D4), so `{ "handled": false }` is a 400. The stamp records the actor via `tenant.userId` in `Lead.handledBy` — written, not served. Role floor `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
58
|
-
- `/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
|
|
59
|
-
- `/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` (
|
|
60
|
-
- `/v1/billing` (GET), `/v1/billing/portal-session` (POST) — org-scoped billing read + Stripe customer-portal session creation
|
|
50
|
+
- `/v1/workspaces/:workspaceId/{theme,identity,seo}` (GET/PUT) — per-workspace site config (multi-tenant module). PUT needs `workspace-settings` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)), checked before the body parse; GET stays open to every member.
|
|
51
|
+
- `/v1/workspaces/:workspaceId/meetings` (GET/PUT) — the operator's enabled-slug gate for meeting types (#6183 S9b, `src/routes/workspaces/meetings.ts`). Beside the three siblings above in URL shape and tenant-isolation posture, but registered in its OWN scope, gated on `isModuleEnabled("scheduling")` ALONE — not the `workspace`/`multi-tenant` scope its siblings share, because its GET reaches `services/scheduling/` (see below), which a scaffold without that module does not carry (`tools/lint-slice-registration.ts`, #5701). PUT needs `meeting-types-manage` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)) because this blob decides which pages the public site serves. GET answers `meeting-types-manage` with the full join and `meeting-types-read` (every role, a member included) with the ENABLED rows only (#7636 D6) — since it does NOT just return the stored blob, it echoes the deployment's whole provider catalog, including the rows the operator chose not to publish (security review, 2026-09-19), which a member never sees. GET is the one segment in this file's family that does NOT just return the stored blob: it ALSO lists the scheduling provider's own bookable event types (`listEventTypes`, #6182), joined with `enabled: boolean`; an unconfigured/unreachable provider answers the same `SchedulingErrorKind` outcome `/v1/schedule/*` uses, distinct from an empty list. No MCP twin — identity-bound tenant route (#3591 exception).
|
|
52
|
+
- `/v1/workspaces/:workspaceId/meetings/connection` (GET, PUT, DELETE) — the workspace's own scheduling connection (#7533, `src/routes/workspaces/meetings.ts`; data access and the cipher in `src/services/scheduling/connections.ts`, the only file that touches `prisma.schedulingConnection`). Same registration, scope and tenant guard as the meetings route above. GET, PUT and DELETE all need `scheduling-connection` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2), the operator's 2026-10-04 decision overturning SEC-7533-11's owner-only write). GET answers `null` or a summary — `provider`, `baseUrl`, `webhookPath`, `createdAt`, `updatedAt` — with no credential, secret, ciphertext or hint of one. PUT takes a strict `{ provider, apiKey, baseUrl? }`; create and replace are one operation, and every PUT mints a new webhook token and secret, returning the secret this once (`Cache-Control: no-store`) so the operator can paste it into the provider — a replace cuts off the previous entry point. `baseUrl` must be `https://api.cal.com` or the deployment's own `CAL_API_BASE_URL` origin, with no path, query or userinfo (`400 base_url_not_allowed`); only the bare origin is stored. The credential and the secret are AES-256-GCM ciphertext under `SCHEDULING_CREDENTIAL_KEY`, bound to the workspace and the column; with the key unset, PUT answers `503 credential_key_not_configured` before any write. DELETE is idempotent (`{ revoked }`) and unconfigures the workspace at once. Because a connection replaces the account every provider call uses, every successful PUT, and every DELETE that revoked a row, writes one `recordAgentAction` audit row (`scheduling.connection.{connect,replace,remove}`; a PUT is `replace` whenever the workspace was served by any provider before it — its own row, or the env credential on a one-workspace deployment) and emails every OTHER owner of the organization through `@working-theory/email`'s `sendSchedulingConnectionChangedEmail` (#7636 D7). Neither step can fail the write: a failed audit is logged at `error`, and the notify step runs in its own `try`, so the one-time secret is always returned; with no sender address or no Resend key it is skipped with a `warn`. Logs carry the recipient count, never an address. The meetings GET above, and the three anonymous `/v1/schedule/*` provider routes below, resolve their provider through `src/services/scheduling/resolve.ts`: a workspace's own row always wins; `CAL_API_KEY` serves a workspace only on a one-workspace deployment; otherwise `not_configured`. No MCP twin — identity-bound tenant routes (#3591 exception).
|
|
53
|
+
- `/v1/workspaces/:workspaceId/general` (GET/PUT) — the workspace's own display name, the Pro `workspace` module's general-settings surface (#5371). Registered under `isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")` alongside the site-config routes above, behind the same tenant guard; the scope is always `request.tenant.workspaceId`, never the path param (the guard 403s a mismatch before any handler runs). PUT takes a strict `{ name }` body (1–200 chars, trimmed) and answers `{ name }`; GET answers `{ name, emailFrom }` (#7519 D6), where `emailFrom` is the deployment's sender (`deliverableSenderAddress()` — `AILK_DELIVERABLE_EMAIL_FROM ?? AILK_LEAD_NOTIFICATION_FROM`, the resolver the fulfillment service sends with) or `null` when neither is set, so the Admin's magnet wizard can show it read-only. Role floor on PUT: `workspace-settings` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)) — renaming is an org-level settings write; GET stays open to every member. No MCP twin — identity-bound tenant route (#3591 exception, same shape as `/v1/billing/*`).
|
|
54
|
+
- `/v1/workspaces/:workspaceId/messages` (GET, `?limit=`), `/v1/workspaces/:workspaceId/messages/:leadId` (GET, PATCH) — the Inbox message API (#6216 S7e, `src/routes/workspaces/messages.ts`): the workspace's unhandled messages newest first, the item read (resolves a message handled or not, serving `handledAt`), and the one-way idempotent handled stamp over #6204's `markMessageHandled`. Registered under `isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")`, the same scope as the two routes above. A message is a `Lead` whose `source` is in #6204's closed `MESSAGE_LEAD_SOURCE_LIST` allowlist — checked only in `tenantDb(tenant).leads`, never in this file; a non-message lead is a 404 on the item read and the PATCH and absent from the list, the SAME 404 body as an absent id, another workspace's id, or an unbackfilled `workspaceId`. **One served row across all three routes** — `id`, `name`, `email`, `source`, `createdAt`, `handledAt` — and nothing else: no `payload`, no message body (operator ruling, 2026-09-19; `leads-inbox.ts`'s PII boundary is unchanged, not widened). PATCH takes `{ "handled": true }` only, `.strict()`; there is no un-handle path (#6204 D4), so `{ "handled": false }` is a 400. The stamp records the actor via `tenant.userId` in `Lead.handledBy` — written, not served. Role floor: `inbox-read` on the GETs and `inbox-act` on PATCH and accept (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included), the SAME rows `services/inbox-messages.ts` re-checks, checked before any read or write. The role is not self-servable: since #7523 `multi-tenant`'s null-binding fallback grants the default tenant only to the pinned, verified operator, the same check the base `workspace`-only guard applies (see the `AILK_OPERATOR_EMAIL` paragraph below). No anonymous MCP twin — identity-bound tenant routes, and the anonymous `/mcp` carries no caller identity. Each route's twin is a signed-in tool on `/mcp/workspace` (#3591 D13), which forwards the caller's agent key here: `list_inbox_messages` (list), `get_inbox_message` (item read), `mark_inbox_message_done` (PATCH) and `accept_inbox_message` (`POST …/:leadId/accept`).
|
|
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
|
+
- `/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
|
+
- `/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/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
|
+
- `/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
|
+
- `/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).
|
|
61
61
|
- `/v1/schedule/availability` (GET), `/v1/schedule/event-meta` (GET), `/v1/schedule/book` (POST) — anonymous scheduling proxy behind the provider seam (`src/services/scheduling/`); Cal.com v2 today, a self-hosted engine later, same three calls. `event-meta` is the meta-only read (title · duration · description · price) so rendering an event's facts never fetches an availability window (#4633). `CAL_API_KEY` is read server-side only. Which account answers is `resolvePublicSchedulingProvider` (#7533 D4), from `resolvePublicWorkspace()` and never the request: off the database adapter, the env credential as before; a resolved workspace, its own connection (or the env credential on a one-workspace deployment); an unresolved one, `503 not_configured` — fail closed. MCP twins: `list_availability` / `get_event_meta` / `create_booking` (#4416, #4633), unchanged — same arguments, same result shape
|
|
62
62
|
- `/v1/schedule/meeting-types` (GET) — the enabled-meeting-slugs public read (#6183 S9b, `src/routes/schedule/meeting-types.ts`): the session-less read the public `/meet` surface needs to honour the operator's enabled-slug gate (`/v1/workspaces/:workspaceId/meetings`, above), since a public page holds no session. Resolves its workspace via `resolvePublicWorkspace()` (the same fail-closed predicate the public content read uses — no client-suppliable workspace, ever) and reads through a new named public-read boundary, `src/lib/public-meetings-config.ts` (modelled on `lib/asset-store.ts`): one `findUnique` by the resolved `workspaceId`, no write, no list. `{ slugs: string[] | null }` — `null` means "no stored list" (an unconfigured deployment, or the non-database adapter); the caller (`apps/web`'s `enabledMeetingSlugs()`) applies the `SCHEDULING_CAL_LINK`-derived singleton fallback for that state, since that env var is `apps/web`'s, not this app's. Reads no client input. No MCP twin — deferred to a named follow-up rather than silently skipped (the route's own header, §10 Q3 of the spec).
|
|
63
63
|
- `POST /v1/webhooks/calcom` — the scheduling booking-event receiver (#6085, brief H7): verifies the provider's HMAC-SHA256 signature (`x-cal-signature-256`) over the raw body, then normalizes one of six `BOOKING_*` trigger events into an `Appointment` row (`src/services/scheduling/appointments.ts`). `CAL_WEBHOOK_SECRET` is read server-side only (`config/index.ts`); an unset secret answers `503`, never a silent accept. Registered on the same `webhooksScope` as `/v1/webhooks/stripe` (its raw-body content-type parser is what a signed receiver needs), module-conditional (`scheduling`, `src/routes/webhooks/index.ts`). No commerce-claim read: a booking event is not a money event (D-67 puts the payment on the provider's own side). No MCP twin — an inbound receiver for one named provider, not an endpoint an agent could reasonably call, the same footing `POST /v1/webhooks/stripe` already takes.
|
|
64
64
|
- `POST /v1/webhooks/calcom/:connectionToken` — the same receiver for ONE workspace's own connection (#7533 D6, `calcomConnectionWebhookHandler`). The 43-character token in the path names one `SchedulingConnection` row, whose own decrypted secret verifies `x-cal-signature-256` and whose `workspaceId` the Appointment is written to — nothing in the body, query or headers reaches the workspace. A malformed or unknown token, or another provider's connection, is `404 not-found` (logged nothing); a secret that will not decrypt is `503 webhook-not-configured`; the rest of the ladder is the legacy route's, by the same code. Same module gate and `loadSlice` call as the legacy route, its own rate-limit bucket. The legacy `POST /v1/webhooks/calcom` is unchanged: the env account's receiver, writing into the `AILK_SITE_ID` workspace (SEC-7533-2, accepted). No MCP twin — the same inbound-receiver footing as the legacy route.
|
|
65
|
-
- `/v1/schedule/appointments` (GET, `?limit=&cursor=&window=`) — the SITE's own appointments (#6085), tenant-guarded and role-floored `
|
|
65
|
+
- `/v1/schedule/appointments` (GET, `?limit=&cursor=&window=`) — the SITE's own appointments (#6085), tenant-guarded and role-floored at `appointments-read` (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included; the rows carry attendee contact PII, which the operator opened to the member who works the inbox). Registered in its OWN tenant scope, gated on `isModuleEnabled("scheduling")` alone, behind `tenantPreHandlerForBuild()` — NOT the anonymous schedule scope above (a prospect booking a call has no account). Scope is ALWAYS `request.tenant.workspaceId`, NEVER `currentSiteId()` (the receiver's own write scope) — and deliberately carries NO platform-operator escape hatch, unlike `/v1/waitlist-experiments/*`: an appointment book is a seller's customer list. `window` defaults to `upcoming`; `cursor` is the previous page's last row id, and one that does not resolve within the caller's scope is a `400`, never a silent page 1 (the `/v1/waitlist-experiments/*/signups` cursor discipline). Eight fields per row and nothing more — `id`, `startTime`, `endTime`, `title`, `attendeeName`, `attendeeEmail`, `status`, `paid`; `siteId`, `providerBookingUid`, `provider`, the three watermark columns, `paymentInitiatedAt`, `noShow`, `cancelledAt`, `createdAt` and `updatedAt` are never selected (`src/services/scheduling/appointments.ts`'s `listAppointments` is the only read path). No MCP twin — identity-bound tenant route (#3591 exception, the same shape as `/v1/waitlist-experiments/*`).
|
|
66
66
|
- `/v1/aeo/score` (POST), `/v1/aeo/score/example` (GET) — anonymous instant AEO score for a respondent-supplied URL, and the bundled pre-launch example, both fed by the SSRF-guarded crawler at `@working-theory/aeo/fetch` and the pure scorer at `@working-theory/aeo` (`src/services/aeo-score.ts`, #4666). No MCP twin — recorded exception, brief D7 (exposing an arbitrary-URL crawler to anonymous agents is a security-posture decision; `apps/mcp/src/tools/audit.ts` deliberately stays same-origin-locked instead).
|
|
67
|
-
- `/v1/waitlist-experiments` (GET), `/v1/waitlist-experiments/:experimentId` (GET), `/v1/waitlist-experiments/:experimentId/signups` (GET), `/v1/waitlist-experiments/:experimentId/signups/:leadId` (GET) — the operator-dashboard read API for Parallel Waitlist Validation (#4665 D7, B0): tenant-scoped list + rollup, per-waitlist comparison, paginated signups, and a signup drill-down over A1's `WaitlistExperiment` / `WaitlistScore` / `Lead` rows (`src/services/waitlist-dashboard.ts`, #4671). Role floor `
|
|
67
|
+
- `/v1/waitlist-experiments` (GET), `/v1/waitlist-experiments/:experimentId` (GET), `/v1/waitlist-experiments/:experimentId/signups` (GET), `/v1/waitlist-experiments/:experimentId/signups/:leadId` (GET) — the operator-dashboard read API for Parallel Waitlist Validation (#4665 D7, B0): tenant-scoped list + rollup, per-waitlist comparison, paginated signups, and a signup drill-down over A1's `WaitlistExperiment` / `WaitlistScore` / `Lead` rows (`src/services/waitlist-dashboard.ts`, #4671). Role floor `waitlist-read` (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included; signup PII); the platform-operator scope (`isSentinel(organizationId)` and the `platform-read-all-sites` permission, owner only, ALSO requiring the caller's VERIFIED email to equal `AILK_OPERATOR_EMAIL` — fail-closed, PR #4687, compared to the caller since #7523) sees every site, every other caller sees only its own `siteId`. No MCP twin — identity-bound tenant routes (#3591 exception, same shape as `/v1/billing/*`).
|
|
68
68
|
- `/v1/waitlist-signups/me` (GET, `?experimentKey=<slug>`) — the respondent read (#4731): a signed-in, **email-verified** user's OWN newest signup for one experiment (`experimentKey`, `waitlistKey`, `completedAt`, `tier`, `score`, `aeoScore`, `answers.q3` — seven fields, nothing more; `src/services/waitlist-signups.ts` is the only read path). Registered in `authScope` only (auth, no tenant guard — a respondent has no workspace). Identity is `request.userId`; the email is the `User`'s, never a parameter; `emailVerified !== true` → 403 `email_unverified` before any `Lead` read; no match → 404 `signup_not_found`; every read scoped to `currentSiteId()`. Module: `signal-collectors` (D-48, #5366). No MCP twin — identity-bound (#3591 exception).
|
|
69
69
|
- `/v1/flow-checkouts/me` (GET) — the respondent purchases read (#4984, FEAT-022 M2): a signed-in, **email-verified** user's OWN `FlowCheckout` rows, newest first, `[]` when none (`id`, `flowSlug`, `experimentKey`, `offerKey`, `status`, `amountMinor`, `currency`, `period`, `completedAt`, `createdAt` — ten fields, nothing more; `src/services/flow-checkouts.ts` is the only read path). Registered in `authScope` only, beside `/v1/waitlist-signups/me`. Identity is `request.userId`; rows are joined through the promotion link (`Lead.promotedUserId = userId`, `src/services/lead-promotion.ts`) — never by an email, and no query parameter is read; `emailVerified !== true` → 403 `email_unverified` before any `Lead` read; every read scoped to `currentSiteId()`. No MCP twin — identity-bound (#3591 exception).
|
|
70
|
-
- `/v1/project-listings/me` (GET) — the FOUNDER'S OWN listings read (#5295): a signed-in user's own `ProjectListing` rows, newest first, `[]` when none. Registered in `authScope` only, beside `/v1/flow-checkouts/me` — a founder is not a workspace member, so the tenant guard's default-deny would answer 403 for their own submission. Identity is `request.userId`; rows are joined through the owner link (`ProjectListing.ownerUserId = userId`, stamped by `src/services/listing-promotion.ts`) — **never** by `founderEmail`, at any stage, and no query parameter is read. `founderEmail` is an unverified string typed into an anonymous form, so a read-time email match would hand one of two people sharing an address the other's submission. The row shape is `projectListingDraftSchema` (the founder-facing shape the anonymous draft read already uses — no `siteId`, `leadId`, `decidedBy`, `issueUrl`, or `experimentKey`). The route is ADDITIVE:
|
|
70
|
+
- `/v1/project-listings/me` (GET) — the FOUNDER'S OWN listings read (#5295): a signed-in user's own `ProjectListing` rows, newest first, `[]` when none. Registered in `authScope` only, beside `/v1/flow-checkouts/me` — a founder is not a workspace member, so the tenant guard's default-deny would answer 403 for their own submission. Identity is `request.userId`; rows are joined through the owner link (`ProjectListing.ownerUserId = userId`, stamped by `src/services/listing-promotion.ts`) — **never** by `founderEmail`, at any stage, and no query parameter is read. `founderEmail` is an unverified string typed into an anonymous form, so a read-time email match would hand one of two people sharing an address the other's submission. The row shape is `projectListingDraftSchema` (the founder-facing shape the anonymous draft read already uses — no `siteId`, `leadId`, `decidedBy`, `issueUrl`, or `experimentKey`). The route is ADDITIVE: the tenant-scoped list/get/patch routes keep their own permissions (`inbox-read`, `applications-decide`, `listings-edit`), which a founder, holding no Membership, never reaches. Module: `signal-collectors` (D-48, #5366) — not severable from the anonymous listing-application routes below (same static barrel, D4). No MCP twin — identity-bound (#3591 exception).
|
|
71
71
|
- `/v1/project-listings/{start,submit}` (POST), `/v1/project-listings/:id` (PATCH, draft), `/v1/project-listings/public` (GET) — the anonymous founder's-application lifecycle (#4998, reshaped by #5036): draft start/patch/submit plus the public shelves read, identity-blind and rate-limited alongside the leads routes. Module: `signal-collectors`. No MCP twin — anonymous form-lifecycle routes, not agent-facing.
|
|
72
72
|
- `/v1/slack/actions` (POST) — the project-listing operator decision (Slack Approve/Reject, #4999): verifies the Slack signature, applies the decision via `src/services/project-listing-decision.ts`. Module: `signal-collectors`. No MCP twin — a Slack-signed webhook, not agent-facing.
|
|
73
73
|
|
|
@@ -161,7 +161,11 @@ sweep's own DB constraint (never a caller-side lock).
|
|
|
161
161
|
- Don't put session state in `apps/api`. It's stateless.
|
|
162
162
|
- Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
|
|
163
163
|
|
|
164
|
-
Last updated: 2026-10-
|
|
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
|
+
|
|
166
|
+
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
|
+
|
|
168
|
+
Prior: 2026-10-03 — doc-sync: the Inbox submissions and subscribers reads (#7535) — added the `/v1/workspaces/:workspaceId/submissions` and `/v1/workspaces/:workspaceId/subscribers` bullets — verified against `apps/api/src/routes/workspaces/{submissions,subscribers}.ts`, `apps/api/src/services/inbox-submissions.ts`, `apps/api/src/lib/tenant-db.ts` and `apps/api/src/server.ts`.
|
|
165
169
|
|
|
166
170
|
Prior: 2026-10-03 — doc-sync: a scheduling connection per workspace (#7533) — added the `/v1/workspaces/:workspaceId/meetings/connection` and `POST /v1/webhooks/calcom/:connectionToken` bullets and the workspace-aware resolution note on `/v1/schedule/*` — verified against `apps/api/src/routes/workspaces/meetings.ts`, `apps/api/src/routes/webhooks/{calcom,index}.ts`, `apps/api/src/routes/schedule/index.ts` and `apps/api/src/services/scheduling/{connections,resolve}.ts`.
|
|
167
171
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file agent-caller.ts
|
|
3
|
+
*
|
|
4
|
+
* An agent caller for route suites that mock `lib/prisma.js` inline (#3591
|
|
5
|
+
* D8): a key minted at run time — never a key-shaped literal in a fixture —
|
|
6
|
+
* plus the two reads the auth preHandler and the multi-tenant guard make for
|
|
7
|
+
* it, so the suite's existing `agent` and `membership` `findUnique` mocks can
|
|
8
|
+
* answer an agent without changing what they answer anyone else.
|
|
9
|
+
*
|
|
10
|
+
* Usage, inside a suite whose inline prisma mock declares
|
|
11
|
+
* `agent: { findUnique: jest.fn(), update: jest.fn() }`: build a caller with
|
|
12
|
+
* `buildAgentCaller({ organizationId, workspaceId })`, give the `agent`
|
|
13
|
+
* delegate's `findUnique` the `agentKeyLookup(caller)` implementation and the
|
|
14
|
+
* `membership` delegate's `findUnique` the `agentMembershipLookup(caller,
|
|
15
|
+
* existingImplementation)` one, then send the request with `caller.headers`.
|
|
16
|
+
* (This docblock names the delegates in prose: the tenant-scope lint in
|
|
17
|
+
* `architecture.yaml` scans comments too.)
|
|
18
|
+
*/
|
|
19
|
+
import { hashAgentKey, mintAgentKey } from "../../lib/agent-store.js";
|
|
20
|
+
|
|
21
|
+
export interface AgentCaller {
|
|
22
|
+
agentId: string;
|
|
23
|
+
/** The responsible person's user id — the agent acts on their behalf. */
|
|
24
|
+
userId: string;
|
|
25
|
+
membershipId: string;
|
|
26
|
+
organizationId: string;
|
|
27
|
+
workspaceId: string;
|
|
28
|
+
/** The person's own role; the guard clamps the agent to `member`. */
|
|
29
|
+
role: "owner" | "admin" | "member";
|
|
30
|
+
headers: { authorization: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildAgentCaller(
|
|
34
|
+
scope: { organizationId: string; workspaceId: string },
|
|
35
|
+
overrides: Partial<
|
|
36
|
+
Pick<AgentCaller, "agentId" | "userId" | "membershipId" | "role">
|
|
37
|
+
> = {},
|
|
38
|
+
): AgentCaller & { key: string } {
|
|
39
|
+
const key = mintAgentKey();
|
|
40
|
+
return {
|
|
41
|
+
agentId: "agent_00000000-0000-7000-8000-00000000a9e1",
|
|
42
|
+
userId: "user_00000000-0000-7000-8000-00000000a9e2",
|
|
43
|
+
membershipId: "member_00000000-0000-7000-8000-00000000a9e3",
|
|
44
|
+
role: "member",
|
|
45
|
+
...scope,
|
|
46
|
+
...overrides,
|
|
47
|
+
key,
|
|
48
|
+
headers: { authorization: `Bearer ${key}` },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The `agent` delegate's `findUnique` for the auth preHandler: the caller's row by its key's digest. */
|
|
53
|
+
export function agentKeyLookup(
|
|
54
|
+
caller: AgentCaller & { key: string },
|
|
55
|
+
): (args: {
|
|
56
|
+
where: { credentialHash?: string };
|
|
57
|
+
}) => Promise<Record<string, unknown> | null> {
|
|
58
|
+
const credentialHash = hashAgentKey(caller.key);
|
|
59
|
+
return ({ where }) =>
|
|
60
|
+
Promise.resolve(
|
|
61
|
+
where.credentialHash === credentialHash
|
|
62
|
+
? {
|
|
63
|
+
id: caller.agentId,
|
|
64
|
+
workspaceId: caller.workspaceId,
|
|
65
|
+
organizationId: caller.organizationId,
|
|
66
|
+
permissionLevel: "member",
|
|
67
|
+
enabled: true,
|
|
68
|
+
keyIssuedToMembershipId: caller.membershipId,
|
|
69
|
+
lastActiveAt: null,
|
|
70
|
+
responsible: { id: caller.membershipId, userId: caller.userId },
|
|
71
|
+
}
|
|
72
|
+
: null,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type MembershipArgs = {
|
|
77
|
+
where: { organizationId_userId?: { organizationId: string; userId: string } };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The `membership` delegate's `findUnique`, answering the agent's person's membership
|
|
82
|
+
* and deferring every other read to `fallback` (the suite's own mock).
|
|
83
|
+
*/
|
|
84
|
+
export function agentMembershipLookup(
|
|
85
|
+
caller: AgentCaller,
|
|
86
|
+
fallback: (args: MembershipArgs) => unknown,
|
|
87
|
+
): (args: MembershipArgs) => unknown {
|
|
88
|
+
return (args) => {
|
|
89
|
+
const key = args.where.organizationId_userId;
|
|
90
|
+
if (
|
|
91
|
+
key?.organizationId === caller.organizationId &&
|
|
92
|
+
key.userId === caller.userId
|
|
93
|
+
) {
|
|
94
|
+
return Promise.resolve({ id: caller.membershipId, role: caller.role });
|
|
95
|
+
}
|
|
96
|
+
return fallback(args);
|
|
97
|
+
};
|
|
98
|
+
}
|