create-ailk 0.29.0 → 0.30.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 +0 -5
- package/dist/add-route.js +1 -0
- package/dist/cli.js +15 -1
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.js +18 -9
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/surfaces.js +3 -0
- package/dist/topology.js +11 -0
- package/package.json +3 -3
- package/templates/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +7 -3
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +42 -8
- package/templates/apps/web/CLAUDE.md +24 -1
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +10 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +10 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- package/templates/apps/web/lib/mcp-oauth.ts +702 -0
- package/templates/apps/web/messages/en.json +42 -13
- package/templates/database/seed.ts +6 -0
- package/versions.json +48 -48
|
@@ -66,7 +66,7 @@ Routes are versioned under `/v1/*`:
|
|
|
66
66
|
- `/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. **An Offerings offer is never quoted here (#9071 D2):** a `checkout` whose register offer or flow config offer carries `lookupKey` is `409 checkout_not_configured` before any write (the flow config's is checked inside the capture, before the email moment's session and lead — #9970 D9) — logged `{ slug, outcome: "offerings_offer_refused" }`; such an offer is sold on `/pricing` at Stripe's own Price. 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.
|
|
67
67
|
- `/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`. **A PATCH whose `config.slug` differs renames the magnet (#8641 D8):** `tenantDb(tenant).flows.rename` moves the row, this workspace's `Lead.source = 'flow:<slug>'` rows and its `FlowSession.flowSlug` rows to the new slug in one transaction, so its submissions follow it; `FlowCheckout` rows and lead payload copies are never rewritten. Refused before any write: `409 slug_fixed` (the stored slug is `onboarding`, `request-quote`, `contact` (#8661 D5), `job-application` (#8746 D3), `newsletter` (#8847 D4), `recommender` (#9071 D12) or `trial` (#9287 D11), which the server reads by name, or the row belongs to a listing) and `409 slug_reserved` (the new slug is one of those seven); inside the transaction, `409 conflict` first (another magnet holds it — #8734 D1; also the loser of two renames racing for one free slug, which fails on the `(site_id, slug)` key because the definition row is the rename's first write, D4), then `409 slug_bound` (a checkout or a waitlist register of this workspace names either slug, or a session of this workspace already sits at the new one). A lead of this workspace at `flow:<new slug>` that no magnet owns does not refuse: the rename re-stamps it `unclaimed:flow:<new slug>` (`unclaimedLeadSource`, `@working-theory/validation`) so the magnet never adopts it (#8734 D3). A rename records a `magnet.rename` audit row whose summary is `{ from, to, leads, sessions, unclaimed }`. 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. **`customersEnabled` (#8171 D6)** — the member-area customer switch — is served on every full row and written by the PATCH (a PATCH of it alone skips the plan check); the member summary row never carries it. **The completion setting (#8757 D1–D4):** every magnet carries `completion` (`lead` | `invite` | `checkout`, column default `lead`) and `checkoutOffering` (set exactly when `checkout`), two `FlowDefinition` columns closed by three CHECKs and read and written only through `lib/flow-completion-store.ts` (`tenant-db.ts` stays closed under the #8746 contract; `architecture.yaml` lists the store under `paths_scoped`). The migration backfilled `onboarding`, `request-quote` and `job-application` to `invite` by slug (`STANDARD_MAGNET_COMPLETIONS`); a magnet created later is `lead` until #8759. Every full row serves the pair; the member summary never does. The PATCH writes it ALONE — `{ completion, checkoutOffering }` beside any other key is `400 completion_alone`, an offering without a completion or a pair `flowCompletionSettingSchema` refuses `400 validation_failed` — with no plan check, and records `magnet.update` with summary `{ completion }`. `checkout` also needs a person (`403 completion_needs_person` for an agent key), the `commerce` claim (`409 commerce_required`, `503 license-undetermined`) and a key `services/plan-offerings.ts`'s `listPlanOfferings` lists right now (`409 offering_unknown`; a failed read `503 offerings_unavailable`). **The standard onboarding magnet (#8515 D2–D4):** every workspace holds one `diagnostic` magnet at slug `onboarding` (`ONBOARDING_FLOW_SLUG`, `onboardingFlowConfig` in `@working-theory/validation`), `draft` and `customersEnabled: true`, written only by `lib/tenant-db.ts`'s insert-only seed (`createMany` with `skipDuplicates`, so a re-seed creates nothing and never overwrites the owner's edits or an owner's own magnet at that slug): `POST /v1/workspaces` seeds the workspace it just created, after its transaction commits (a failed seed is one `error` line and still a `201`), and the seed-presets CLI (`node dist/bin/seed-presets.js`, run once after deploy) seeds every existing workspace and prints counts only. Its two bare-id exports have one production caller each, pinned by `tests/architecture/onboarding-seed-callers.test.ts`. **The standard Contact form magnet (#8661 D1–D3):** every workspace also holds one `contact` magnet at slug `contact` (`CONTACT_FORM_FLOW_SLUG`, `contactFormFlowConfig`), titled "Contact form", `live`, `customersEnabled: false`, landing page `/contact` — Name, Email, a five-option Subject, Message and a required terms checkbox. It is written by the same insert-only seed, from the same two callers (a failed seed is one `contact-form-seed` `error` line and still a `201`), and by `database/seed.ts` for the default workspace; a re-seed never duplicates it or overwrites an edit. The web /contact page reads it on every request through `GET /v1/flows/contact`, and a submission that used it is stamped `flow:contact`, an Inbox submission under "Contact form". **The standard Job application magnet (#8746 D3, D4):** every workspace also holds one `diagnostic` magnet at slug `job-application` (`JOB_APPLICATION_FLOW_SLUG`, `jobApplicationFlowConfig`), titled "Job application", `live`, `customersEnabled: false`, landing page `/careers` — First name, Last name and Email (required), Phone and LinkedIn URL — with no `completion` field. It is written by the same insert-only seed, from the same two callers (a failed seed is one `job-application-seed` `error` line and still a `201`), and by `database/seed.ts` for the default workspace; `RESERVED_FLOW_SLUGS` carries its slug, so no flow-engine write, spoke seed or new listing takes it. Each web job page `/careers/<slug>` carries an Apply button to `/careers/<slug>/apply` (#8849), whose Free apply page reads it through `GET /v1/flows/job-application` and draws its field step as a one-step form, and a submission is stamped `flow:job-application`, an Inbox submission under "Job application" whose `name` is first + last and whose `job` is the posting's slug. **The standard trial magnet (#9287 D11, #9301):** every workspace also holds one `diagnostic` magnet at slug `trial` (`TRIAL_FLOW_SLUG`, `trialFlowConfig`), titled "Free trial", `live`, `customersEnabled: false`, completion `lead`, landing page `/trial` — First name, Last name, Work email and a required terms box; a site may add a `company`-tagged field, which fills the Person's company. It is written by the same insert-only seed, from the same two callers (a failed seed is one `trial-seed` `error` line and still a `201`), by `database/seed.ts` for the default workspace and by the `20261025120000_trial_magnet` migration for existing workspaces; `RESERVED_FLOW_SLUGS` carries its slug. A completed walk is stamped `flow:trial`, an Inbox submission under "Free trial" whose Person carries the given name, family name and email. 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).
|
|
68
68
|
- `/v1/deliverables/open/:slug` (GET) — an UNGATED file deliverable's bytes, keyed by the flow slug (#8848 D5, `deliverableOpenRoute` in `src/routes/deliverables/index.ts`). Anonymous; the workspace is the caller's (`resolveCallerWorkspace`, keyless through the one-workspace predicate as the flows bullet says; a refused spoke key or an ambiguous deployment `503 tenant_unresolved`), never a request value; no `DATABASE_URL` is `503 deliverables_not_configured`. The flow must be live and carry `deliverable` with `kind: "file"` and `gated: false`; the bytes are its `deliverables` default entry's `Asset`, through `lib/deliverable-asset-store.ts` (a client's file refused), never the legacy store, never a `playbook:` key; served with the grant route's `asset_` headers (`attachment`, pinned extension, `nosniff`, the sandbox CSP, `private, no-store`). Every miss is the file's one uniform 404. Registered in its own encapsulated `deliverableopen` rate-limit scope (60 a minute) with the visitor forward-key digests, and on `FORWARDED_VISITOR_ROUTES`, so a site's proxied reads (`/reports/<slug>/file`) are counted per visitor; the grant route's own budget is unchanged. Module: `signal-collectors`. No MCP twin — a byte download on the grant route's footing, recorded in `tests/parity/README.md`.
|
|
69
|
-
- `/v1/content/{types,search,offerings,pricing,shown-offerings,:type,:type/:slug}` — public content reads. `GET /v1/content/search`, `GET /v1/content/:type` and `GET /v1/content/:type/:slug` are `search_content`'s, `list_pages`' and `read_content`'s twins (#7167, #7168, #7169): each parses its request with the `@working-theory/validation` exports in `content-reads.ts` (`contentSearchQuerySchema`; `contentListParamsSchema` + `contentListQuerySchema`; `contentPageParamsSchema` + `contentPageQuerySchema`) and answers through the shared builder in `@working-theory/content-adapters` the tool also calls (`searchContentItemList`, `listPagesItemList`, `readContentDocument`), so the pair takes the same arguments and returns the same result — the tool adopted the API; the `/v1` wire shapes are unchanged. `GET /v1/content/offerings` (`?type=&locale=`) is `get_offerings`' twin (#7177), answering `{ ok: true, data: { offerings } }` through `listOfferings` in `@working-theory/content-adapters`, the seam the tool also calls. `GET /v1/content/pricing` (`?slug=&locale=`) is `get_pricing`'s twin (#7178), answering `{ ok: true, data: { tiers } | null }` through `getPricingTiers`. `GET /v1/content/shown-offerings` is the `/pricing` page's Offerings read (#8924), answering `{ ok: true, data: { status: "shown", shownSet } | { status: "unconnected" } | { status: "empty" } }` behind the `commerce` claim, refreshing the snapshot on the bounded non-forced path, with no MCP twin. On a connected workspace with a current shown set, `GET /v1/content/pricing` (the `pricing` page) and `GET /v1/content/offerings` (unless `type=service`) answer that set through `readShownOfferings` (exported from `shown-offerings.ts`, the one read all three share, registered with one `shownSetRouteOptions` in `server.ts`) and `@working-theory/content-adapters`' shown-set mapping (`pricingTiersFromShownSet`, `listOfferingsWithShownSet`), with major-unit prices, `interval` and the full `lookupKey` (offerings: the Service pages, then the shown Products in place of the Product pages, each with `prices`); every other case — no connection, no claim, an expired or empty set, a failed read or mapping (one `warn { outcome: "shown_set_failed", errName }`) — answers page content as before; their MCP twins read the same answer through `apiClient.offerings.shown` (#8933).
|
|
69
|
+
- `/v1/content/{types,search,offerings,pricing,shown-offerings,discovery,:type,:type/:slug}` — public content reads. `GET /v1/content/search`, `GET /v1/content/:type` and `GET /v1/content/:type/:slug` are `search_content`'s, `list_pages`' and `read_content`'s twins (#7167, #7168, #7169): each parses its request with the `@working-theory/validation` exports in `content-reads.ts` (`contentSearchQuerySchema`; `contentListParamsSchema` + `contentListQuerySchema`; `contentPageParamsSchema` + `contentPageQuerySchema`) and answers through the shared builder in `@working-theory/content-adapters` the tool also calls (`searchContentItemList`, `listPagesItemList`, `readContentDocument`), so the pair takes the same arguments and returns the same result — the tool adopted the API; the `/v1` wire shapes are unchanged. `GET /v1/content/offerings` (`?type=&locale=`) is `get_offerings`' twin (#7177), answering `{ ok: true, data: { offerings } }` through `listOfferings` in `@working-theory/content-adapters`, the seam the tool also calls. `GET /v1/content/pricing` (`?slug=&locale=`) is `get_pricing`'s twin (#7178), answering `{ ok: true, data: { tiers } | null }` through `getPricingTiers`. `GET /v1/content/shown-offerings` is the `/pricing` page's Offerings read (#8924), answering `{ ok: true, data: { status: "shown", shownSet } | { status: "unconnected" } | { status: "empty" } }` behind the `commerce` claim, refreshing the snapshot on the bounded non-forced path, with no MCP twin. On a connected workspace with a current shown set, `GET /v1/content/pricing` (the `pricing` page) and `GET /v1/content/offerings` (unless `type=service`) answer that set through `readShownOfferings` (exported from `shown-offerings.ts`, the one read all three share, registered with one `shownSetRouteOptions` in `server.ts`) and `@working-theory/content-adapters`' shown-set mapping (`pricingTiersFromShownSet`, `listOfferingsWithShownSet`), with major-unit prices, `interval` and the full `lookupKey` (offerings: the Service pages, then the shown Products in place of the Product pages, each with `prices`); every other case — no connection, no claim, an expired or empty set, a failed read or mapping (one `warn { outcome: "shown_set_failed", errName }`) — answers page content as before; their MCP twins read the same answer through `apiClient.offerings.shown` (#8933). `GET /v1/content/discovery` is the owner's discovery switch as the deployment itself reads it (#10510, `packages/api/src/routes/content/discovery.ts`): no argument (a query string is ignored); with no `DATABASE_URL` it answers `{ ok: true, data: { discoverable: true } }` before any resolver or database import; otherwise the workspace is `resolveCallerWorkspace`'s (a spoke's key, else the one workspace) — `unresolved` is `404 not_found`, `resolved` answers `readSiteDiscovery` (`packages/api/src/services/site-discovery.ts`, the one reader and writer of the `site.discovery` `SiteSetting` row; no row or an unparsable one reads as on), a throw is `500 internal_error` logged by class name only; every answer appends `x-ailk-spoke-key` to `Vary` (`lib/vary-spoke-key.ts`, the one copy the offerings, pricing and shown-offerings routes share). apps/web and apps/mcp read it through `siteDiscoverable()` in `@working-theory/api-client`; no MCP twin, by decision.
|
|
70
70
|
- `/v1/auth/tokens`, `/v1/user/{profile,data/:key}` — local auth + user data
|
|
71
71
|
- `/v1/hub-sign-in/codes` (POST), `/v1/hub-sign-in/token` (POST), `/v1/hub-sign-in/revoke` (POST), `/v1/hub-sign-in/me` (GET) — the hub signs in its spokes (#7894, `packages-paid/api-hub/src/routes/auth/hub-sign-in.ts` over `packages-paid/api-hub/src/services/hub-sign-in.ts` — `@working-theory/api-hub` since #9571, which owns the spoke registry and the sign-in trio and hands them to `buildServer({ hub })` through `packages/api/src/lib/hub.ts`; spec `docs/specs/7894-the-hub-signs-in-its-spec.md`): an authorization code with PKCE between a spoke (a confidential client whose `OAuthClient.clientId` IS its `Spoke.id`) and this hub. `codes` is in `authScope` and answers a COOKIE session only (a personal `ApiToken` is 403, an agent key 403 at the preHandler, a hub credential 404): it mints a single-use 60 s code only for an email-verified person and a callback in the client's registered `redirectUris` (exact match), 30 a user per 10 minutes. `token` and `revoke` are in their OWN scope outside `authScope` (per-IP `rateLimitMiddleware`, scope `hubsignin`, 60/min; plus a per-client bucket of 120 exchanges a minute keyed on the VERIFIED client): the caller authenticates with `Authorization: Basic clientId:secret` AND `x-ailk-spoke-key`, which must resolve to the unrevoked spoke whose id is that client — either alone is `401 invalid_client`. `token` consumes the code first and exactly once, then checks the client, the callback and the PKCE verifier (`400 invalid_grant` for any failure, the code gone either way) and issues `ailk_hst_…`, a 24 h credential stored only as a SHA-256 digest in `OAuthAuthorization` (one row per person and spoke, so a new sign-in rotates it); it writes no `Session` row and sets no cookie. `revoke` ends a credential or burns an unexpired code of that client (`{ revoked }`, never an error). `me` answers the credential's person, read live, and is reachable only by that credential, and `billingCustomerId` — the Stripe customer of the caller's Person in the spoke's workspace, read only while that Person's `userId` is the caller and the id is the caller's own Stripe binding, else `null`; since #8047 no anonymous checkout writes it, and the one writer is the hub site's signed-in checkout, so on a spoke's workspace it is `null` until a later writer exists. Its first read links the caller's Person under the verified-email rule (`stampPersonUser` through `resolveMemberPersonId`), so this GET can write. **The credential is admitted by one branch of `middleware/auth.ts`** — only beside the same spoke's key, only while its grant is unrevoked and unexpired, its client enabled and its person's email verified, and only on `HUB_SESSION_ROUTES`, an exact list of 26 method + registered-URL strings, never a prefix (#8220): `GET /v1/hub-sign-in/me`, #7877's three member reads, #8169's eight member reads, #8170's six member writes, #8435's two contact routes, #8438's two consent routes, #8765's account-agreements pair and #8515's onboarding read and write; every other route answers `404` with no handler run (the status the account area's `read()` loads as "nothing for this caller"), and the list is extended only by a reviewed edit. **The per-person budget (#8220 D2, SEC-8169-4):** each person at each spoke gets 60 requests and 10 writes a minute and 30 writes an hour (a fixed hour window from the first admitted write, so up to twice that across a window edge), keyed on the VERIFIED spoke and person and charged only after admission (a refused credential or an off-list route never gets a bucket); over budget is `429 rate_limited` with `Retry-After`. The write limits refuse only writes, and a refused request is not counted. Every line the request logger writes for an admitted request after admission carries `spokeId` (never the key or the credential); a helper logging through the module-level `logger` carries no request binding. The server-wide per-IP limiter still counts all of one spoke's members together — a named residual whose follow-up (the visitor forward key on member calls) must land before Phase 3 (#8163) ships to a live spoke. A bearer write skips the origin check, so the cross-site-request protection for the member writes is the spoke's: its server actions that send a sealed credential carry it (Phase 3, #8163; SEC-8220-11). A completed password reset (`apps/web/auth.ts` `onPasswordReset`) revokes every credential of that person. The operator registers a spoke's client with `spokes sign-in-client --id <spoke_id> --redirect-uri <url>` (`packages-paid/api-hub/src/bin/spokes.ts`; `--disable` switches it off) — `packages-paid/api-hub/src/lib/hub-sign-in-store.ts` is the only file that names `prisma.oAuthClient` and `prisma.oAuthAuthorization`. **The `hub` claim (#9571 D4; #9568 D5):** read only inside `@working-theory/api-hub` (`src/claim.ts`), through `checkLicense()`, registry first — with no `DATABASE_URL` or no registered spoke the gate answers `not-serving` and never reads the licence; with a spoke and the claim, `claimed`; with a spoke and no claim, `unclaimed`, and `codes`, `token`, `me` (a verified credential) and `clients/:clientId` answer `403 feature_required` naming `hub`; an unreadable licence is `undetermined`, `503 license_undetermined` with `Retry-After: 300`. `revoke` is never gated; a definite answer is held 300 s, `not-serving` 30 s, `undetermined` never; `spokes register` refuses without the claim (`hub-claim-missing`). Lead capture, the resolver and magnet completion never read it, and a boot without the package answers as an empty registry (the four routes `404`). No MCP twin for any of the four — a credential exchange between a site and its hub is not an agent-facing call (#3591 exception, the `/v1/auth/tokens` footing). The browser step lives on the hub's web app: `apps/web/app/api/auth/hub/authorize/route.ts`.
|
|
72
72
|
- `/v1/checkout/sessions` — start a Stripe checkout. **Two paths (#8931, `src/routes/checkout/sessions.ts` over `src/services/offerings/checkout-authorization.ts`; spec `docs/specs/8931-offerings-checkout-authorization-server-side-spec.md`; contract A1–A11, A13).** The path comes from `resolvePublicWorkspace()` and the workspace's own Organization alone — no body field names either: a resolved workspace whose Organization has a Stripe connection takes the **Offerings path**; a presented `x-ailk-spoke-key` the resolver refuses is `503 { error: "tenant_unresolved" }` with no Stripe call; a corrupt connection, or a database read that fails while choosing, is `502 checkout-unavailable`; everything else (keyless, `ambiguous`, off the database adapter, no Organization, no connection) takes the **env path**, today's code. **A spoke authenticates with its key on a hub that sets `OSS_API_INTERNAL_SECRET` (#9910; spec `docs/specs/9910-a-spoke-can-start-an-spec.md`):** a call with no `x-internal-auth` header and an `x-ailk-spoke-key` `resolveCallerSite` resolves to a registered, unrevoked spoke is a spoke call — it sells only that spoke's own workspace (a path naming any other is `401`), and its `successUrl` and `cancelUrl` must be on the origin of the spoke's enabled hub sign-in client's first redirect URI (`registeredSpokeOrigins`, read through `installedHub()`), else `400 validation-error`; a spoke with no such origin is `500 config-error`, both before any Stripe call. A refused key, no credential at all, or a presented `x-internal-auth` that does not match is `401 internal-auth-required` — the keyless and wrong-secret `401` before any read — and a matching `x-internal-auth` keeps its redirects. On a hub without the secret every caller is unchanged. **The env path is the hub's own-products path only, never a spoke's (#9142):** a spoke sells only through its `apps/web` Offerings buy route, which calls this route only for a lookup key its connected workspace's shown set holds, so a spoke's buy always takes the Offerings path; the spoke's own env-key buy route and Stripe webhook are retired. The Offerings path charges `chargeWriteBudget` scope `offeringscheckout` before the body is read — 10 a minute per `request.ip` and 60 per workspace (`OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE`), charged one at a time with the IP first, so a request its IP bucket refuses never spends the workspace's, whatever the process's mode — answering `429 { error: "rate_limited", retryAfterSeconds }` with `Retry-After`. It parses `productCode` (the full lookup key) by the one grammar first, builds the connection's own client (`stripeClientForOrganization` with `{ storedOnly: true }` — the stored connection only, never the environment key, so a connection deleted between the path read and this one is `502`, #9346), reads org-enabled ∩ workspace-shown from the rows (`getSellableProductIds`, never the snapshot), looks the key up live on that client, and sells only when the Price's own Product is in the set — and, for a workspace saved per Price (#9070 D5), the Price itself is one of its stored shown Prices (`getSellableProductIds`' `priceIds`; `null` for a row saved per Product) — and `normalizeCatalog` keeps that Price under that key and period. Every miss — ungrammatical, not enabled, not shown, moved, archived, nonexistent — is the one `404 { error: "unknown-product-code" }`, never echoing the key; Stripe, the rows or the connection failing is `502`, never the snapshot or the env key. The session is anonymous (no `customer`, no signed-in buyer, so no `ailk_user_id`), sells the authorized Price id, allows promotion codes, `customer_creation: "always"` in payment mode, and is stamped by the server `metadata` and `subscription_data.metadata` `{ site: <workspaceId>, ailk_offering: <lookup key> }`; it is recorded through `recordCheckoutSession` before the route answers, and a failed record expires it on the same client and answers `502`. **The env path refuses an Offerings product (A9):** `createCheckoutSession` gets `{ refuseProduct: isOfferingsProduct }`, so a Price whose Product carries `metadata.ailk_group` (or whose Product did not expand) is the same `404`. **The Offerings path refuses the hub's own plans (#9338 DD5):** right after the grammar parse, a key whose plan begins `ailk-` (`HUB_PLAN_PREFIX`, any period — `ailk-pro`, `ailk-pro-plus` and any hub plan added later, #9968) is the same `404`, before any read or Stripe call, for every Organization but the one whose fulfilment hook mints licences (#9383 DD5, `fulfilmentFor(organizationId)?.mintsLicences`, read with no Stripe call) — anywhere else such a sale would stamp `ailk_offering` with no licence minted for it. Every hub plan's lookup key begins `ailk-` (`ailk-<plan>_<period>`, as `PLAN_PURCHASE_LOOKUP_KEYS` builds them): a hub plan keyed outside that namespace is outside this refusal. The request schema and the `201 { sessionId, url }` body are unchanged, so the `start_purchase` parity row is too. **`GET /v1/checkout/sessions/:id` (A13):** when `DATABASE_URL` is set, a session `readCheckoutSessionRecord` holds is read with that record's Organization's own client (keeping the environment fallback, so a session Start trial recorded on the owner's environment key stays readable), only in the record's mode; its line-item names pass the catalog's T1 `normalizeText`; a record that cannot be read, or a connection gone, unusable or in another mode, is `502`, never the env path; no record is today's env read. Logs carry the workspace or Organization, an 8-character session prefix and a fixed reason only.
|
|
@@ -93,7 +93,9 @@ Routes are versioned under `/v1/*`:
|
|
|
93
93
|
- `/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).
|
|
94
94
|
- `GET /v1/workspaces/:workspaceId/knowledge` — the workspace knowledge read (#9683, spec `docs/specs/9683-agent-session-audit-knowledge-spec.md`; `packages-paid/api-workspace/src/routes/workspaces/knowledge.ts`, `// oss: false`): what the business offers, runs and serves, as `workspaceKnowledgeSchema` — `offerings` (the shown offering set, only while the commerce claim is held), `magnets` (the newest flows page's `live` rows), `meetingTypes` (the enabled slugs minus `privateSlugs`, titled by slug), `sitesServed` (the installed hub's live spokes for this workspace, read only when the hub's own gate answers `claimed`, through the optional `HubRegistry.listLiveSpokesForWorkspace`) and `strategyDocs` (`lib/strategy-docs.ts`, `[]` until a store exists) — every key always present, each entry an id, a title and a summary (`null` when it fails its check; an entry whose id or title fails is skipped and counted). Takes no query. Gate: `allows(people-read) || allows(people-memory-propose)` — a person member holds both; an agent only by grant. Every authenticated call writes one `workspace.knowledge.read` audit row (counts only; an agent's carries its session, thread `workspace:<workspaceId>`); `Cache-Control: private, no-store`. Same registration scope as the routes above (`workspace || multi-tenant`, `tenant:settings`). MCP twin: `get_workspace_knowledge` on `/mcp/workspace`.
|
|
95
95
|
- `/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 nine permissions an owner or admin granted it one by one — `content-edit`, `content-publish`, `assets-write`, `magnets-manage`, `meeting-types-manage`, `people-read` since #852, `member-pieces-manage` since #7878, and `people-memory-propose` and `inbox-act` since #9679 (`@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 nine check `allows(tenant, "<permission>")`, which reads the grants — except that the six existing inbox `inbox-act` checks still read `can(tenant.role, …)` until #9686 moves them, and `people-memory-propose` is read today only by the workspace knowledge read (#9683, beside `people-read`); 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. The member-area writes audit every caller through `recordAuditAction`, whose actor names an agent as the agent. 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`).
|
|
96
|
-
-
|
|
96
|
+
- `GET /v1/workspaces/:workspaceId/clients` and `DELETE /v1/workspaces/:workspaceId/clients/:clientId[?userId=]` — the workspace's Connected clients (#10393, `src/routes/workspaces/clients.ts` over `src/lib/connected-clients-store.ts`, `// oss: false`). The GET lists, as one table, the workspace's live OAuth authorizations (one row per client and person, from the token rows bound to this workspace; `lastUsedAt` is the newest sign-in or refresh) and its enabled agent keys, each row `kind`, `id`, `client.name`, `person`, `scopes`, `lastUsedAt` and never a token or key. A person holding `agents-manage` (owner, admin) sees every row; anyone else, an agent principal included, sees only their own person's. The DELETE refuses every agent caller (403), ends an OAuth authorization by deleting its token rows for this workspace only (a caller without `agents-manage` may name only themselves — another person's row is the same 404 as a missing one) and an agent key by switching it off (`agents-manage`), touches no session, and writes one audit row and one `connected-client-revoked` log line. The GET's MCP twin is `list_connected_clients`; the DELETE has no tool, by decision. Same registration scope as the routes above (`workspace || multi-tenant`).
|
|
97
|
+
- `GET /v1/workspaces/:workspaceId/discovery` and `PUT /v1/workspaces/:workspaceId/discovery` — the owner's discovery switch, whether AI and search tools can find the workspace's site (#10510, `src/routes/workspaces/discovery.ts`, `// oss: false`, over `packages/api/src/services/site-discovery.ts`). Both parse `:workspaceId` with `inboxWorkspaceParamsSchema` (`400 validation_failed`) and act on `request.tenant.workspaceId` only. The GET needs any tenant, an agent included, and answers `{ ok: true, data: { discoverable } }`; the PUT needs `workspace-settings` (owner, admin — an agent is refused, `403`), checked before the strict `{ discoverable: boolean }` body (`400 validation_failed` on anything else), and answers the stored value. The GET's MCP twin is `get_site_discovery`; the PUT has no tool, by decision. Same registration scope as the routes above (`workspace || multi-tenant`, one `tenant:settings` row).
|
|
98
|
+
- `/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 `X-Draft-Version`; `If-Match` still accepted for one release, #10264), 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).
|
|
97
99
|
- `/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. **A client's file is never in the library's public life (#7877 D18):** the default `GET …/assets` listing leaves out every asset tagged `private` — only `?tag=private` lists them (the Admin's Compose) — and the public read answers its uniform 404 for an asset tagged `private`, attached to any member-area message, or (#8171 D3) named by a delivered piece version (one `pieceVersion.count`, since the tag can be removed after delivery), so such a file's bytes are served only by its owner's member reads.
|
|
98
100
|
- `/v1/subscription/me` (GET) — `?include=billing` (#8171 D7), from a cookie session only, adds the optional `planName` and `paymentMethod` (brand, last four, expiry) read from Stripe through `lib/stripe.ts`'s `readCustomerBillingSummary` for the caller's own resolved customer: cached per customer for 60 s (a failure too), one read in flight per customer, bounded at 3 s; a failure leaves both keys absent, a value outside the schema's bounds is `null`, and no id is served or logged. A bearer token, another value or none answers exactly as before.
|
|
99
101
|
- `/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). **The read's `commerce` field (#8907 D14)** is the deployment's commerce claim, `claimed | unclaimed | undetermined` (`resolveCommerceClaim()`; a rejected read is `undetermined` and the answer stays `200`) — what the Admin's Billing card reads (#8908 D10). **`?include=billing` (#9067 D1)** — exactly that string, never an array — adds `details` only for a caller holding `billing-manage` (the owner; an agent never qualifies): `licenseExpiresAt` (`checkLicense().expiresAt`, `null` on a failed read) always, and for an Organization with a Stripe customer `renewsAt` (the current subscription's first item's `current_period_end`, only while it will renew), `sites` (its quantity) and `paymentMethod` (brand, last four, expiry), read through `lib/stripe.ts`'s `readCustomerBillingSummary` by `services/entitlements.ts`'s `readOrgBillingSummary` — cached per customer for 60 s (a failure too), one read in flight per customer, bounded at 3 s; a failure answers `details: { licenseExpiresAt }` and still `200`, a value outside `orgBillingDetailsSchema`'s bounds is `null`, no id is served or logged, and the reply carries `Cache-Control: private, no-store`. Every other request is answered exactly as before, with no Stripe or licence read. **`POST /v1/billing/plan-checkout` (#9067 D3, `routes/billing/plan-checkout.ts`)** is the Billing tab's Purchase plan: a subscription Checkout for the caller's own Organization's plan (`planPurchaseLookupKey` — `ailk-pro_monthly` / `ailk-pro-plus_monthly`, `PLAN_PURCHASE_LOOKUP_KEYS` built by the Offerings lookup-key grammar, #9338 DD1), sandbox-only like the portal. **It sells only an Offerings plan (#9338 DD4):** `createCheckoutSession` gets a `refuseProduct` that refuses every Product whose `metadata.ailk_group` is not exactly `plan` (`isOfferingsPlanProduct`, `services/offerings/catalog.ts`), checked on the same expanded Price it sells; a refused or unexpanded Product answers `500 plan_price_missing`. Check order: tenant → `billing-manage` (403 before any lookup) → write budget (`chargeWriteBudget` scope `plancheckout`, 10 an hour per Organization and per person, `429` with `Retry-After`) → the commerce claim (`409 purchase_unavailable`, `503 license_undetermined`) → a strict `{ returnUrl }` (`bodyLimit` 4 KiB, `400 validation_failed`) → the return URL (canonical, no userinfo, query, fragment or `//` path, `https:` or a local `http:`, on `config.TRUSTED_ORIGINS`; `400 return_url_refused`, `500 config-error` when none is trusted) → no customer yet (`409 billing_customer_exists`) and a purchasable tier (`409 plan_not_purchasable`) → `STRIPE_SECRET_KEY` and `assertSandboxKey` → one payable session (an open earlier one is expired first, a completed one is `409 purchase_pending`) → `createCheckoutSession` with `mode: "subscription"`, 1–50 sites, `metadata` and `subscriptionMetadata` `{ organizationId, ...siteStamp() }` and `PLATFORM_TERMS_OF_SERVICE`, never a `buyer` → a Checkout URL only → the session id recorded on `Organization.planCheckoutSessionId` by compare-and-set (a lost race or a failed write expires the new session). It answers `{ url }` with `Cache-Control: private, no-store`; no body or log line carries the return URL, an id or a Stripe message. **The webhook's Organization arm binds only that recorded session (#9067 D13):** a `checkout.session.completed` with `metadata.organizationId` links `session.customer` through one guarded `updateMany` on `{ id, planCheckoutSessionId: session.id, billingCustomerId: null }` (clearing the session); anything else — an unrecorded session, a bound customer, every `customer.created` — writes nothing and logs `org_bind_unproven`. No MCP twin for the purchase — owner-only, recorded in `tests/parity/README.md`.
|
|
@@ -232,7 +234,9 @@ sweep's own DB constraint (never a caller-side lock).
|
|
|
232
234
|
- Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
|
|
233
235
|
- Don't construct Fastify with `logger: true` or any `logger:` option. Pass `loggerInstance` built from `createLogger` in `@working-theory/observability` (see `packages/api/src/server.ts`). Fastify's own pino carries none of the redaction.
|
|
234
236
|
|
|
235
|
-
Last updated: 2026-
|
|
237
|
+
Last updated: 2026-11-01 — #10510: the public content reads bullet names `GET /v1/content/discovery`, and a bullet for `GET`/`PUT /v1/workspaces/:workspaceId/discovery` — verified against `packages/api/src/routes/content/discovery.ts`, `packages/api/src/services/site-discovery.ts` and `packages-paid/api-workspace/src/routes/workspaces/discovery.ts`.
|
|
238
|
+
|
|
239
|
+
Prior: 2026-10-26 — #8047 (#8241): the member purchases bullet matches a purchase's lead on the caller's verified `User.email`, never the Person's — verified against `packages/api/src/lib/member-store.ts` and `packages-paid/api-workspace/src/routes/member/purchases.ts`.
|
|
236
240
|
|
|
237
241
|
Prior: 2026-10-26 — #8047: the `/v1/hub-sign-in/me` entry names its `billingCustomerId` answer and its first-read Person link, the People bullet names the one `Person.billingCustomerId` writer, Get trial reads the recorded Start-trial session, and `## What NOT to do` gains the Fastify `logger:` rule (#5239 AC-3) — verified against `packages/api/src/services/{person,checkout-buyer}.ts`, `packages/api/src/routes/webhooks/stripe.ts`, `packages-paid/api-hub/src/services/hub-sign-in.ts`, `packages-paid/api-workspace/src/services/trial.ts` and `packages/api/src/server.ts`.
|
|
238
242
|
|
|
@@ -21,8 +21,18 @@ MCP_HTTP_PATH=/mcp
|
|
|
21
21
|
# Public URL of the HTTP transport (used by /.well-known/mcp.json discovery).
|
|
22
22
|
# Set this to the externally-reachable URL of the MCP server.
|
|
23
23
|
# Example: https://mcp.yourdomain.com
|
|
24
|
+
# With BETTER_AUTH_URL below, it also turns on the OAuth sign-in on
|
|
25
|
+
# /mcp/workspace: its origin names the protected resource (#10391).
|
|
24
26
|
MCP_PUBLIC_HTTP_URL=
|
|
25
27
|
|
|
28
|
+
# The site's own origin — the OAuth authorization server MCP clients sign in
|
|
29
|
+
# through (apps/web's BETTER_AUTH_URL, the same value). Set together with
|
|
30
|
+
# MCP_PUBLIC_HTTP_URL, /mcp/workspace accepts the site's access tokens beside
|
|
31
|
+
# agent keys, validating each against the site's token-info lookup. Both must
|
|
32
|
+
# be https: (or http: on localhost); unset, /mcp/workspace takes agent keys only.
|
|
33
|
+
# A spoke has no authorization server of its own: leave it unset there.
|
|
34
|
+
# BETTER_AUTH_URL=
|
|
35
|
+
|
|
26
36
|
# ── Rate limiting ──────────────────────────────────────────────────────────────
|
|
27
37
|
|
|
28
38
|
# Max requests per minute per IP for read tools.
|
|
@@ -85,3 +95,13 @@ MCP_ABUSE_BLOCKED_USER_AGENTS=
|
|
|
85
95
|
|
|
86
96
|
# Required ONLY when CONTENT_ADAPTER=database.
|
|
87
97
|
# DATABASE_URL=
|
|
98
|
+
|
|
99
|
+
# ── Spoke key ─────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
# SPOKE (server-only). The key the hub's `spokes register` printed. Sent as
|
|
102
|
+
# `x-ailk-spoke-key` on every server-side call to AILK_API_URL, and only when
|
|
103
|
+
# that is https (or loopback). Not the same value as AILK_VISITOR_FORWARD_KEY.
|
|
104
|
+
# On a hub, the hub's own apps/mcp carries the hub site's own key: a keyless
|
|
105
|
+
# call is refused there, so the owner's discovery switch (#10510) cannot be
|
|
106
|
+
# read and the public /mcp stays on.
|
|
107
|
+
# AILK_SPOKE_KEY=
|
|
@@ -57,6 +57,13 @@ now mean `packages/mcp-server/src/<file>`.
|
|
|
57
57
|
instance answers), `false`/`0` stateful (sessions in one process's memory). Unset, the
|
|
58
58
|
Vercel entry is stateless and `src/index.ts` is stateful. Any other value fails the boot.
|
|
59
59
|
The Vercel entry needs stateless: a session kept in one instance is lost on the next.
|
|
60
|
+
- **The owner's discovery switch (#10510 D5).** `httpTransportConfigFromEnv()` sets
|
|
61
|
+
`discoverable: siteDiscoverable` (`@working-theory/api-client`), so while the owner has hidden
|
|
62
|
+
the site, GET, POST and DELETE on the anonymous path answer Fastify's own 404 — an off server
|
|
63
|
+
looks like none — before the abuse guard or any tool runs. It is read per request through the
|
|
64
|
+
reader's 15-second shared answer; a failed read keeps the last answer, then on.
|
|
65
|
+
`/mcp/workspace`, its protected-resource metadata and stdio are untouched. A hub's own tool
|
|
66
|
+
server needs the hub site's `AILK_SPOKE_KEY`, or its hub refuses the read and the server stays on.
|
|
60
67
|
- **Content root.** Every content tool and `audit` take their root from `mcpContentRoot()`
|
|
61
68
|
(`lib/content-adapter.ts`): `AILK_CONTENT_ROOT`, else `<cwd>/content`, else
|
|
62
69
|
`<cwd>/../../content`. Never build a content path from `process.cwd()` in a tool — the one
|
|
@@ -130,9 +137,11 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
|
|
|
130
137
|
|
|
131
138
|
- **22 in the `TOOLS` array / parity test**: the `TOOLS` array in
|
|
132
139
|
`packages/metadata/src/mcp-tool-catalog.ts` defines 22 anonymous tools; the
|
|
133
|
-
parity test counts that full array. It also holds the thirty-
|
|
140
|
+
parity test counts that full array. It also holds the thirty-seven signed-in
|
|
134
141
|
workspace tools (#3591; the content-suggestion pair `draft_page_suggestions`
|
|
135
|
-
and `decide_page_suggestion` since #10218
|
|
142
|
+
and `decide_page_suggestion` since #10218; the draft pair `save_draft` and
|
|
143
|
+
`read_report` since #10336; the knowledge-base pair `read_knowledge` and
|
|
144
|
+
`read_knowledge_doc` since #10345), which no anonymous view returns — see § The
|
|
136
145
|
signed-in workspace surface below.
|
|
137
146
|
- **20 registered on a KEYLESS deployment, 21 on a commerce-claimed one,
|
|
138
147
|
while the Free level is held or over HTTP — 21 and 22 over stdio once it is
|
|
@@ -178,12 +187,14 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
178
187
|
- **apps/mcp checks only the key's shape.** The key is attached as the SDK's `req.auth` for that
|
|
179
188
|
request alone and reaches a tool as `extra.authInfo.token`. apps/api is the one place it is
|
|
180
189
|
checked, and its tenant guard and the caller's clamped role decide every answer.
|
|
181
|
-
- **Thirty-
|
|
190
|
+
- **Thirty-eight tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
|
|
182
191
|
and `modulesAnyOf` (the modules any one of which registers the tool — its twin route's gate):
|
|
183
192
|
`get_agent_context`, (#9683) `get_workspace_knowledge` — twin of `GET
|
|
184
193
|
/v1/workspaces/:workspaceId/knowledge`: the business's shown offerings, live magnets, listed
|
|
185
194
|
meeting types, sites served and strategy documents, read with the `people-read` or
|
|
186
|
-
`people-memory-propose` grant —
|
|
195
|
+
`people-memory-propose` grant — (#10393) `list_connected_clients` — twin of `GET
|
|
196
|
+
/v1/workspaces/:workspaceId/clients`: the AI clients and agent keys connected to the workspace,
|
|
197
|
+
only the caller's own person's for an agent; revoking has no tool — the five message tools (#9686 added `list_inbox_threads`, twin of
|
|
187
198
|
`GET …/messages/threads`: the inbox's threads, 25 to a page with a count per view; Done now
|
|
188
199
|
archives the message's thread and accept no longer marks it done, and an agent reaches any
|
|
189
200
|
inbox tool only with the `inbox-act` grant), the three submission tools, the three
|
|
@@ -198,18 +209,35 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
198
209
|
tools, `list_proposals` and `create_proposal`, twinning `GET`/`POST
|
|
199
210
|
/v1/workspaces/:workspaceId/people/:personId/proposals` with the same grant: a proposal is
|
|
200
211
|
private to one client, `payments` says whether approval leads to a Stripe page, and an agent's
|
|
201
|
-
price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person`. Each takes its route's params, query and body and returns the route's
|
|
212
|
+
price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person` — and (#10218) the content-suggestion pair, `draft_page_suggestions` and `decide_page_suggestion` — and (#10336) the draft pair, `save_draft` and `read_report`, twinning `PATCH`/`GET …/content/:type/:slug` with the `content-edit` grant: `save_draft` takes the page's `content` whole as the PATCH body and its `version` as `X-Draft-Version`, writes a draft version that never goes live (no tool publishes; a person does), and answers 409 `conflict` with `currentVersion` when the draft moved; `read_report` returns the draft tip, the version a save must send, its open suggestions and the route's check report (`ready`, `needs repair` or `needs your words`), computed by the route and never by the tool — and (#10345) the knowledge-base pair, `read_knowledge` and `read_knowledge_doc`, twinning `GET …/knowledge-base` and `GET …/knowledge-base/:type/:slug` with no grant (`docs-read` is every role's): the docs a named member may read (`userId`, by default the agent's own person), at the lower of the agent's person's role and that member's, so naming someone only narrows; Live does not gate them and a disabled doc never appears; `filter: "workspace-memory"` answers the always-loaded set with its `totalTokens` and an 8000-token `warning`. No workspace tool sets Live, widens Audience or re-enables a doc — those routes answer an agent 403 `agent_refused`, which reaches a tool as the code and status. Each takes its route's params, query and body and returns the route's
|
|
202
213
|
`data`. They are in the parity guard like every other tool, and never in
|
|
203
214
|
`/.well-known/mcp.json`.
|
|
215
|
+
- **The OAuth bearer branch (#10391).** When `BETTER_AUTH_URL` (the site's origin, its OAuth
|
|
216
|
+
2.1 authorization server, #10357) and `MCP_PUBLIC_HTTP_URL` are both `https:` (or `http:` on a
|
|
217
|
+
loopback host), `/mcp/workspace` also accepts the site's access token — only the plugin's
|
|
218
|
+
shape, `Bearer` + 32 ASCII letters, so an arbitrary string is 401 with no lookup. Each request
|
|
219
|
+
is validated by one `GET <BETTER_AUTH_URL>/api/auth/mcp/token-info` (`oauth-bearer.ts`: 5 s
|
|
220
|
+
timeout, no redirect followed, nothing cached — a revoked token fails its next call). A valid
|
|
221
|
+
token reaches tools as `AuthInfo` (`clientId`, the `ailk:*` scopes the live grants still name,
|
|
222
|
+
`expiresAt`, `resource`, and `extra: { kind: "oauth", userId, workspaceId, actingRole }`), its
|
|
223
|
+
`token` forwarded to apps/api like a key; a `tools/call` whose `arguments.workspaceId` is not
|
|
224
|
+
the token's workspace is 401 before any tool runs. While the branch is on, every 401 carries
|
|
225
|
+
`WWW-Authenticate: Bearer [error="invalid_token", ]resource_metadata="…"`, the RFC 9728
|
|
226
|
+
document is served at `/.well-known/oauth-protected-resource${path}/workspace` (no root
|
|
227
|
+
document), and an authorization server that cannot answer is `503 authorization_unavailable`
|
|
228
|
+
with `Retry-After: 5` — never a pass and never a 401. Issuer and resource come from env only,
|
|
229
|
+
never a request's `Host`. Either name unset, the route takes agent keys only, exactly as before;
|
|
230
|
+
a spoke has no authorization server, so it leaves `BETTER_AUTH_URL` unset. `/mcp` is untouched.
|
|
204
231
|
- **Handlers forward, nothing else.** `workspace-server.ts` resolves each tool from
|
|
205
232
|
`@working-theory/mcp-workspace`'s `workspaceTools` (`agent_context.ts`, `knowledge.ts`, `inbox_messages.ts`,
|
|
206
|
-
`inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, all
|
|
233
|
+
`inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, `page_suggestions.ts`, `content_draft.ts`, `knowledge_base.ts`, all
|
|
207
234
|
`// oss: false`) and, for the two prospect-channel settings tools, `@working-theory/mcp-scheduling`'s
|
|
208
235
|
`workspaceTools` (`prospect_channel_settings.ts`, #9749 — the pack the scheduling module carries, so a
|
|
209
236
|
scheduling-only build serves what it lists), loaded through the site's generated registry, as `server.ts` resolves the
|
|
210
237
|
module-gated tools from their packs (#9460). Each calls `@working-theory/api-client`'s `inbox` surface
|
|
211
238
|
(the people-list, notes, person-thread, person-memory and proposals tools, its `people` surface; the
|
|
212
|
-
Slack status and prospect-channel tools, its `slack` surface
|
|
239
|
+
Slack status and prospect-channel tools, its `slack` surface; the suggestion and draft tools, its
|
|
240
|
+
`pageSuggestions` and `contentDrafts` surfaces) with the key. A refusal from the
|
|
213
241
|
route is an MCP tool error naming the route's `error.code` and HTTP status only.
|
|
214
242
|
- **Never log or return the key.** Don't log or serialise `extra`, `extra.authInfo` or
|
|
215
243
|
`extra.requestInfo` — the SDK puts the raw `authorization` header there.
|
|
@@ -285,7 +313,13 @@ Example (lead routing):
|
|
|
285
313
|
- Don't add a tool without the matching `apps/api` endpoint.
|
|
286
314
|
- Don't put business logic in tool handlers — they're adapters.
|
|
287
315
|
|
|
288
|
-
Last updated: 2026-10-
|
|
316
|
+
Last updated: 2026-10-31 — #10391 (D2–D6): `/mcp/workspace` gains the OAuth bearer branch beside the agent key — one `token-info` lookup per request, the `WWW-Authenticate` challenge and the RFC 9728 protected-resource document, on only with `BETTER_AUTH_URL` and `MCP_PUBLIC_HTTP_URL` — verified against `packages/mcp-server/src/{http-transport,oauth-bearer}.ts`.
|
|
317
|
+
|
|
318
|
+
Prior: 2026-10-31 — #10345 (D9, D12): `read_knowledge` and `read_knowledge_doc` (`mcp-workspace`'s `knowledge_base.ts`) twin the per-user knowledge-base read — the signed-in workspace surface is thirty-seven tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
319
|
+
|
|
320
|
+
Prior: 2026-10-31 — #10336 (D1, D2, D10): `save_draft` and `read_report` (`mcp-workspace`'s `content_draft.ts`) twin the content-write draft save and read — the signed-in workspace surface is thirty-five tools, and the guard's `wire` row option binds the save's body and header — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
321
|
+
|
|
322
|
+
Prior: 2026-10-29 — #10202 (D8): `get_redirect` (`mcp-workspace`'s `redirect.ts`, the pack's first anonymous handler) twins `GET /v1/redirects` under `module: "content-write"` and `feature: "workspace"` — 22 in `TOOLS`, 20 registered keyless and 21 commerce-claimed while Free is held, seven `tier: "pro"` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
289
323
|
|
|
290
324
|
Prior: 2026-10-26 — #9965 (D12, D13): `create_project` is served over stdio only — the HTTP transport, the default, never registers it, and the web manifest always drops it — and `trustOneHop` trusts its hop only on Vercel — verified against `packages/mcp-server/src/{server,run,serverless-app}.ts` and `apps/web/app/.well-known/mcp.json/route.ts`.
|
|
291
325
|
|
|
@@ -164,6 +164,16 @@ route's rule, since the API honours consent only with it where
|
|
|
164
164
|
`OSS_API_INTERNAL_SECRET` is set. A password reset or a sign-out revokes the
|
|
165
165
|
grant, so the next sign-in asks again. `ref` is in both telemetry scrub lists.
|
|
166
166
|
|
|
167
|
+
## The MCP authorization server
|
|
168
|
+
|
|
169
|
+
**The site is the OAuth 2.1 authorization server an MCP client signs in through (#10357).** `auth.ts`'s block between `// mcp-oauth:begin` and `// mcp-oauth:end` installs better-auth's `mcp` plugin (dynamic client registration, S256 PKCE, one-hour access tokens, seven-day refresh tokens) and AILK's `lib/mcp-oauth.ts` beside it, only when `BETTER_AUTH_URL` pins the issuer. The plugin is configured, never patched: every AILK behaviour is a hook or an endpoint in that file.
|
|
170
|
+
|
|
171
|
+
- **Tokens are opaque, validated by lookup** (decision (a)): no JWKS and no `jwt` plugin. `GET /api/auth/mcp/token-info` is the self-check: the bound workspace, the member-clamped `actingRole` and the grants the person's live role still holds — never a token.
|
|
172
|
+
- **Every authorization asks** (decision (b)): a before-hook forces `prompt=consent`, so the plugin always redirects to `/consent` (an `(auth)` page, `AUTH_SEGMENTS`). The page reads only `consent_code` and asks `GET /mcp/consent-details`; Allow posts `POST /mcp/consent`, which binds one `ailk:workspace:<id>` and only role-held `ailk:*` scopes. `/mcp/token` refuses any code that did not pass it or whose approving session has ended, any `code` not shaped like one (so no AILK verification row can be named and consumed), and any non-string body field. The plugin's own `/oauth2/consent`, `/mcp/get-session` and discovery copy are `disabledPaths`; the site serves `/.well-known/oauth-authorization-server` at its root.
|
|
173
|
+
- **Rotation and revocation**: a refresh deletes the used pair and leaves a tombstone, so a replayed refresh token ends the person's line for that client. `POST /mcp/revoke` (RFC 7009) and the person's own `POST /mcp/revoke-client` delete tokens.
|
|
174
|
+
- **A password reset or change deletes every MCP token** of the person: `onPasswordReset`'s guarded blocks, which end the person's sessions first (so none can mint a token in between) and then their tokens (`reason: "session_revocation_failed"` / `"mcp_token_revocation_failed"` on failure, never a rejection) and an after-hook on `/change-password`.
|
|
175
|
+
- Two plugin facts AILK works around: `/mcp/register` answers a raw `Response`, so no after-hook runs on it (the sweep runs before), and the plugin's `/mcp/token` treats any `oauthApplication.type` but `public` as confidential, so `POST /mcp/consent` re-derives it from the stored secret (`public` for a client with no secret, the column defaulting to `web`).
|
|
176
|
+
|
|
167
177
|
## Contact page
|
|
168
178
|
|
|
169
179
|
The contact page (`apps/web/app/[locale]/contact/page.tsx`) renders the
|
|
@@ -602,6 +612,15 @@ A list page whose frontmatter sets `navOnly: true` (valid on a registry list typ
|
|
|
602
612
|
- **Not honoured.** The docs-blog module's Fumadocs `/blog` and `/docs`; `DownloadsList` (`/downloads` already redirects to `/reports`); `LocationsList` (no route). `/sitemap.xml` and `/llms.txt` still list a Nav-only list's URL.
|
|
603
613
|
- **The nav is untouched.** `lib/nav-config.ts` is generated from route surfaces, not from list pages, so the heading and its children stay in the menu.
|
|
604
614
|
|
|
615
|
+
## The owner's discovery switch
|
|
616
|
+
|
|
617
|
+
**One switch hides the site from AI and search tools (#10510).** The Admin's MCP tab writes the workspace's `discoverable` setting; this app reads it through `siteDiscoverable()` from `@working-theory/api-client` (`GET /v1/content/discovery`, the workspace the API resolves for this site's key), shared per server instance for 15 seconds, the last answer on a failed read and on when there is none — it never rejects.
|
|
618
|
+
|
|
619
|
+
- **The seven handlers.** `robots.txt`, `sitemap.xml`, `llms.txt` (both copies — the docs + blog module's `lib/slices/docs-blog/llms-route.ts` too), `.well-known/mcp.json`, `.well-known/ai-plugin.json` and `openapi.json` are `force-dynamic` and read it once per request, after the checks each already makes and before any other work. `createSitemapHandler` and `createLlmsHandler` take a `discoverable` dep (default `siteDiscoverable`); every test caller passes one, so no suite reads a hub.
|
|
620
|
+
- **On** is today, byte for byte. **Off**: robots.txt answers STEALTH's `User-agent: *` / `Disallow: /` with no Sitemap line; the other five answer 404 with no body (mcp.json keeps its CORS headers; its `OPTIONS` is unchanged). Every off answer is `cache-control: no-store`, so a crawler asks again.
|
|
621
|
+
- **Hidden wins.** With `STEALTH` on, or no public page (`PUBLIC_PAGES` false), robots.txt answers as before and makes no read; sitemap.xml and llms.txt 404 before it.
|
|
622
|
+
- **Pages are never hidden.** No page, layout or `middleware.ts` reads the switch, and `<meta robots>` and `X-Robots-Tag` stay `STEALTH`'s alone: off is a preference about discovery, not access control. `STEALTH` remains the hard, deploy-time hide.
|
|
623
|
+
|
|
605
624
|
## AEO & performance
|
|
606
625
|
|
|
607
626
|
- Static rendering is the default. Don't introduce client rendering without a reason.
|
|
@@ -618,7 +637,11 @@ A list page whose frontmatter sets `navOnly: true` (valid on a registry list typ
|
|
|
618
637
|
- Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
|
|
619
638
|
- Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
|
|
620
639
|
|
|
621
|
-
Last updated: 2026-
|
|
640
|
+
Last updated: 2026-11-01 — § The owner's discovery switch: the six discovery files read the workspace's `discoverable` setting per request through `siteDiscoverable()`, off answering robots.txt's disallow-all and 404 for the rest, never cached (#10510) — verified against `app/{robots.txt,sitemap.xml,llms.txt,openapi.json}/route.ts`, `app/.well-known/{mcp.json,ai-plugin.json}/route.ts`, `lib/slices/docs-blog/llms-route.ts` and `packages/api-client/src/discovery.ts`.
|
|
641
|
+
|
|
642
|
+
Prior: 2026-10-30 — § The MCP authorization server: the site's OAuth 2.1 authorization server, its consent step, rotation and revocation (#10357) — verified against `auth.ts`, `lib/mcp-oauth.ts`, `app/[locale]/(auth)/consent/` and `app/.well-known/oauth-authorization-server/route.ts`.
|
|
643
|
+
|
|
644
|
+
Prior: 2026-10-28 — § Nav-only lists (`navOnly`): a Nav-only list's route answers 308 to its first child and 404 with none (#10200) — verified against `lib/hub-children.tsx` and the nine list routes it is called from beside the hubs.
|
|
622
645
|
|
|
623
646
|
Prior: 2026-10-27 — § Where the services live (`services.path`): the services base, one category level, the 308 from `/services` and the `update-page` base move (#10043) — verified against `lib/services-path.ts`, `lib/service-listing.ts`, `middleware.ts`, `next.config.mjs` and `app/[locale]/services/`.
|
|
624
647
|
|
|
@@ -28,9 +28,15 @@
|
|
|
28
28
|
* See `docs/security/agent-manifest.md` for the full consumer guidance.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
32
|
+
|
|
31
33
|
import { SITE_DESCRIPTION, SITE_NAME } from "../../../lib/site-brand";
|
|
32
34
|
import { resolveBaseUrl } from "../../../src/lib/routes";
|
|
33
35
|
|
|
36
|
+
// #10510 D4 — read per request: the owner's discovery switch is a runtime
|
|
37
|
+
// setting a prerendered file could never follow.
|
|
38
|
+
export const dynamic = "force-dynamic";
|
|
39
|
+
|
|
34
40
|
/** Soft cap to detect runaway `description_for_model` growth. */
|
|
35
41
|
export const DESCRIPTION_FOR_MODEL_SOFT_CAP_CHARS = 8_000;
|
|
36
42
|
|
|
@@ -59,7 +65,9 @@ export function validateDescriptionForModel(description: string): boolean {
|
|
|
59
65
|
// are flagged before the first request lands.
|
|
60
66
|
validateDescriptionForModel(MANIFEST_DESCRIPTION_FOR_MODEL);
|
|
61
67
|
|
|
62
|
-
export function GET(): Response {
|
|
68
|
+
export async function GET(): Promise<Response> {
|
|
69
|
+
// #10510 D4 — hidden by the owner: 404, never cached.
|
|
70
|
+
if (!(await siteDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
|
|
63
71
|
const baseUrl = resolveBaseUrl();
|
|
64
72
|
|
|
65
73
|
const manifest = {
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* Issue #8057 — `description` resolves from SITE_DESCRIPTION
|
|
25
25
|
* (`siteIdentity.description` in _site.mdx → neutral default) instead of the
|
|
26
26
|
* template's own blurb on every site.
|
|
27
|
+
* Issue #10392 — `authorization` names where an OAuth client signs in to the
|
|
28
|
+
* workspace tools, when the site pins its issuer and advertises its MCP host.
|
|
27
29
|
*/
|
|
28
30
|
|
|
29
31
|
import {
|
|
@@ -78,6 +80,16 @@ export interface McpManifest {
|
|
|
78
80
|
ailk: {
|
|
79
81
|
version: string;
|
|
80
82
|
};
|
|
83
|
+
/**
|
|
84
|
+
* Where an OAuth client signs in to the workspace tools (#10392 D8). Present
|
|
85
|
+
* only when the site pins its issuer and advertises its MCP host.
|
|
86
|
+
*/
|
|
87
|
+
authorization?: {
|
|
88
|
+
/** RFC 8414: the site's authorization-server metadata. */
|
|
89
|
+
authorizationServerMetadata: string;
|
|
90
|
+
/** RFC 9728: the workspace endpoint's protected-resource metadata. */
|
|
91
|
+
protectedResourceMetadata: string;
|
|
92
|
+
};
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
export interface BuildMcpManifestOptions {
|
|
@@ -91,6 +103,13 @@ export interface BuildMcpManifestOptions {
|
|
|
91
103
|
* Typically sourced from the MCP_PUBLIC_HTTP_URL env var in the web app.
|
|
92
104
|
*/
|
|
93
105
|
mcpHttpUrl?: string;
|
|
106
|
+
/**
|
|
107
|
+
* Optional: the site's OAuth issuer — the origin apps/web serves its
|
|
108
|
+
* authorization server from (`BETTER_AUTH_URL`, #10392 D8). With
|
|
109
|
+
* `mcpHttpUrl`, the manifest names the two OAuth metadata URLs. Injected,
|
|
110
|
+
* like `mcpHttpUrl`, so the builder stays pure.
|
|
111
|
+
*/
|
|
112
|
+
authIssuer?: string;
|
|
94
113
|
/**
|
|
95
114
|
* Whether this deployment holds the `commerce` add-on licence claim
|
|
96
115
|
* (#5971). Add-on-gated tools — today only `start_purchase` — are advertised
|
|
@@ -125,6 +144,42 @@ export interface BuildMcpManifestOptions {
|
|
|
125
144
|
/** MCP HTTP transport version this server implements. */
|
|
126
145
|
const MCP_HTTP_VERSION = "2025-03-26";
|
|
127
146
|
|
|
147
|
+
const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
148
|
+
|
|
149
|
+
/** A URL a client may trust for OAuth: `https:`, or `http:` on a loopback host. */
|
|
150
|
+
function trustableUrl(value: string | undefined): URL | null {
|
|
151
|
+
if (!value) return null;
|
|
152
|
+
let url: URL;
|
|
153
|
+
try {
|
|
154
|
+
url = new URL(value);
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (url.protocol === "https:") return url;
|
|
159
|
+
if (url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname)) return url;
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The two OAuth metadata URLs (#10392 D8), or `undefined` unless both the
|
|
165
|
+
* issuer and the MCP host are trustable — so the manifest never advertises a
|
|
166
|
+
* sign-in surface the site has not turned on. The protected resource is the
|
|
167
|
+
* workspace endpoint, RFC 9728's path-inserted location on the MCP origin.
|
|
168
|
+
*/
|
|
169
|
+
function authorizationFor(
|
|
170
|
+
authIssuer: string | undefined,
|
|
171
|
+
mcpHttpUrl: string | undefined,
|
|
172
|
+
): McpManifest["authorization"] {
|
|
173
|
+
const issuer = trustableUrl(authIssuer);
|
|
174
|
+
const mcp = trustableUrl(mcpHttpUrl);
|
|
175
|
+
if (!issuer || !mcp) return undefined;
|
|
176
|
+
const mcpPath = mcp.pathname.replace(/\/+$/, "");
|
|
177
|
+
return {
|
|
178
|
+
authorizationServerMetadata: new URL("/.well-known/oauth-authorization-server", issuer).href,
|
|
179
|
+
protectedResourceMetadata: `${mcp.origin}/.well-known/oauth-protected-resource${mcpPath}/workspace`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
128
183
|
/**
|
|
129
184
|
* Builds the /.well-known/mcp.json manifest. Pure: same input → same output.
|
|
130
185
|
*/
|
|
@@ -142,6 +197,8 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
|
|
|
142
197
|
});
|
|
143
198
|
}
|
|
144
199
|
|
|
200
|
+
const authorization = authorizationFor(opts.authIssuer, opts.mcpHttpUrl);
|
|
201
|
+
|
|
145
202
|
return {
|
|
146
203
|
schemaVersion: "1",
|
|
147
204
|
name: SITE_NAME,
|
|
@@ -160,5 +217,6 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
|
|
|
160
217
|
},
|
|
161
218
|
transports,
|
|
162
219
|
ailk: { version: opts.ailkVersion },
|
|
220
|
+
...(authorization ? { authorization } : {}),
|
|
163
221
|
};
|
|
164
222
|
}
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* is appropriate per the MCP discovery spec.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
32
|
+
|
|
31
33
|
import { UNSERVED_MCP_TOOLS } from "../../../lib/topology.generated";
|
|
32
34
|
import packageJson from "../../../package.json";
|
|
33
35
|
import {
|
|
@@ -37,6 +39,10 @@ import {
|
|
|
37
39
|
|
|
38
40
|
import { buildMcpManifest } from "./build-manifest";
|
|
39
41
|
|
|
42
|
+
// #10510 D4 — read per request: the owner's discovery switch is a runtime
|
|
43
|
+
// setting a prerendered file could never follow.
|
|
44
|
+
export const dynamic = "force-dynamic";
|
|
45
|
+
|
|
40
46
|
const CORS_HEADERS = {
|
|
41
47
|
"Access-Control-Allow-Origin": "*",
|
|
42
48
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
@@ -51,11 +57,20 @@ export function OPTIONS(): Response {
|
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
export async function GET(): Promise<Response> {
|
|
60
|
+
// #10510 D4 — hidden by the owner: 404, never cached, before the licence read.
|
|
61
|
+
if (!(await siteDiscoverable())) {
|
|
62
|
+
return new Response(null, {
|
|
63
|
+
status: 404,
|
|
64
|
+
headers: { "cache-control": "no-store", ...CORS_HEADERS },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
54
67
|
// MCP_PUBLIC_HTTP_URL advertises the standalone HTTP transport endpoint
|
|
55
68
|
// (apps/mcp running in HTTP mode). When set, the manifest names it as
|
|
56
69
|
// `mcpEndpoint` and as an http transport descriptor; when unset, it
|
|
57
70
|
// advertises no MCP URL at all (#7396).
|
|
58
71
|
const mcpHttpUrlEnv = process.env["MCP_PUBLIC_HTTP_URL"];
|
|
72
|
+
// With it, the site's pinned issuer turns on the OAuth metadata (#10392).
|
|
73
|
+
const authIssuerEnv = process.env["BETTER_AUTH_URL"];
|
|
59
74
|
const heldFeatures = await resolveLicenceFeatures();
|
|
60
75
|
|
|
61
76
|
const manifest = buildMcpManifest({
|
|
@@ -71,6 +86,7 @@ export async function GET(): Promise<Response> {
|
|
|
71
86
|
// Spread conditional to satisfy exactOptionalPropertyTypes: omit the key
|
|
72
87
|
// entirely when the env var is not set rather than passing undefined.
|
|
73
88
|
...(mcpHttpUrlEnv ? { mcpHttpUrl: mcpHttpUrlEnv } : {}),
|
|
89
|
+
...(authIssuerEnv ? { authIssuer: authIssuerEnv } : {}),
|
|
74
90
|
});
|
|
75
91
|
|
|
76
92
|
return Response.json(manifest, {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /.well-known/oauth-authorization-server
|
|
3
|
+
*
|
|
4
|
+
* The site's RFC 8414 authorization-server metadata (#10357 D7), at the root
|
|
5
|
+
* where RFC 8414 and MCP client discovery look for it. It is the `mcp`
|
|
6
|
+
* plugin's own document — whose HTTP copy under `/api/auth` is disabled, so
|
|
7
|
+
* there is one — with four AILK edits:
|
|
8
|
+
*
|
|
9
|
+
* - `revocation_endpoint` (AILK's RFC 7009 route) and its auth methods;
|
|
10
|
+
* - `scopes_supported` limited to `offline_access` and the `ailk:*` scopes;
|
|
11
|
+
* - `jwks_uri` and `userinfo_endpoint` removed — neither route exists; the
|
|
12
|
+
* plugin's tokens are opaque and validated by lookup (D1);
|
|
13
|
+
* - the grant types, PKCE method and RFC 9207 `iss` support stated.
|
|
14
|
+
*
|
|
15
|
+
* 404 when the authorization server is not installed (no pinned
|
|
16
|
+
* BETTER_AUTH_URL — `auth.ts` D4). Public, credential-free, cross-origin by
|
|
17
|
+
* design, like `.well-known/mcp.json`.
|
|
18
|
+
*/
|
|
19
|
+
import { MCP_SCOPES } from "@working-theory/validation";
|
|
20
|
+
|
|
21
|
+
import { auth } from "../../../auth";
|
|
22
|
+
|
|
23
|
+
const HEADERS = {
|
|
24
|
+
"Cache-Control": "public, max-age=300",
|
|
25
|
+
"Access-Control-Allow-Origin": "*",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
type Metadata = Record<string, unknown> & {
|
|
29
|
+
token_endpoint?: string;
|
|
30
|
+
token_endpoint_auth_methods_supported?: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function GET(): Promise<Response> {
|
|
34
|
+
const read: unknown = Reflect.get(auth.api, "getMcpOAuthConfig");
|
|
35
|
+
if (typeof read !== "function") return new Response(null, { status: 404 });
|
|
36
|
+
const metadata = (await read()) as Metadata | null;
|
|
37
|
+
if (!metadata?.token_endpoint) return new Response(null, { status: 404 });
|
|
38
|
+
|
|
39
|
+
const { jwks_uri: _jwks, userinfo_endpoint: _userinfo, ...rest } = metadata;
|
|
40
|
+
const base = metadata.token_endpoint.replace(/\/mcp\/token$/, "");
|
|
41
|
+
return Response.json(
|
|
42
|
+
{
|
|
43
|
+
...rest,
|
|
44
|
+
revocation_endpoint: `${base}/mcp/revoke`,
|
|
45
|
+
revocation_endpoint_auth_methods_supported: metadata.token_endpoint_auth_methods_supported,
|
|
46
|
+
scopes_supported: ["offline_access", ...MCP_SCOPES],
|
|
47
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
48
|
+
code_challenge_methods_supported: ["S256"],
|
|
49
|
+
authorization_response_iss_parameter_supported: true,
|
|
50
|
+
},
|
|
51
|
+
{ headers: HEADERS },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -62,7 +62,14 @@ jest.mock("better-auth/next-js", () => ({
|
|
|
62
62
|
nextCookies: () => ({ id: "next-cookies" }),
|
|
63
63
|
}));
|
|
64
64
|
|
|
65
|
-
jest.mock("better-auth/plugins", () => ({
|
|
65
|
+
jest.mock("better-auth/plugins", () => ({
|
|
66
|
+
magicLink: () => ({ id: "magic-link" }),
|
|
67
|
+
mcp: () => ({ id: "mcp" }),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// The MCP authorization server (#10357 D12): its ESM-only module never loads here.
|
|
71
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: lib/mcp-oauth statically imports ESM-only better-auth/api, which CommonJS Jest cannot load.
|
|
72
|
+
jest.mock("../../../../lib/mcp-oauth", () => ({ mcpOAuthPlugin: () => ({ id: "ailk-mcp-oauth" }) }));
|
|
66
73
|
|
|
67
74
|
/**
|
|
68
75
|
* The signer seam. `buildAuthEmailRequest` has exactly one external
|