create-ailk 0.11.5 → 0.12.1
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/component-catalog.json +35 -2
- package/component-catalog.md +9 -6
- package/dist/add-module.js +13 -0
- package/dist/module-architecture.d.ts +8 -0
- package/dist/module-architecture.js +33 -0
- package/dist/paid-sections.d.ts +23 -0
- package/dist/paid-sections.js +119 -0
- package/dist/programmatic.js +8 -0
- package/package.json +3 -3
- package/templates/.env.example +69 -3
- package/templates/apps/api/.env.example +22 -0
- package/templates/apps/api/CLAUDE.md +19 -7
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/bin/spokes.ts +137 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
- package/templates/apps/api/src/config/index.ts +97 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
- package/templates/apps/api/src/lib/spoke-store.ts +131 -0
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
- package/templates/apps/api/src/routes/leads/index.ts +33 -3
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
- package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
- package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
- package/templates/apps/api/src/server.ts +48 -0
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
- package/templates/apps/api/src/services/default-tenant.ts +51 -28
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
- package/templates/apps/api/src/services/lead-routing.ts +13 -1
- package/templates/apps/api/src/services/project-listings.ts +209 -18
- package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
- package/templates/apps/api/src/services/slack-notify.ts +54 -3
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/layout.tsx +4 -0
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/jest.config.cjs +1 -1
- package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
- package/templates/apps/web/lib/listings.tsx +472 -0
- package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
- package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
- package/templates/apps/web/messages/en.json +10 -0
- package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
- package/templates/database/CHANGELOG.md +34 -0
- package/templates/database/__tests__/build.test.ts +131 -0
- package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
- package/templates/database/auth/schema.prisma +31 -0
- package/templates/database/config/schema.prisma +26 -0
- package/templates/database/inbox/schema.prisma +21 -0
- package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
- package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
- package/templates/database/package.json +3 -3
- package/versions.json +34 -34
|
@@ -39,7 +39,7 @@ Routes are versioned under `/v1/*`:
|
|
|
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
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 `owner|admin` (contact PII), the same floor and the same reason as `/v1/waitlist-experiments/*`; the scope is the UNION of every workspace the caller owns or administers, 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, role `owner|admin` — 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
|
-
- `/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). 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).
|
|
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.
|
|
@@ -49,25 +49,29 @@ Routes are versioned under `/v1/*`:
|
|
|
49
49
|
- `/v1/checkout/sessions` — start a Stripe checkout
|
|
50
50
|
- `/v1/workspaces/:workspaceId/{theme,identity,seo}` (GET/PUT) — per-workspace site config (multi-tenant module)
|
|
51
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). Both verbs are role-floored `owner|admin` (the same floor `general.ts` puts on a rename): PUT because this blob decides which pages the public site serves, and GET because — 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). 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/
|
|
53
|
-
- `/v1/workspaces/:workspaceId/
|
|
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 (`owner|admin`) answers `null` or a summary — `provider`, `baseUrl`, `webhookPath`, `createdAt`, `updatedAt` — with no credential, secret, ciphertext or hint of one. PUT (`owner` only, SEC-7533-11: a connection replaces the account every provider call uses) 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 (`owner` only) is idempotent (`{ revoked }`) and unconfigures the workspace at once. 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 `owner|admin` (#5515 item 2) — 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 `owner|admin` (`LEAD_INBOX_ROLES`, the SAME constant `services/inbox-messages.ts`'s sibling functions already enforce), 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 MCP twin — identity-bound tenant routes (#3591 exception, the same shape as `/v1/waitlist-experiments/*` and `/v1/project-listings/index.ts`): MCP carries no caller identity today, so a twin could only invent an MCP auth story or serve whatever workspace the environment points at; the deferred ambition is #6222.
|
|
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 `owner|admin` (`LEAD_INBOX_ROLES`), checked before any read or write. The handlers and the service never log a lead id, email, name or answer. No MCP twin — identity-bound tenant routes (#3591 exception).
|
|
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 `owner|admin` floor as the submissions bullet above. No MCP twin — identity-bound tenant route (#3591 exception).
|
|
54
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 `owner|admin` (#5515 item 2), the same floor and the same reason as `/v1/waitlist-experiments/*` and `/v1/leads`: the roster carries every member's email, so membership alone is not authorization. No MCP twin — identity-bound tenant route (#3591 exception).
|
|
55
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 floor: publish and unpublish are `owner|admin` only (#5922, operator decision 2026-09-17) — publishing to, or pulling down from, a live public site is the highest-consequence action in the family, so it carries the same floor as this file's rename and roster routes above; list, create, read and save stay member-level. **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).
|
|
56
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` (member-level — the same floor the union content list above already carries for the very same rows, D9) 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` (`owner|admin`; `null` clears a text field to SQL `NULL`, `""` stores empty; the asset's `id` and public URL are untouched). `DELETE …/assets/:assetId` (`owner|admin`) 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.
|
|
57
60
|
- `/v1/billing` (GET), `/v1/billing/portal-session` (POST) — org-scoped billing read + Stripe customer-portal session creation
|
|
58
|
-
- `/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. MCP twins: `list_availability` / `get_event_meta` / `create_booking` (#4416, #4633)
|
|
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
|
|
59
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).
|
|
60
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
|
+
- `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.
|
|
61
65
|
- `/v1/schedule/appointments` (GET, `?limit=&cursor=&window=`) — the SITE's own appointments (#6085), tenant-guarded and role-floored `owner|admin` (the rows carry attendee contact PII, the same floor `/v1/leads` and `/v1/waitlist-experiments/*` carry). 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/*`).
|
|
62
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).
|
|
63
|
-
- `/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 `owner|admin` (signup PII); the platform-operator scope (`isSentinel(organizationId) && role === "owner"`, ALSO requiring
|
|
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 `owner|admin` (signup PII); the platform-operator scope (`isSentinel(organizationId) && role === "owner"`, 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, an ordinary owner/admin sees only its own `siteId`. No MCP twin — identity-bound tenant routes (#3591 exception, same shape as `/v1/billing/*`).
|
|
64
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).
|
|
65
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).
|
|
66
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: `PROJECT_LISTING_ROLES` stays `owner|admin` and the tenant-scoped list/get/patch routes are unchanged. 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).
|
|
67
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.
|
|
68
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.
|
|
69
73
|
|
|
70
|
-
**`AILK_OPERATOR_EMAIL` is REQUIRED for a `workspace`-module deployment** (#5515 item 1), exactly as it already is for the review dashboard. On a build with the `workspace` module and WITHOUT `multi-tenant`, `server.ts`'s `tenantPreHandlerForBuild()` selects the base guard (`src/middleware/default-tenant.ts` → `resolveDefaultTenantForOperator`), which fails CLOSED when the pin is unset: every `/v1/workspaces/*` route answers 403 to everyone — so the module's Admin pages (`apps/web/app/[locale]/(authed)/admin*`, renamed from `workspace` by #5637) render their shared `ForbiddenState` — and — the reason this matters — the TOFU operator bind (`services/default-tenant.ts#bindOperatorMembership`, #3631) is never reached, so the first account to load the settings page cannot seat ITSELF as the sole owner off a plain GET. With `multi-tenant` present
|
|
74
|
+
**`AILK_OPERATOR_EMAIL` is REQUIRED for a `workspace`-module deployment** (#5515 item 1), exactly as it already is for the review dashboard. On a build with the `workspace` module and WITHOUT `multi-tenant`, `server.ts`'s `tenantPreHandlerForBuild()` selects the base guard (`src/middleware/default-tenant.ts` → `resolveDefaultTenantForOperator`), which fails CLOSED when the pin is unset: every `/v1/workspaces/*` route answers 403 to everyone — so the module's Admin pages (`apps/web/app/[locale]/(authed)/admin*`, renamed from `workspace` by #5637) render their shared `ForbiddenState` — and — the reason this matters — the TOFU operator bind (`services/default-tenant.ts#bindOperatorMembership`, #3631) is never reached, so the first account to load the settings page cannot seat ITSELF as the sole owner off a plain GET. With `multi-tenant` present, a session that has activated a workspace resolves from it and its Membership, unchanged; a session with NO active workspace (every `apps/web` request) goes through the same `resolveDefaultTenantForOperator`, so on a one-workspace deployment the pin is REQUIRED there too — unset, nobody is seated and those requests answer 403 (#7523). The `kind: "all"` waitlist read likewise needs the caller to BE the pin, not merely a sentinel-org owner. `.env.example` § Single-tenant operator pin carries the operator-facing copy; `src/middleware/__tests__/default-tenant.test.ts` § "#5515 item 1" is the objective record.
|
|
71
75
|
|
|
72
76
|
The workspace site-config routes are **identity-bound tenant routes** (guarded by the tenant preHandler; the write scope is the caller's active workspace, never a body-supplied id). Like the sibling `/v1/workspaces/*` routes they have **no anonymous MCP twin** — the OSS MCP surface is identity-blind (`apps/mcp/CLAUDE.md`), so the "1:1 MCP tool" rule above does not apply to them; the tenant-scoped authed MCP surface is tracked in #3591. The `/v1/billing/*` and `/v1/waitlist-experiments/*` routes are the same shape — identity-bound tenant routes, admin/settings-facing rather than agent-facing, no MCP twin.
|
|
73
77
|
|
|
@@ -157,7 +161,15 @@ sweep's own DB constraint (never a caller-side lock).
|
|
|
157
161
|
- Don't put session state in `apps/api`. It's stateless.
|
|
158
162
|
- Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
|
|
159
163
|
|
|
160
|
-
Last updated: 2026-
|
|
164
|
+
Last updated: 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
|
+
|
|
166
|
+
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
|
+
|
|
168
|
+
Prior: 2026-10-03 — doc-sync: the multi-tenant null-binding fallback and the platform-operator scope both require the caller to BE the verified `AILK_OPERATOR_EMAIL` pin (#7523 D1, D3) — corrected the Inbox message role-floor note, the `/v1/waitlist-experiments/*` row and the `AILK_OPERATOR_EMAIL` paragraph — verified against `apps/api/src/services/tenant-context.ts`, `apps/api/src/services/default-tenant.ts` and `apps/api/src/services/waitlist-operator-reads.ts`.
|
|
169
|
+
|
|
170
|
+
Prior: 2026-10-03 — doc-sync: the general GET gains `emailFrom`, and `GET /v1/flows/:slug` serves the config minus `completionEmail` (#7519 D5, D6) — verified against `apps/api/src/routes/workspaces/general.ts`, `apps/api/src/services/flow-engine.ts` and `apps/api/src/services/deliverable-fulfillment.ts`.
|
|
171
|
+
|
|
172
|
+
Prior: 2026-09-21 — doc-sync: re-pointed magnet deliverables at the workspace-scoped `Asset` store (#6310) — extended the `/v1/deliverables/:grantId` row with the new `asset_` grant class (the named boundary it reads through, its serve-time tenancy re-check, its hardened attachment+CSP+no-store serve for every content type, and its uniform-404 fold), recording the rejected redirect-to-`/v1/assets/:assetId` mechanism and why — verified against `apps/api/src/routes/deliverables/index.ts`, `apps/api/src/lib/deliverable-asset-store.ts` and `apps/api/src/services/deliverable-fulfillment.ts`.
|
|
161
173
|
|
|
162
174
|
Prior: 2026-09-19 — doc-sync: added the three per-magnet captured-lead list routes (#6246 S6h) — `GET /v1/workspaces/:workspaceId/magnets/:id/{signups,subscribers,messages}` — to the magnets bullet, recording the `Lead.source = 'flow:' + slug` join, the new `tenantDb(tenant).leads.listBySource` accessor, the new `(workspaceId, source)` index, the wrong-kind/absent-magnet shared 404, the four/five-key wire shapes, the bounded `?limit=`, and the no-MCP-twin decision — verified against `apps/api/src/routes/magnets/index.ts`, `apps/api/src/lib/tenant-db.ts` and `database/inbox/schema.prisma`.
|
|
163
175
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"deliverable:upload": "tsx src/bin/deliverable-upload.ts",
|
|
15
15
|
"deliverable:resend": "tsx src/bin/deliverable-resend.ts",
|
|
16
16
|
"seed:presets": "tsx src/bin/seed-presets.ts",
|
|
17
|
+
"spokes": "tsx src/bin/spokes.ts",
|
|
17
18
|
"build": "tsc",
|
|
18
19
|
"build:vercel": "cd ../../database && pnpm exec prisma generate && cd ../.. && pnpm --filter @working-theory/validation build && pnpm --filter @working-theory/api build && mkdir -p apps/api/public && echo '{}' > apps/api/public/.vercel",
|
|
19
20
|
"start": "node dist/cli.js",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* istanbul ignore file -- CLI bootstrap; mirrors deliverable-resend.ts's
|
|
3
|
+
coverage-ignore rationale — the register/revoke/list logic is unit-tested at
|
|
4
|
+
the boundary it calls (lib/__tests__/spoke-store.test.ts). This file is a
|
|
5
|
+
thin entry: parse args, dispatch, print, set exit code. */
|
|
6
|
+
/**
|
|
7
|
+
* The hub's spoke registry CLI (#7532, spec
|
|
8
|
+
* docs/specs/7532-a-spoke-s-public-reads-spec.md D7).
|
|
9
|
+
*
|
|
10
|
+
* Dev: `pnpm --filter @working-theory/api spokes <command> [flags]`
|
|
11
|
+
* Production: `node dist/bin/spokes.js <command> [flags]`
|
|
12
|
+
*
|
|
13
|
+
* register --workspace <wksp_id> --name <label>
|
|
14
|
+
* Bind a new spoke to one existing workspace and print its key ONCE.
|
|
15
|
+
* The hub stores only the key's digest; the key cannot be shown again.
|
|
16
|
+
* Do not run this where stdout is captured (CI logs, a shared terminal
|
|
17
|
+
* recording) — set the printed key as the spoke's `AILK_SPOKE_KEY`.
|
|
18
|
+
* revoke --id <spoke_id>
|
|
19
|
+
* Stop that one spoke's key on its next request. Other spokes of the
|
|
20
|
+
* same workspace keep working.
|
|
21
|
+
* list
|
|
22
|
+
* Every spoke: id, name, workspace, key prefix, created, revoked. Never
|
|
23
|
+
* the digest.
|
|
24
|
+
*
|
|
25
|
+
* A key rotates by registering a second spoke for the workspace, deploying
|
|
26
|
+
* its key, then revoking the first. There is no HTTP route for any of this.
|
|
27
|
+
*/
|
|
28
|
+
import { prisma } from "../lib/prisma.js";
|
|
29
|
+
import {
|
|
30
|
+
listSpokes,
|
|
31
|
+
registerSpoke,
|
|
32
|
+
revokeSpoke,
|
|
33
|
+
} from "../lib/spoke-store.js";
|
|
34
|
+
|
|
35
|
+
const USAGE = [
|
|
36
|
+
"usage: spokes register --workspace <wksp_id> --name <label>",
|
|
37
|
+
" spokes revoke --id <spoke_id>",
|
|
38
|
+
" spokes list",
|
|
39
|
+
].join("\n");
|
|
40
|
+
|
|
41
|
+
type Flags = {
|
|
42
|
+
workspace?: string | undefined;
|
|
43
|
+
name?: string | undefined;
|
|
44
|
+
id?: string | undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Minimal `--flag value` parser — no external dependency for a 3-flag CLI. */
|
|
48
|
+
function parseFlags(argv: string[]): Flags {
|
|
49
|
+
const out: Flags = {};
|
|
50
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
51
|
+
const flag = argv[i];
|
|
52
|
+
const value = argv[i + 1];
|
|
53
|
+
if (flag === "--workspace") out.workspace = value;
|
|
54
|
+
else if (flag === "--name") out.name = value;
|
|
55
|
+
else if (flag === "--id") out.id = value;
|
|
56
|
+
else continue;
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function runRegister(flags: Flags): Promise<void> {
|
|
63
|
+
const name = flags.name?.trim();
|
|
64
|
+
if (!flags.workspace || !name || name.length > 120) {
|
|
65
|
+
console.error(`[spokes] ${USAGE}\n(--name is 1–120 characters)`);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const result = await registerSpoke({ workspaceId: flags.workspace, name });
|
|
70
|
+
if (!result.ok) {
|
|
71
|
+
console.error(`[spokes] ${result.reason}: no workspace "${flags.workspace}"`);
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
console.log(
|
|
76
|
+
`[spokes] registered spoke="${result.spoke.id}" workspace="${result.spoke.workspaceId}" name="${result.spoke.name}"`,
|
|
77
|
+
);
|
|
78
|
+
console.log(`AILK_SPOKE_KEY=${result.key}`);
|
|
79
|
+
console.log(
|
|
80
|
+
"[spokes] This key will not be shown again. Set it as the spoke's AILK_SPOKE_KEY now.",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function runRevoke(flags: Flags): Promise<void> {
|
|
85
|
+
if (!flags.id) {
|
|
86
|
+
console.error(`[spokes] ${USAGE}`);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const revoked = await revokeSpoke(flags.id);
|
|
91
|
+
if (!revoked) {
|
|
92
|
+
console.error(`[spokes] no unrevoked spoke "${flags.id}"`);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
console.log(`[spokes] revoked spoke="${flags.id}"`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runList(): Promise<void> {
|
|
100
|
+
const spokes = await listSpokes();
|
|
101
|
+
if (spokes.length === 0) {
|
|
102
|
+
console.log("[spokes] no spokes registered");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
for (const spoke of spokes) {
|
|
106
|
+
console.log(
|
|
107
|
+
[
|
|
108
|
+
spoke.id,
|
|
109
|
+
`name="${spoke.name}"`,
|
|
110
|
+
`workspace=${spoke.workspaceId}`,
|
|
111
|
+
`prefix=${spoke.credentialPrefix}`,
|
|
112
|
+
`created=${spoke.createdAt.toISOString()}`,
|
|
113
|
+
`revoked=${spoke.revokedAt ? spoke.revokedAt.toISOString() : "-"}`,
|
|
114
|
+
].join(" "),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function main(): Promise<void> {
|
|
120
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
121
|
+
const flags = parseFlags(rest);
|
|
122
|
+
if (command === "register") return runRegister(flags);
|
|
123
|
+
if (command === "revoke") return runRevoke(flags);
|
|
124
|
+
if (command === "list") return runList();
|
|
125
|
+
console.error(`[spokes] ${USAGE}`);
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
main()
|
|
130
|
+
.catch((err: unknown) => {
|
|
131
|
+
console.error(
|
|
132
|
+
"[spokes] failed:",
|
|
133
|
+
err instanceof Error ? err.message : String(err),
|
|
134
|
+
);
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
})
|
|
137
|
+
.finally(() => prisma.$disconnect());
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file hub-require-spoke-key.test.ts
|
|
3
|
+
* @description `AILK_HUB_REQUIRE_SPOKE_KEY` — the hub's strict-mode flag
|
|
4
|
+
* (#7532, spec docs/specs/7532-a-spoke-s-public-reads-spec.md D7, AC-5).
|
|
5
|
+
*
|
|
6
|
+
* `true`/`1` turn it on; unset, empty, `false` and `0` leave it off —
|
|
7
|
+
* case-insensitively, after trimming. Any other value refuses the boot with a
|
|
8
|
+
* message naming the variable, never the value. On with no `DATABASE_URL`
|
|
9
|
+
* refuses the boot too: strict mode with no registry would refuse every call.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Mock dotenv so a developer's local .env never leaks into the config module.
|
|
13
|
+
jest.mock('dotenv', () => ({
|
|
14
|
+
config: jest.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const mockLoggerError = jest.fn();
|
|
18
|
+
const mockLoggerWarn = jest.fn();
|
|
19
|
+
jest.mock('@working-theory/observability', () => ({
|
|
20
|
+
logger: {
|
|
21
|
+
error: (...args: unknown[]) => mockLoggerError(...args),
|
|
22
|
+
warn: (...args: unknown[]) => mockLoggerWarn(...args),
|
|
23
|
+
info: jest.fn(),
|
|
24
|
+
debug: jest.fn(),
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
|
|
29
|
+
const originalEnv = process.env;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
jest.resetModules();
|
|
33
|
+
process.env = { ...originalEnv };
|
|
34
|
+
delete process.env.AILK_HUB_REQUIRE_SPOKE_KEY;
|
|
35
|
+
process.env.DATABASE_URL = 'postgresql://test:test@localhost/test';
|
|
36
|
+
jest.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
process.env = originalEnv;
|
|
41
|
+
jest.restoreAllMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it.each(['true', 'TRUE', '1', ' True '])('%j parses on', (raw) => {
|
|
45
|
+
process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
|
|
46
|
+
const { config } = require('../index');
|
|
47
|
+
expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.each([undefined, '', ' ', 'false', 'FALSE', '0'])('%j parses off', (raw) => {
|
|
51
|
+
if (raw !== undefined) process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
|
|
52
|
+
const { config } = require('../index');
|
|
53
|
+
expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('any other value refuses the boot, naming the variable and never the value', () => {
|
|
57
|
+
process.env.AILK_HUB_REQUIRE_SPOKE_KEY = 'yes-sentinel-value';
|
|
58
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
59
|
+
throw new Error('Process exit called');
|
|
60
|
+
});
|
|
61
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
62
|
+
|
|
63
|
+
expect(() => require('../index')).toThrow('Process exit called');
|
|
64
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
65
|
+
|
|
66
|
+
const logged = JSON.stringify([mockLoggerError.mock.calls, consoleSpy.mock.calls]);
|
|
67
|
+
expect(logged).toContain('AILK_HUB_REQUIRE_SPOKE_KEY');
|
|
68
|
+
expect(logged).not.toContain('yes-sentinel-value');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('on with DATABASE_URL unset refuses the boot', () => {
|
|
72
|
+
process.env.AILK_HUB_REQUIRE_SPOKE_KEY = 'true';
|
|
73
|
+
delete process.env.DATABASE_URL;
|
|
74
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
75
|
+
throw new Error('Process exit called');
|
|
76
|
+
});
|
|
77
|
+
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
78
|
+
|
|
79
|
+
expect(() => require('../index')).toThrow('Process exit called');
|
|
80
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
81
|
+
expect(JSON.stringify(mockLoggerError.mock.calls)).toContain(
|
|
82
|
+
'AILK_HUB_REQUIRE_SPOKE_KEY',
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('off with DATABASE_URL unset still boots database-less, as today', () => {
|
|
87
|
+
delete process.env.DATABASE_URL;
|
|
88
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
89
|
+
throw new Error('Process exit called');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const { config } = require('../index');
|
|
93
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
94
|
+
expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -102,6 +102,23 @@ export interface Config {
|
|
|
102
102
|
// nothing (§ D2 row 1) — the same fail-closed posture STRIPE_WEBHOOK_SECRET
|
|
103
103
|
// unset already has.
|
|
104
104
|
CAL_WEBHOOK_SECRET?: string;
|
|
105
|
+
|
|
106
|
+
// Per-workspace scheduling connections (#7533 D2) — the AES-256-GCM key
|
|
107
|
+
// services/scheduling/connections.ts encrypts each connection's provider
|
|
108
|
+
// credential and webhook secret under. Canonical standard base64 of exactly
|
|
109
|
+
// 32 random bytes (`openssl rand -base64 32`), checked at startup by
|
|
110
|
+
// resolveSchedulingCredentialKey() below; a refused value stops the API.
|
|
111
|
+
// Optional: unset (or empty) means connections cannot be written, and the
|
|
112
|
+
// env-configured CAL_API_KEY path works exactly as before. Held decoded,
|
|
113
|
+
// never as the raw string, and never logged.
|
|
114
|
+
SCHEDULING_CREDENTIAL_KEY?: Buffer;
|
|
115
|
+
// Hub strict mode (#7532 D7). A hub serving several spokes sets it so a
|
|
116
|
+
// request to a spoke-aware public surface (meeting types, the three lead
|
|
117
|
+
// writes, booking, the public content reads) that carries no
|
|
118
|
+
// `x-ailk-spoke-key` resolves to nothing. Off (the default) keeps today's
|
|
119
|
+
// header-less resolution, byte for byte. Parsed by parseHubRequireSpokeKey()
|
|
120
|
+
// below; read by services/public-site-resolver.ts.
|
|
121
|
+
AILK_HUB_REQUIRE_SPOKE_KEY: boolean;
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
/** Why an AILK_TRUSTED_ORIGINS entry was refused (#7395 D2). */
|
|
@@ -201,6 +218,47 @@ export function resolveTrustedOrigins(env: {
|
|
|
201
218
|
return { origins: Object.freeze([...new Set(origins)]), refused, primaryUnusable };
|
|
202
219
|
}
|
|
203
220
|
|
|
221
|
+
/** Why a SCHEDULING_CREDENTIAL_KEY value was refused (#7533 D2). */
|
|
222
|
+
export type SchedulingCredentialKeyRefusal = 'not-canonical-base64' | 'length';
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The scheduling-connection key (#7533 D2, SEC-7533-6). Pure;
|
|
226
|
+
* `validateConfig()` calls it once.
|
|
227
|
+
*
|
|
228
|
+
* Accepts only CANONICAL standard base64 — `Buffer.from(v, 'base64')
|
|
229
|
+
* .toString('base64') === v` — of exactly 32 bytes. Base64 decoding silently
|
|
230
|
+
* skips characters outside its alphabet, so without the round-trip a
|
|
231
|
+
* base64url value, or a passphrase that happens to decode to 32 bytes, would
|
|
232
|
+
* be taken as a key. `''` and unset are both `unset`. A refusal carries the
|
|
233
|
+
* reason kind only, never any part of the value.
|
|
234
|
+
*/
|
|
235
|
+
export function resolveSchedulingCredentialKey(
|
|
236
|
+
raw: string | undefined,
|
|
237
|
+
):
|
|
238
|
+
| { kind: 'unset' }
|
|
239
|
+
| { kind: 'ok'; key: Buffer }
|
|
240
|
+
| { kind: 'refused'; reason: SchedulingCredentialKeyRefusal } {
|
|
241
|
+
if (raw === undefined || raw === '') return { kind: 'unset' };
|
|
242
|
+
const key = Buffer.from(raw, 'base64');
|
|
243
|
+
if (key.toString('base64') !== raw) {
|
|
244
|
+
return { kind: 'refused', reason: 'not-canonical-base64' };
|
|
245
|
+
}
|
|
246
|
+
if (key.length !== 32) return { kind: 'refused', reason: 'length' };
|
|
247
|
+
return { kind: 'ok', key };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7): `true`/`1` → on; unset, empty,
|
|
252
|
+
* `false`, `0` → off — case-insensitive, after trimming. `null` for any other
|
|
253
|
+
* value, which `validateConfig()` refuses at boot.
|
|
254
|
+
*/
|
|
255
|
+
export function parseHubRequireSpokeKey(raw: string | undefined): boolean | null {
|
|
256
|
+
const value = (raw ?? '').trim().toLowerCase();
|
|
257
|
+
if (value === 'true' || value === '1') return true;
|
|
258
|
+
if (value === '' || value === 'false' || value === '0') return false;
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
204
262
|
/** Enabled modules whose state lives in the database (see validateConfig). */
|
|
205
263
|
const DATABASE_MODULES = [
|
|
206
264
|
'commerce',
|
|
@@ -267,6 +325,40 @@ function validateConfig(): Config {
|
|
|
267
325
|
);
|
|
268
326
|
}
|
|
269
327
|
|
|
328
|
+
// #7533 D2 — a set-but-refused SCHEDULING_CREDENTIAL_KEY stops the API in
|
|
329
|
+
// every environment, as a refused AILK_TRUSTED_ORIGINS entry does: a key
|
|
330
|
+
// taken in some other shape would encrypt every connection under a value
|
|
331
|
+
// the operator did not mean. The reason kind only, never the value.
|
|
332
|
+
const schedulingKey = resolveSchedulingCredentialKey(process.env.SCHEDULING_CREDENTIAL_KEY);
|
|
333
|
+
if (schedulingKey.kind === 'refused') {
|
|
334
|
+
const message =
|
|
335
|
+
'❌ SCHEDULING_CREDENTIAL_KEY must be canonical standard base64 of exactly 32 bytes (openssl rand -base64 32)';
|
|
336
|
+
logger.error({ reason: schedulingKey.reason }, message);
|
|
337
|
+
console.error(message, JSON.stringify({ reason: schedulingKey.reason }));
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// #7532 D7 — the hub strict-mode flag. An unparseable value stops the API:
|
|
342
|
+
// the variable is opt-in, and a typo read as "off" would silently leave the
|
|
343
|
+
// hub open to header-less callers. The message names the variable, never
|
|
344
|
+
// its value. On with no DATABASE_URL stops it too: strict mode with no
|
|
345
|
+
// registry to look a key up in would refuse every call.
|
|
346
|
+
const hubRequireSpokeKey = parseHubRequireSpokeKey(process.env.AILK_HUB_REQUIRE_SPOKE_KEY);
|
|
347
|
+
if (hubRequireSpokeKey === null) {
|
|
348
|
+
const message =
|
|
349
|
+
'❌ AILK_HUB_REQUIRE_SPOKE_KEY must be true, 1, false, 0 or unset';
|
|
350
|
+
logger.error(message);
|
|
351
|
+
console.error(message);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
if (hubRequireSpokeKey && !process.env.DATABASE_URL) {
|
|
355
|
+
const message =
|
|
356
|
+
'❌ AILK_HUB_REQUIRE_SPOKE_KEY is on but DATABASE_URL is not set — the spoke registry lives in the database';
|
|
357
|
+
logger.error(message);
|
|
358
|
+
console.error(message);
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
|
|
270
362
|
return {
|
|
271
363
|
NODE_ENV: nodeEnv,
|
|
272
364
|
PORT: parseInt(process.env.PORT || '3002', 10),
|
|
@@ -334,6 +426,11 @@ function validateConfig(): Config {
|
|
|
334
426
|
...(process.env.CAL_WEBHOOK_SECRET !== undefined && {
|
|
335
427
|
CAL_WEBHOOK_SECRET: process.env.CAL_WEBHOOK_SECRET,
|
|
336
428
|
}),
|
|
429
|
+
|
|
430
|
+
...(schedulingKey.kind === 'ok' && {
|
|
431
|
+
SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
|
|
432
|
+
}),
|
|
433
|
+
AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey === true,
|
|
337
434
|
};
|
|
338
435
|
}
|
|
339
436
|
|
|
@@ -161,6 +161,11 @@ export const prisma = {
|
|
|
161
161
|
upsert: jest.fn(),
|
|
162
162
|
deleteMany: jest.fn(),
|
|
163
163
|
},
|
|
164
|
+
// `findUnique` — the public meetings read (`lib/public-meetings-config.ts`),
|
|
165
|
+
// which a spoke's meeting-types read and booking gate reach (#7532 D3/D4).
|
|
166
|
+
workspaceMeetings: {
|
|
167
|
+
findUnique: jest.fn(),
|
|
168
|
+
},
|
|
164
169
|
// ── Inbox namespace (Lead, Subscriber, Callback) ─────────────────────────
|
|
165
170
|
lead: {
|
|
166
171
|
create: jest.fn(),
|
|
@@ -355,6 +360,33 @@ export const prisma = {
|
|
|
355
360
|
findMany: jest.fn(),
|
|
356
361
|
updateMany: jest.fn(),
|
|
357
362
|
},
|
|
363
|
+
// ── Config namespace (SchedulingConnection — #7533) ─────────────────────
|
|
364
|
+
// Every call lives in services/scheduling/connections.ts: `findUnique` by
|
|
365
|
+
// `workspaceId` (the provider read + the summary) or by `webhookToken` (the
|
|
366
|
+
// per-connection webhook); `upsert` keyed on the tenant's workspace (the
|
|
367
|
+
// connect/replace write); `deleteMany` keyed the same way (the revoke).
|
|
368
|
+
schedulingConnection: {
|
|
369
|
+
findUnique: jest.fn(),
|
|
370
|
+
upsert: jest.fn(),
|
|
371
|
+
deleteMany: jest.fn(),
|
|
372
|
+
},
|
|
373
|
+
// ── #7532 — the hub's spoke registry (auth) and the booking origin (inbox) ─
|
|
374
|
+
// `spoke.findUnique` — the resolver's one lookup by credential digest;
|
|
375
|
+
// `create`/`updateMany`/`findMany` — the operator CLI's register, revoke and
|
|
376
|
+
// list (`lib/spoke-store.ts`). `appointmentOrigin.createMany` — the bind's
|
|
377
|
+
// ON CONFLICT DO NOTHING insert; `findUnique` — the origin read the bind and
|
|
378
|
+
// the webhook make (`services/scheduling/appointments.ts`).
|
|
379
|
+
spoke: {
|
|
380
|
+
findUnique: jest.fn(),
|
|
381
|
+
findFirst: jest.fn(),
|
|
382
|
+
findMany: jest.fn(),
|
|
383
|
+
create: jest.fn(),
|
|
384
|
+
updateMany: jest.fn(),
|
|
385
|
+
},
|
|
386
|
+
appointmentOrigin: {
|
|
387
|
+
findUnique: jest.fn(),
|
|
388
|
+
createMany: jest.fn(),
|
|
389
|
+
},
|
|
358
390
|
// ── Content namespace (UsageEvent, UsagePeriod — FEAT-047 P1.S1/#3709) ──
|
|
359
391
|
usageEvent: {
|
|
360
392
|
create: jest.fn(),
|
|
@@ -385,6 +417,9 @@ export const prisma = {
|
|
|
385
417
|
// `mockTransaction` helper models commit/rollback so atomicity is asserted,
|
|
386
418
|
// not assumed.
|
|
387
419
|
$transaction: jest.fn(),
|
|
420
|
+
// `$executeRaw` — the per-booking `pg_advisory_xact_lock` (#7532 D5),
|
|
421
|
+
// taken inside `$transaction`. Suites on that path arm both.
|
|
422
|
+
$executeRaw: jest.fn(),
|
|
388
423
|
$connect: jest.fn(),
|
|
389
424
|
$disconnect: jest.fn(),
|
|
390
425
|
};
|