create-ailk 0.31.0 → 0.33.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 +50 -8
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cutover.d.ts +6 -1
- package/dist/cutover.js +23 -6
- package/dist/export-content.d.ts +6 -3
- package/dist/export-content.js +79 -10
- package/dist/language-import.d.ts +16 -3
- package/dist/language-import.js +27 -11
- package/dist/lib/import-mdx.d.ts +42 -0
- package/dist/lib/import-mdx.js +584 -65
- package/dist/lib/render-comparison.d.ts +51 -0
- package/dist/lib/render-comparison.js +205 -6
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +1 -1
- package/dist/migrate-content.js +4 -2
- package/dist/programmatic.js +2 -8
- package/dist/seed-flows.d.ts +1 -1
- package/dist/seed-flows.js +9 -4
- package/dist/site-manifest.js +6 -38
- package/dist/site-roster.d.ts +46 -3
- package/dist/site-roster.js +124 -4
- package/dist/surfaces.d.ts +8 -0
- package/dist/surfaces.js +17 -0
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/apps/api/CLAUDE.md +7 -5
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
- package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
- package/templates/apps/web/auth.ts +22 -6
- package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
- package/templates/apps/web/lib/account-config.ts +52 -14
- package/templates/apps/web/lib/auth-email.ts +87 -5
- package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
- package/templates/apps/web/messages/en.json +26 -1
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/database/seed.ts +7 -7
- package/templates/package.json +2 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- package/versions.json +48 -48
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
|
@@ -64,7 +64,7 @@ Routes are versioned under `/v1/*`:
|
|
|
64
64
|
- `/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).
|
|
65
65
|
- `/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`). A client's file — tagged `private`, attached to a member-area message, or (#8171 D3) named by a delivered piece version — is never minted or served as a deliverable. 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).
|
|
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
|
-
- `/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).
|
|
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. **`who` (#8171 D6, #10819)** — who can use the magnet, a People type (`flowWhoSchema`: `visitor` | `lead` | `user`, column default `visitor`, closed by the `flow_definitions_who_domain` CHECK); `user` is the member-area customer switch — is served on every full row and written by the PATCH (a value outside the three is `400`; 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 `who: "user"`, 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`, `who: "visitor"`, 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`, `who: "visitor"`, 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`, `who: "visitor"`, 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
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
|
|
@@ -81,10 +81,10 @@ Routes are versioned under `/v1/*`:
|
|
|
81
81
|
- `/v1/workspaces/:workspaceId/subscribers` (GET, `?limit=`) — the workspace's newsletter signups, read-only (#7535, `src/routes/workspaces/subscribers.ts`): `{ count, items }`, where `count` is every signup and `items` the newest (1–200, default 50), each `{ id, email, source, subscribedAt, magnet: { slug, title } | null }`. A signup is a `Subscriber` row of this workspace or the `Lead` a newsletter-kind magnet of this workspace wrote, both from `tenantDb(tenant).subscribers`, called directly. No item route and no write: a subscriber is never an item to open or mark handled. Same registration, scope, tenant guard and `inbox-read` floor as the submissions bullet above. No MCP twin — identity-bound tenant route (#3591 exception).
|
|
82
82
|
- `/v1/workspaces/:workspaceId/orders` (GET, `?limit=`), `/v1/workspaces/:workspaceId/orders/:leadId` (GET) — the Inbox orders read (#9170, `src/routes/workspaces/orders.ts` over `src/services/inbox-orders.ts`): the purchases of the caller's active workspace, newest first and bounded (`?limit=` 1–200, default 50, the messages list's own query), and the item read. Same registration, scope, tenant guard and `inbox-read` floor as the messages bullet above. An order is a `Lead` the hub recorded as a purchase — `spokeCheckout`, or `source: "web"` with `payload.page: "checkout"` (the Offerings webhook's and a spoke's checkout leads, marked or not) — decided by ONE predicate in `tenantDb(tenant).leads` (`ORDER_LEAD_WHERE`), whose exact complement (`NON_ORDER_LEAD_WHERE`, written out so a missing `page` is never SQL NULL) every message read and write carries, so an order is never a message, never stamped handled or accepted, and never a For-you item. The hub's own flow checkout writes no lead of its own (its lead is the flow's `flow:<slug>` submission), so it is not an order. The list row is `id`, `name`, `email`, `offering` (the checkout's lookup key or legacy offering code, stripped and bounded), `planName` (the workspace's own listed name for it — `listPlanOfferings`, matched with any leading `<site>_` removed, one read per request bounded at 1.5 s, else `null`), `amount` (a positive integer of minor units, else `null`), `currency` (three letters upper-cased, else `null`), `createdAt` and `source`; the item adds `personId`. Never `payload`. Every success body is re-parsed against its own `.strict()` schema. Every miss on the item read — a malformed, absent or foreign id, a lead that is not an order — is the SAME `404 No such order`. There is no handled stamp: an order is not an ask. No MCP twin, recorded in `tests/parity/README.md`.
|
|
83
83
|
- `/v1/workspaces/:workspaceId/people` (GET, `?limit=&cursor=`), `/v1/workspaces/:workspaceId/people/:personId` (GET), `/v1/workspaces/:workspaceId/people/:personId/contact` (PATCH) — the workspace's clients (#852, `src/routes/workspaces/people.ts` over `tenantDb(tenant).people`; the Person writer is `src/services/person.ts`). A **Person** is one client of one workspace, keyed on a lowercased, trimmed email unique per workspace and created only by `findOrCreatePerson` — called by lead capture, the hub's own Stripe checkout webhook, appointment creation and the inbox accept, none of which writes a customer id (#8047): `Person.billingCustomerId` is written only by a signed-in buyer's live checkout, as their own bound customer onto the Person linked to them (`setLinkedPersonCustomer`); `Person.userId` is stamped only on a verified-email match (`stampPersonUser`), never overwritten. The list serves `{ items, nextCursor }` — five keys per row (`id`, `email`, `name`, `planOffering`, `createdAt`), newest first, `limit` 1–200 (default 50); a cursor that does not resolve to a Person of this workspace is `400 invalid_cursor`. The record serves `{ person, timeline, timelineTruncated }`: the Person (`userId` projected to `hasAccount`, never served; the billing pair, and `stripeCustomerUrl` — the Stripe Dashboard link to the customer, built from `billingCustomerId` by `services/person.ts`'s `stripeCustomerDashboardUrl` in the Organization's stored connection's mode, else the environment key's for the Organization that owns it, else `null` — served only to a caller holding `billing-read`; every other caller, an agent key included, gets `billingProvider`, `billingCustomerId` and `stripeCustomerUrl` as `null` and no connection read runs (#9349 DD5/DD6); a failed connection read is a `null` link, the record still `200`, and one `{ event: "person-stripe-link", outcome: "connection_unavailable" }` warn) over a timeline derived at read time — leads and appointments by `personId`, flow sessions, checkouts, deliverable grants and project listings by the Person's newest 200 lead ids, plus an exact-normalized-email fallback for lead-less flow sessions and listings older than the Person backfill, (since #7877) the Person's member-area messages as `message` entries carrying the subject, `hasFile` and `readAt` only, and (since #8171 D4) the Person's inbound member rows as `request` entries (`requestKind`, `subject`) and the versions delivered to them as `version` entries (`pieceId`, `version`, `review`; `at` is `deliveredAt`) — keyed by `personId`, never the email grouping, and never a body, link, quote answer, note or review comment — newest first, at most 50 entries; a malformed, absent or foreign id is one `404 not_found`. Never selected or served: a grant id, a flow-session id, a checkout id, a lead `payload`, a permalink, a Stripe session id, an attendee email, a founder email. Role floor: `people-read` (owner, admin and, since #8514, member — `@working-theory/validation`'s permission table) via `allows`, checked before any read, so an agent key granted `people-read` (the sixth `AGENT_GRANTABLE_PERMISSIONS` entry, clamped to its person's role) reads both in its one workspace. **Agents are withheld (#8514 D3, D4):** `people-read`, `people-write` and `member-pieces-manage` are `AGENT_WITHHELD_PERMISSIONS` — a member holds them by role, an agent (every agent acts at member) never does: `roleAllows` and `allows` read the `agent: true` marker `agentTenant` sets on every agent tenant, so an agent holds the grantable two only by an explicit grant and `people-write` never, and both tenant guards refuse an agent request whose tenant lacks the marker (403, one `agent-tenant-unmarked` error line — fail closed). An operator read only: a Person groups rows by an unverified email, so no client-facing read may key on that grouping (#7877's feed keys on a verified link). **The contact details (#8435, spec `docs/specs/8435-contact-details-on-the-client-spec.md`):** the Person also carries a given name, a family name, an E.164 telephone, a six-part address (the magnet's `addressLine1`/`addressLine2`/`city`/`region`/`postalCode`/`country`, stored whole or not at all), a company name and a job title, plus `contactConfirmedAt`/`contactConfirmedBy` — who last saved the contact by hand. The record serves them as `person.contact` (six keys, `null` where blank) and `person.contactConfirmed` (`{ by: "operator" | "client", at }`, `null` while only captures have written it). **A capture fills a BLANK field of an UNCURATED Person only** (`findOrCreatePerson`'s `contact`: no linked user and never saved by hand; every guarded `updateMany` repeats that condition): a stored value is never overwritten and a cleared field is never refilled. A magnet field's `mapsTo` tag now names the contact field it fills (`email`, `name`, `givenName`, `familyName`, `phone`, `address`, `company`, `jobTitle`); the flow engine hands the visible steps' tagged answers to `routeLead` as `options.contact` and the lead's `payload` keeps every raw answer. A subscriber and a callback with a storable email (a callback's is `payload.email`; at confirm, `recordPaymentLink` links the buyer email when capture had none) now carry a `personId` — a composite foreign key onto `people(id, workspace_id)`, `RESTRICT`. `PATCH …/people/:personId/contact` needs `people-write` (owner, admin, member — by role; never an agent) and takes any of the six keys (`null` clears; an address is whole or `null`; `400 telephone_invalid` for a telephone that is not E.164, `400 validation_failed` otherwise, an 8 KiB body bound), stamps `contactConfirmedBy: "operator"`, answers `{ contact }` and writes one `people.contact.update` audit row naming the actor and the Person, never a value. **The private notes (#8514, spec `docs/specs/8514-redesign-the-person-record-page-spec.md` D5–D8):** `GET`/`PUT …/people/:personId/notes` — the team's one note on a client (`notes`, ≤ 5000 code points), who last saved it and when, and which agent when one did; never selected by any other read and never served to the client (`lib/member-store.ts` names no notes column). The GET needs a person's `people-read` role or an agent's `member-pieces-manage` grant (never a `people-read` grant alone); the PUT needs `member-pieces-manage` through `allows`, takes `{ notes, ifUpdatedAt }` (both required; `null` clears; `ifUpdatedAt` is the `updatedAt` read, a stale one `409 notes_changed` with nothing written; `400 notes_too_long`), and writes one `people.notes.set` audit row whose summary is `{ ok, cleared }`, never the text. Both answer `{ notes: { text, updatedAt, updatedBy: { name, viaAgent } | null } }` with `Cache-Control: private, no-store`, through `tenantDb(tenant).people.notes`/`setNotes`; a malformed, absent or foreign id is one `404 not_found`. Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). The notes pair has MCP twins on `/mcp/workspace`: `get_person_notes` and `set_person_notes` (#8514 D8). The record, contact and consents routes have no MCP twin, by decision — People MCP tools are a later phase of the CRM nucleus (#852 AC 6), recorded in `tests/parity/README.md`. **The changed mode, the agent reply and the address proof (#9714, spec `docs/specs/9714-hub-side-agent-thread-spec.md`):** `GET …/people?changedSince=<ISO 8601 with offset>&limit=` serves `{ items: [{ id, lastInboundAt }], more }` — each Person whose thread gained an inbound item (a message lead, never an order, or an inbound member request) strictly after the instant, ascending by that item's instant then id, through `tenantDb(tenant).people.changed` over `database/tenancy.ts`'s `PEOPLE_CHANGED_SQL`; a page never ends inside a millisecond it shares with the next Person, so a poller passes back the last `lastInboundAt`; `changedSince` with `cursor` is `400 validation_failed`; every call in this mode writes one `people.list.changed` audit row (counts only) carrying an agent's session, and the plain list still writes none. The list in both modes has its MCP twin, `list_people`. The record's `person.emailVerified` is `services/person.ts`'s `personAddressProved`: linked to a verified User whose email is still the Person's and who holds an `Account` or `Session` row — never `userId` alone and never the operator's contact stamp. `POST …/people/:personId/messages` accepts an agent holding `people-read` with `inbox-act` as well as `member-pieces-manage` (plus the inbox gate); an agent's message stores its id in `interactions.created_by_agent_id` (outbound-only, CHECKed), and the thread's `message` entry serves `messageId` and `sentBy: { kind: "user" | "agent", name }`, its `lead` entry `senderProved: false` and its `request` entry `senderProved: true` (working-theory-agents#565 — a form lead never proves its sender). **The thread, its state, its pointers and the erasure (#9681, spec `docs/specs/9681-person-thread-read-spec.md`; `packages-paid/api-workspace/src/routes/workspaces/people-thread.ts`):** `GET …/people/:personId/thread` (`?limit=` 1–100, default 50, `&cursor=`) pages the whole thread newest first with no cap — every timeline kind plus `external_thread` pointers and `memory` events, never a body or a draft — each page carrying `state` (`readAt`, `archivedAt`, `resolvedAt`); a cursor it did not mint is `400 invalid_cursor`. `PATCH …/thread` takes `{ field, on, ifUpdatedAt }` (`ifUpdatedAt` is that field's own value as read; a stale one is `409 thread_state_changed`, nothing changed). `GET …/external-threads` lists the Person's email and Slack thread pointers (newest first, at most 200, `truncated`); `POST …/external-threads` upserts one by channel and ref (`201` new, `200` refreshed) — channel, ref (a Gmail thread id or a Slack `<channel>/<ts>`, never a URL), a ≤ 280-character summary (`400 summary_too_long`), `lastSeenAt` — and refuses any other key, so no message text is stored. Reads need `people-read`; writes need `people-read` AND `inbox-act` (an agent holds both only by grant). `DELETE …/people/:personId` erases the client in one transaction through `services/person-erasure.ts` (`people.erase`, #9680) — an owner's interactive cookie session only: any bearer caller that reaches it (an agent key whatever its grants, a hub credential) is `403 session_required` and writes one refused row naming it (#9837) — a personal API token is refused `403 forbidden` by the tenant guard before it — an admin's or a member's session is `403 forbidden`, both checked before any read; `503 erasure_unavailable` with nothing deleted when it cannot run. A Person linked to a sign-in User with no other tie (no membership, no other Person, no hub OAuth grant or refresh token, no billing customer, `free` tier, `user` role) tombstones that User in the same transaction — `erased+<id>@invalid`, no name or image, unverified, its sessions, accounts and API tokens deleted; a User with any other tie is untouched (#9875). Every request to these five routes writes one `people.*` audit row before its response (reads included; the erasure's success row names the Person id and table counts only); an agent's row carries `metadata.agentSession` `{ sessionId, threadId: hub:<personId>, reason }` from `X-Agent-Session` / `X-Agent-Reason` (`api-workspace/src/lib/agent-session.ts`; a bad header is dropped by the writer, never refused). Four MCP twins on `/mcp/workspace` — `get_person_thread`, `set_person_thread_state`, `list_external_threads`, `add_external_thread`; the erasure has no tool, by decision (recorded in `tests/parity/README.md`). **The client's memory (#9682, spec `docs/specs/9682-person-memory-routes-spec.md`; `packages-paid/api-workspace/src/routes/workspaces/people-memory.ts` over `services/person-memory.ts` and `tenantDb(tenant).people.memories`):** nine routes under `…/people/:personId/memories` — `GET` the index (an agent: live, unexpired entries only, filtered in the store; a person: every status, `expired` computed), `GET …/:path` (stamps `last_read_at`, so a person's read restores an expired document), `POST` a proposal (`path` in the body; `201` a new draft, `200` a draft's next version or a live document's pending version — never `live`; the `sourceRef` must name a row of this workspace's Person, else `400 invalid_source`), and six person-only routes — `PATCH …/:path` (`ifVersion` required), `DELETE …/:path`, `POST …/:path/confirm` (`{ version }`, which must be the highest), `POST …/:path/redact` (the document and every version, terminally), `GET …/:path/versions`, `POST …/:path/versions/:n/restore` (appends a copy). Reads need `people-read` or `people-memory-propose`; a proposal needs `people-memory-propose`; the person-only routes refuse every agent key whatever its grants, and an agent body carrying `status` is `403` before it is parsed. Every write takes the document's row lock; a stale `ifVersion` or confirm is `409 memory_changed`; a redacted or expired path `409 path_unavailable`; 200 topics, 50 drafts or 100 versions `409 memory_full`. Expiry is `people.memory_expiry_days` (180) in `project.yaml`, read through `PERSON_MEMORY_EXPIRY_DAYS`; nothing is deleted by it. Every request writes one `people.memory*` audit row (ids, enums and counts only, never a path, title or body). Three MCP twins — `list_person_memories`, `get_person_memory`, `propose_person_memory`; the six person-only routes have no tool, by decision (recorded in `tests/parity/README.md`).
|
|
84
|
-
- `/v1/workspaces/:workspaceId/people/:personId/pieces` (GET, POST), `…/people/:personId/pieces/:pieceId` (PATCH), `/v1/workspaces/:workspaceId/piece-stages` (GET, PUT), `…/people/:personId/plan` (PUT), `…/people/:personId/messages` (GET, POST) — the member area as an operator keeps it (#7877, `src/routes/workspaces/{pieces,plan,messages-out}.ts` over `tenantDb(tenant).memberArea`; spec `docs/specs/7877-the-member-area-contract-a-spec.md`). A **Piece** is one deliverable for one client: a title (1–200), a **stage** drawn from the workspace's OWN declared list (`WorkspacePieceStages`, one row per workspace; no row is the product default `requested, in-progress, in-review, delivered`, which lives only in `lib/tenant-db.ts`'s `DEFAULT_PIECE_STAGES` — there is no stage enum anywhere), a `needsClient` flag and `updatedBy`. The **plan** is `Person.planOffering` (#852's field — never a Plan model), written only by `services/person.ts`'s `setPersonPlanOffering`. A **message** is an `Interaction` on the `member` channel, direction `outbound`: a plain-text subject (1–200) and body (1–10000), both stripped of control and bidi characters (the body keeps `\n`, `\t`), and an optional attachment that must be an `Asset` of THIS workspace tagged `private` with a raster-image or PDF type (never SVG) — `400 asset_not_found` / `400 asset_not_attachable` otherwise. Both records reference a Person of the same workspace through a composite `(person_id, workspace_id)` foreign key. Role floors from `@working-theory/validation`'s permission table: the two list reads need `people-read`, the stage PUT `workspace-settings`, the stage GET the guard alone, each checked by role (`roleAllows(tenant, …)`) before any read, so an agent's `people-read` grant does not reach pieces or message bodies; the four writes — piece POST and PATCH, plan PUT, message POST — need `member-pieces-manage` (owner, admin, member since #8514), checked with `allows(tenant, "member-pieces-manage")` before any read, so an agent key granted it (#7878, the seventh `AGENT_GRANTABLE_PERMISSIONS` entry) keeps them in its one workspace, attaching only an asset the message's own checks admit; attaching a file to a message or delivering one as a version also needs `assets-write` (#8514 D19), so a member sends text and link versions, never a private file. `GET …/plan-offerings` (#8514 D9, `people-read`) lists plan keys for the Admin's picker from one of two sources (#8934, `services/plan-offerings.ts`): with the `commerce` claim, a connected workspace with a current Offerings snapshot lists one item per sellable Price in its shown set (`getWorkspaceShownSet`, never Stripe) — `key` the full lookup key, `name` the product name and its billing word, `type` `plan` or `addon`; an unclaimed workspace, or one with no connection or no selection, lists its own Service then Product pages (`services/public-site-resolver.ts`'s `workspaceContentAdapter`: off the database adapter only the default workspace has content, every other workspace `[]`). An undetermined licence or an expired or empty snapshot is a failed read (`{ offerings: [] }` and one warn line), and so the magnet checkout check and the purchase name fail closed. `people-write` (owner, admin, member since #8514) is never agent-grantable and is no longer read by these routes — the Admin person page reads it to render its forms. A malformed, absent or foreign Person or piece is one `404 not_found`; a stage outside the declared list `400 invalid_stage`; a stage-list PUT that drops a stage a piece still holds `409 stage_in_use` with nothing written — piece writes and stage changes take one per-workspace advisory lock first, in one transaction. Lists hold at most 50 rows, newest first. Every write that reaches the store records one `recordAuditAction` row naming the actor (`people.piece.create`, `people.piece.update`, `people.stages.set`, `people.plan.set`, `people.message.create`); no audit summary or log line carries a title, subject, body or offering. The operator's message read never stamps `readAt`, and serves an attachment by its sanitized download name, never its asset id. **The checkout plan rule:** a `POST /v1/leads/submit` from a registered spoke (`viaSpoke`) whose `Idempotency-Key` is a LIVE Checkout Session id (`cs_live_…`), with `source: "web"`, `payload.page: "checkout"`, a string `payload.offering` and `payload.site`, and an `amountTotal` that is absent, `null` or positive, names a plan — the offering minus a leading `<site>_` — but never writes one (#8793): its email is what the buyer typed at Stripe Checkout, so the lead HOLDS the plan (`services/lead-routing.ts`, one `{ event: "checkout-plan", outcome: "held" | "skipped", siteId }` line, no Person call; a request relayed for a visitor never reaches it). `services/person.ts`'s `releaseHeldCheckoutPlan` releases it once, when `resolveMemberPersonId` (a member read, or `GET /v1/hub-sign-in/me`) first links the Person to the signed-in user whose verified email is the Person's own (a fresh `stamped` only — never a Person linked earlier, never the Inbox accept's link): into a blank plan only, through `setPersonPlanOffering`'s link guard, and only when the Person has at most 50 marked leads and those under that email all name one key — two keys, or more than 50 marked leads, release nothing and the operator sets the plan. Never on a replay, never refusing or changing the lead, the purchase message, the purchase email or the consent grant, and with no spoke change. Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). **Proposals (#8516, spec `docs/specs/8516-proposals-as-an-artifact-kind-spec.md`):** a piece now carries a `kind` — `deliverable` (every piece before #8516, the column default) or `proposal` — served on every piece row and never set or changed through the piece routes (a body naming it is `400 validation_failed`), plus a proposal's optional price (minor units of `usd`/`eur`/`gbp`/`cad`/`aud`). `GET …/people/:personId/proposals` (a person's `people-read` role or an agent's `member-pieces-manage` grant) lists the Person's proposals with their price, approval, paid stamp and latest version, and `payments` (`on-approval` or `off`); `POST …/people/:personId/proposals` (`member-pieces-manage`; a file version also `assets-write`) is Send proposal — the proposal (`needsClient: true`, the first declared stage unless named) and its version 1 in one transaction under the piece lock, one `people.proposal.create` audit row whose summary is `{ priced }` alone. **Only a person prices (C4, the money gate):** an agent's create with a price, and an agent's PATCH or Deliver a version on a priced proposal, are `403 proposal_needs_person` with nothing written. Both read and write through `tenantDb(tenant).memberArea`. The proposals pair has MCP twins on `/mcp/workspace`: `list_proposals` and `create_proposal` (#8516 D7). **Start onboarding (#8515 D5, D6):** `POST …/people/:personId/onboarding` (`member-pieces-manage` through `allows`) takes `{ subject, body }` (the message bounds, no attachment — a body naming `assetId` is `400 validation_failed`) and writes one outbound member message whose `payload` is `{ action: "onboarding" }` — the one outbound payload `interactions_outbound_shape` admits — which the client's own site renders as a link to its `/account/onboarding`; it reads the workspace's onboarding magnet through `tenantDb(tenant).flows.bySlug` and never seeds one: no `diagnostic` row at that slug with `
|
|
84
|
+
- `/v1/workspaces/:workspaceId/people/:personId/pieces` (GET, POST), `…/people/:personId/pieces/:pieceId` (PATCH), `/v1/workspaces/:workspaceId/piece-stages` (GET, PUT), `…/people/:personId/plan` (PUT), `…/people/:personId/messages` (GET, POST) — the member area as an operator keeps it (#7877, `src/routes/workspaces/{pieces,plan,messages-out}.ts` over `tenantDb(tenant).memberArea`; spec `docs/specs/7877-the-member-area-contract-a-spec.md`). A **Piece** is one deliverable for one client: a title (1–200), a **stage** drawn from the workspace's OWN declared list (`WorkspacePieceStages`, one row per workspace; no row is the product default `requested, in-progress, in-review, delivered`, which lives only in `lib/tenant-db.ts`'s `DEFAULT_PIECE_STAGES` — there is no stage enum anywhere), a `needsClient` flag and `updatedBy`. The **plan** is `Person.planOffering` (#852's field — never a Plan model), written only by `services/person.ts`'s `setPersonPlanOffering`. A **message** is an `Interaction` on the `member` channel, direction `outbound`: a plain-text subject (1–200) and body (1–10000), both stripped of control and bidi characters (the body keeps `\n`, `\t`), and an optional attachment that must be an `Asset` of THIS workspace tagged `private` with a raster-image or PDF type (never SVG) — `400 asset_not_found` / `400 asset_not_attachable` otherwise. Both records reference a Person of the same workspace through a composite `(person_id, workspace_id)` foreign key. Role floors from `@working-theory/validation`'s permission table: the two list reads need `people-read`, the stage PUT `workspace-settings`, the stage GET the guard alone, each checked by role (`roleAllows(tenant, …)`) before any read, so an agent's `people-read` grant does not reach pieces or message bodies; the four writes — piece POST and PATCH, plan PUT, message POST — need `member-pieces-manage` (owner, admin, member since #8514), checked with `allows(tenant, "member-pieces-manage")` before any read, so an agent key granted it (#7878, the seventh `AGENT_GRANTABLE_PERMISSIONS` entry) keeps them in its one workspace, attaching only an asset the message's own checks admit; attaching a file to a message or delivering one as a version also needs `assets-write` (#8514 D19), so a member sends text and link versions, never a private file. `GET …/plan-offerings` (#8514 D9, `people-read`) lists plan keys for the Admin's picker from one of two sources (#8934, `services/plan-offerings.ts`): with the `commerce` claim, a connected workspace with a current Offerings snapshot lists one item per sellable Price in its shown set (`getWorkspaceShownSet`, never Stripe) — `key` the full lookup key, `name` the product name and its billing word, `type` `plan` or `addon`; an unclaimed workspace, or one with no connection or no selection, lists its own Service then Product pages (`services/public-site-resolver.ts`'s `workspaceContentAdapter`: off the database adapter only the default workspace has content, every other workspace `[]`). An undetermined licence or an expired or empty snapshot is a failed read (`{ offerings: [] }` and one warn line), and so the magnet checkout check and the purchase name fail closed. `people-write` (owner, admin, member since #8514) is never agent-grantable and is no longer read by these routes — the Admin person page reads it to render its forms. A malformed, absent or foreign Person or piece is one `404 not_found`; a stage outside the declared list `400 invalid_stage`; a stage-list PUT that drops a stage a piece still holds `409 stage_in_use` with nothing written — piece writes and stage changes take one per-workspace advisory lock first, in one transaction. Lists hold at most 50 rows, newest first. Every write that reaches the store records one `recordAuditAction` row naming the actor (`people.piece.create`, `people.piece.update`, `people.stages.set`, `people.plan.set`, `people.message.create`); no audit summary or log line carries a title, subject, body or offering. The operator's message read never stamps `readAt`, and serves an attachment by its sanitized download name, never its asset id. **The checkout plan rule:** a `POST /v1/leads/submit` from a registered spoke (`viaSpoke`) whose `Idempotency-Key` is a LIVE Checkout Session id (`cs_live_…`), with `source: "web"`, `payload.page: "checkout"`, a string `payload.offering` and `payload.site`, and an `amountTotal` that is absent, `null` or positive, names a plan — the offering minus a leading `<site>_` — but never writes one (#8793): its email is what the buyer typed at Stripe Checkout, so the lead HOLDS the plan (`services/lead-routing.ts`, one `{ event: "checkout-plan", outcome: "held" | "skipped", siteId }` line, no Person call; a request relayed for a visitor never reaches it). `services/person.ts`'s `releaseHeldCheckoutPlan` releases it once, when `resolveMemberPersonId` (a member read, or `GET /v1/hub-sign-in/me`) first links the Person to the signed-in user whose verified email is the Person's own (a fresh `stamped` only — never a Person linked earlier, never the Inbox accept's link): into a blank plan only, through `setPersonPlanOffering`'s link guard, and only when the Person has at most 50 marked leads and those under that email all name one key — two keys, or more than 50 marked leads, release nothing and the operator sets the plan. Never on a replay, never refusing or changing the lead, the purchase message, the purchase email or the consent grant, and with no spoke change. Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). **Proposals (#8516, spec `docs/specs/8516-proposals-as-an-artifact-kind-spec.md`):** a piece now carries a `kind` — `deliverable` (every piece before #8516, the column default) or `proposal` — served on every piece row and never set or changed through the piece routes (a body naming it is `400 validation_failed`), plus a proposal's optional price (minor units of `usd`/`eur`/`gbp`/`cad`/`aud`). `GET …/people/:personId/proposals` (a person's `people-read` role or an agent's `member-pieces-manage` grant) lists the Person's proposals with their price, approval, paid stamp and latest version, and `payments` (`on-approval` or `off`); `POST …/people/:personId/proposals` (`member-pieces-manage`; a file version also `assets-write`) is Send proposal — the proposal (`needsClient: true`, the first declared stage unless named) and its version 1 in one transaction under the piece lock, one `people.proposal.create` audit row whose summary is `{ priced }` alone. **Only a person prices (C4, the money gate):** an agent's create with a price, and an agent's PATCH or Deliver a version on a priced proposal, are `403 proposal_needs_person` with nothing written. Both read and write through `tenantDb(tenant).memberArea`. The proposals pair has MCP twins on `/mcp/workspace`: `list_proposals` and `create_proposal` (#8516 D7). **Start onboarding (#8515 D5, D6):** `POST …/people/:personId/onboarding` (`member-pieces-manage` through `allows`) takes `{ subject, body }` (the message bounds, no attachment — a body naming `assetId` is `400 validation_failed`) and writes one outbound member message whose `payload` is `{ action: "onboarding" }` — the one outbound payload `interactions_outbound_shape` admits — which the client's own site renders as a link to its `/account/onboarding`; it reads the workspace's onboarding magnet through `tenantDb(tenant).flows.bySlug` and never seeds one: no `diagnostic` row at that slug with `who: "user"` is `409 onboarding_off` with nothing written. A malformed, absent or foreign Person is the same `404 not_found`; one `people.onboarding.start` audit row, no summary. The message POST never carries the action. The other member-area routes have no MCP twin, by decision — member-area MCP tools are a later phase of the CRM nucleus, recorded in `tests/parity/README.md`.
|
|
85
85
|
- `…/people/:personId/messages` (POST) emails, and `/v1/workspaces/:workspaceId/people/:personId/invitation` (GET, POST) invites (#9690, spec `docs/specs/9690-invite-and-reply-spec.md`): a reply is saved, then emailed in full with a link to the member area (`/account/inbox/message/:id`) when the Person's address is proved, else to sign-up — `201 { message, delivery }`, where a failed email never fails the 201 and an agent's reply goes only to a proved address (`withheld` otherwise); it writes no thread state. The invitation pair (a person only — every agent is `403` with one `refused` row) reports `{ hasAccount, lastInvitedAt }` and, on `{ "invite": true }`, emails the site's sign-in link and creates nothing; a repeat is a Resend, a proved Person is `409 has_account`. Both emails come from `AILK_INBOX_FROM`, else `AILK_LEAD_NOTIFICATION_FROM`, through `@working-theory/api/services/inbox-mail.js`. Per-caller send caps (`api-workspace/src/services/send-caps.ts`: 20 sends an hour per caller, 10 replies an hour and 3 invitations a day per caller to one Person) are counted from the rows each send writes before its email, under a per-workspace advisory lock (`tenantDb.inboxSends`); a capped send is `429` with `Retry-After` and the named reason, a lock or count failure `503 send_cap_unavailable`. No MCP twin for the invitation pair (it refuses agents; `tests/parity/README.md`).
|
|
86
86
|
- `/v1/member/pieces` (GET), `/v1/member/messages` (GET), `/v1/member/messages/:id/file` (GET) — the member reads: a client's OWN pieces and messages, served to the spoke that signed them in (#7877, `src/routes/member/{pieces,messages}.ts`). Registered in `authScope` only (auth, NOT tenant-guarded — a client holds no membership; an agent key is 403 at the preHandler) under the same `workspace || multi-tenant` predicate, at the exact URLs `HUB_SESSION_ROUTES` admits a hub credential on. Who and where come only from `services/member-area.ts`'s `resolveMember`, before any member read: the caller through `middleware/auth.ts`'s `memberCaller` — the hub's own cookie session or a hub credential, never a personal `ApiToken` (`401 { error: "unauthorized" }`); the workspace from the calling spoke's `x-ailk-spoke-key` (a keyless call only on a one-workspace deployment that is not a hub), and a hub credential must name that same workspace, else `503 tenant_unresolved`; an unverified email `403 email_unverified`. The Person is the one linked by `Person.userId`, else the one whose email is the caller's verified email, linked on that first read by `stampPersonUser`'s rule; no Person is `items: []`. Every read goes through the named boundary `src/lib/member-store.ts`, scoped by that resolved workspace AND Person: at most 50 rows, newest first; a message serves `{ id, subject, body, file: { filename, contentType } | null, createdAt, readAt }` — never an asset, creator, person or workspace id — and the first read stamps `readAt` ("shown to the client's spoke", not "read"; served as it stood before the stamp). The file read streams the attachment only when the message is the caller's own and its stored type is attachable — any other case is one `404 not_found` — `attachment` with a sanitized, extension-pinned name, `nosniff`, the sandbox `Content-Security-Policy` and `Cache-Control: private, no-store`. `@working-theory/api-client`'s `member.*` is the spoke's caller; a spoke that re-serves the bytes sends those five headers unchanged. No MCP twin, by decision — identity-bound client reads behind a hub credential; the anonymous MCP surface is identity-blind, recorded in `tests/parity/README.md`.
|
|
87
|
-
- `/v1/member/purchases`, `/v1/member/appointments`, `/v1/member/artifacts`, `/v1/member/artifacts/:pieceId/versions/:version/file`, `/v1/member/meeting-types`, `/v1/member/availability`, `/v1/member/request-form`, `/v1/member/billing` (all GET) — the Phase 1 member reads (#8169, `src/routes/member/{purchases,appointments,artifacts,meeting-types,availability,request-form,billing}.ts`; spec `docs/specs/8169-member-reads-spec.md`). Same caller seam, refusals and registration as the bullet above, each read through one `src/lib/member-store.ts` function, each success body re-parsed against its `@working-theory/validation` `member.ts` schema. All eight are in `HUB_SESSION_ROUTES` (#8220). Appointments, artifacts and the file read are scoped by the resolved workspace AND Person (≤ 50, never an attendee's name, email or time zone; never a version or asset id; the file read is one `404 not_found` for every miss, a version above the 32-bit column included, and sends `messages.ts`'s five headers). **Purchases and billing read at workspace grain** (#9966 D4, replacing #8140's organization grain): only Persons in the calling workspace whose `userId` is ALREADY the caller (`lib/member-store.ts`'s `linkedPersonsInWorkspace`, pinned by `src/__tests__/member-isolation.test.ts`) — they link nothing, and a spoke's credential never reads a sibling workspace of its organization. A purchase is a completed hub `FlowCheckout` whose lead carries the caller's VERIFIED `User.email` (case-insensitive; never the Person's email, which is whatever a buyer typed at intake — #8241), or (#8185) a `Lead` the hub marked `spokeCheckout` when it was written — a registered spoke's unrelayed (`x-ailk-relayed` absent), LIVE paid checkout, the #7877 plan rule's own predicate evaluated in `services/lead-routing.ts` before the create and never updated, or (#8932) the per-Organization Stripe webhook's live, recorded, paid Offerings session, marked by that predicate's Offerings twin — served with the same Person-workspace and verified-email rules, its offering kept only if it is a plan-offering key (SEC-8169-6); an anonymous, relayed, test-mode or pre-#8185 lead is never marked and never served (no backfill), and `tests/architecture/idempotency-key-producers.test.ts` fails if any spoke-side file, MCP tool or client wrapper starts sending an `Idempotency-Key`. **Billing answers a hub credential only**: any other caller gets `404 not_found` before `resolveMember` and before any read, and no log line ever names a `cus_` id; a hub credential reads it at the workspace grain. Meeting types and availability join the workspace's `customerSlugs` (independent of `enabledSlugs`) to the scheduling provider — a provider failure is its `SCHEDULING_ERROR_STATUS`, never an empty list; a slug outside `customerSlugs` is `404 meeting_type_not_offered` with no provider call — and register under `scheduling && (workspace || multi-tenant)`, because only the `scheduling` module delivers `services/scheduling/`. The request form is the `request-quote` contact row while `
|
|
87
|
+
- `/v1/member/purchases`, `/v1/member/appointments`, `/v1/member/artifacts`, `/v1/member/artifacts/:pieceId/versions/:version/file`, `/v1/member/meeting-types`, `/v1/member/availability`, `/v1/member/request-form`, `/v1/member/billing` (all GET) — the Phase 1 member reads (#8169, `src/routes/member/{purchases,appointments,artifacts,meeting-types,availability,request-form,billing}.ts`; spec `docs/specs/8169-member-reads-spec.md`). Same caller seam, refusals and registration as the bullet above, each read through one `src/lib/member-store.ts` function, each success body re-parsed against its `@working-theory/validation` `member.ts` schema. All eight are in `HUB_SESSION_ROUTES` (#8220). Appointments, artifacts and the file read are scoped by the resolved workspace AND Person (≤ 50, never an attendee's name, email or time zone; never a version or asset id; the file read is one `404 not_found` for every miss, a version above the 32-bit column included, and sends `messages.ts`'s five headers). **Purchases and billing read at workspace grain** (#9966 D4, replacing #8140's organization grain): only Persons in the calling workspace whose `userId` is ALREADY the caller (`lib/member-store.ts`'s `linkedPersonsInWorkspace`, pinned by `src/__tests__/member-isolation.test.ts`) — they link nothing, and a spoke's credential never reads a sibling workspace of its organization. A purchase is a completed hub `FlowCheckout` whose lead carries the caller's VERIFIED `User.email` (case-insensitive; never the Person's email, which is whatever a buyer typed at intake — #8241), or (#8185) a `Lead` the hub marked `spokeCheckout` when it was written — a registered spoke's unrelayed (`x-ailk-relayed` absent), LIVE paid checkout, the #7877 plan rule's own predicate evaluated in `services/lead-routing.ts` before the create and never updated, or (#8932) the per-Organization Stripe webhook's live, recorded, paid Offerings session, marked by that predicate's Offerings twin — served with the same Person-workspace and verified-email rules, its offering kept only if it is a plan-offering key (SEC-8169-6); an anonymous, relayed, test-mode or pre-#8185 lead is never marked and never served (no backfill), and `tests/architecture/idempotency-key-producers.test.ts` fails if any spoke-side file, MCP tool or client wrapper starts sending an `Idempotency-Key`. **Billing answers a hub credential only**: any other caller gets `404 not_found` before `resolveMember` and before any read, and no log line ever names a `cus_` id; a hub credential reads it at the workspace grain. Meeting types and availability join the workspace's `customerSlugs` (independent of `enabledSlugs`) to the scheduling provider — a provider failure is its `SCHEDULING_ERROR_STATUS`, never an empty list; a slug outside `customerSlugs` is `404 meeting_type_not_offered` with no provider call — and register under `scheduling && (workspace || multi-tenant)`, because only the `scheduling` module delivers `services/scheduling/`. The request form is the `request-quote` contact row while `who` is `user`, whatever its status, else `{ form: null }`. **`GET /v1/member/onboarding` (#8515 D7)** serves the standard onboarding magnet — the `diagnostic` row at slug `onboarding` while `who` is `user`, whatever its status — reduced to display keys (a field's name, input type, `required`, select options and label/help/placeholder; a choice question's name, options, `multi` and `exclusiveValues`; never a `showIf`, `mapsTo`, `purposes`, field rule, `segments`, `whenHidden` or `resultsReveal`), else `{ form: null }`. `GET /v1/member/messages` items carry `action: "onboarding" | null` (#8515 D6). No MCP twin, by decision — the same reason, recorded in `tests/parity/README.md`.
|
|
88
88
|
- `/v1/member/requests` (POST), `/v1/member/messages` (POST), `/v1/member/bookings` (POST), `/v1/member/artifacts/:pieceId/review` (POST), `/v1/member/profile` (PATCH), `/v1/member/profile/contact` (GET, PATCH), `/v1/member/deletion-request` (POST) — the Phase 1 member writes (#8170, the contact pair #8435, `src/routes/member/{requests,messages,bookings,artifact-review,profile,deletion-request}.ts`; spec `docs/specs/8170-member-writes-spec.md`). Same caller seam and refusals as the reads, and a cookie write must carry a trusted `Origin` (the preHandler's 401). Nothing in a body, path or query names a person, user or workspace; every body parses its `@working-theory/validation` `member.ts` schema (`400 invalid_input`, or `400 link_not_https` when a link is why) and every success body is re-parsed before it is sent. **Requests, messages and reviews are each ONE inbound `Interaction` on the caller's own Person** — channel `member`, direction `inbound`, `createdBy` the caller — and never a `Lead`: a request is checked against the `request-quote` magnet's CURRENT field steps (`400 answers_invalid` for a key the form does not ask, a missing required answer, an array answer or a failed field rule; `409 request_form_off` when the form is off or the caller has no Person) and stores `{ flowSlug, items }` with each label as the form shows it; a message stores `{ link }` when one is sent (`404 not_found` with no Person). **The cap:** every inbound write runs in one transaction whose first statement is the Person's advisory lock (`ailk:member-inbound:<personId>`), then counts the Person's inbound rows of the last hour — 30 or more is `429 too_many_requests` with nothing written. **The review** lands only on the piece's LATEST version (`409 version_not_latest`), only once per version and never after `Piece.approvedAt` is set (`409 review_closed`); a missing, foreign or approved piece is refused from one read before any lock, otherwise the Person lock then the workspace's piece lock (`lib/tenant-db.ts`'s `pieceStagesLockKey`, the key the operator's piece writes take), and it writes the version's `review`, `reviewComment`, `reviewedAt`, the piece's `approvedAt` on an approval and the `review` Interaction in that one transaction — never a title, stage, link, asset or note; asking for changes stays open on every new latest version (#8140 disposition 3). **The booking** books an offered `customerSlugs` meeting type at `<handle>/<slug>` as the caller (verified email, own name), refuses a Person holding 10 upcoming non-cancelled appointments `429` before any provider call, answers a provider failure through `SCHEDULING_ERROR_STATUS`, and binds a spoke caller's booking to its workspace as `/v1/schedule/book` does; it registers under `scheduling && (workspace || multi-tenant)`. **The profile** writes only the caller's own `User.name` / `User.image` (https only) and answers `{ name, image }`. **The contact** (`profile/contact`, #8435) is the caller's contact details on THIS site, never on the hub `User` and never synced to another site: the GET serves the contact of the caller's linked Person only once a human saved it (`contactConfirmedAt` set — a value only an anonymous capture wrote is `{ contact: null }`, SEC-8435-B); the PATCH takes all six keys, each nullable and required, and REPLACES the whole contact (so no value the client never saw survives), stamping `contactConfirmedBy: "client"`. With no Person yet it finds or creates the Person for the caller's VERIFIED email and links it through `stampPersonUser`'s rule; any outcome but linked is `409 contact_unavailable`, nothing written. Only the caller's own linked Person (`{ id, workspaceId, userId }`) is ever written; `400 invalid_input` or `400 telephone_invalid` for a refused body, an 8 KiB body bound, and `{ event: "member-contact", outcome }` as its one log line. **The deletion request** is filed through `routeLead` as a `web` message lead (kind `deletion`, the caller's verified email, no idempotency key) into `resolveSiteWorkspaceId()` — the deployment's own site workspace, so a spoke caller's request reaches the hub operator (`sentTo: "hub-operator"`), never the spoke's. Every data write goes through `src/lib/member-store.ts`; every write logs only `{ event, outcome }` and a `ref` of at most 8 characters (`services/member-area.ts`'s `memberWriteLog`), never a subject, body, answer, link, note, name, email or full id. All six (and the two contact routes) are in `HUB_SESSION_ROUTES` (#8220): a spoke's deletion request reaches the hub operator, and a spoke's profile write changes the name and photo the hub's `me` answers. **The onboarding submission (#8515 D8–D10):** `POST /v1/member/onboarding` takes the request body's `{ answers }` (`plainText`, ≤ 64), checks them against the served onboarding form (every key a served field or question; a required field non-blank; `email`, https `url`, `select` options and `minLength` as the request checker applies them; a single choice one option value; a `multi` choice 1…options distinct option values, an exclusive value only alone — `400 answers_invalid` / `link_not_https`; `409 onboarding_off` with no Person or no form) and writes ONE `flow:onboarding` `Lead` — never an `Interaction` — on the caller's own Person with the Person's email, the answers, `flowSlug`, `locale` and the server-written `submittedBy` (the caller's user id, a server-owned key the Inbox never shows as an answer), so it is an Inbox submission and a `lead` Activity entry with no reader change. Under the Person's inbound lock, at most five of the caller's OWN submissions an hour (`429 too_many_requests`) — only leads carrying the caller's `submittedBy` count, so an anonymous `POST /v1/leads/submit` with that source and the client's email never fills the allowance. Both onboarding routes are in `HUB_SESSION_ROUTES`. **A proposal (#8516):** the artifacts read and the review answer carry `kind`, `price` and `payment` (`{ status: "due" }` for an approved, priced, unpaid proposal where payment runs; `{ status: "paid", paidAt }` once paid; else `null`), and a proposal's review — the same write, which is how a proposal is accepted or sent back — also writes one `people.proposal.client_review` audit row as the client (`recordWorkspaceAuditAction`). **`POST /v1/member/artifacts/:pieceId/payment`** opens the client's Stripe Checkout page for their own approved, priced, unpaid proposal and answers `{ url }` — a `checkout.stripe.com` URL only (`isStripeCheckoutUrl`, checked before anything is stored), `Cache-Control: private, no-store`. It sits behind the commerce claim (`requireCommerceClaim`, the module-disabled 404 before any read) and its own strict per-IP limiter (`proposalpay`); it is NOT on `HUB_SESSION_ROUTES`. **Same site, sandbox only:** `services/proposal-payments.ts`'s `proposalPaymentsFor` is `on-approval` only with the claim, on a one-workspace deployment whose workspace is the proposal's (the deployment's one Stripe account is provably that seller's), with a key `assertSandboxKey` admits and `FRONTEND_URL` set — and never for a call carrying a spoke key or a hub credential; anywhere else approval records acceptance only and the pay route is `404 not_found`. The session is card only, expires in an hour, carries a fixed product name and the stored amount (never a request value), and is reused while it has more than five minutes left; a concurrent second session is expired. `409 proposal_not_approved` / `409 proposal_paid` / `502 payment_unavailable`; each opened session writes one `people.proposal.checkout` audit row. No MCP twin, by decision — identity-bound client writes, recorded in `tests/parity/README.md`.
|
|
89
89
|
- `/v1/consent/cookie` (POST), `/v1/consent/unsubscribe` (POST), `/v1/consent/sign-up` (GET), `/v1/consent/account` (GET, POST), `/v1/consent/platform` (GET, POST), `/v1/member/consents` (GET, POST), `/v1/workspaces/:workspaceId/people/:personId/consents` (GET) — the consent ledger's routes (#8438, spec `docs/specs/8438-consent-registry-and-ledger-step-spec.md`; #8765, spec `docs/specs/8765-sign-up-refuses-without-the-spec.md`). `consent_grants` is THE ledger: append-only (the database refuses any change to a stored row, and any truncation, on it and on `consent_visitor_links`), every row naming its `source`, its `policyVersion` and, when known, its Person; it has two writers — `src/services/consent.ts` (`recordConsent`, `recordPersonConsent`, `recordCookieChoice`, `linkConsentVisitor`, `recordCheckoutConsent`; every source) and the web's sign-up hook `apps/web/lib/sign-up-agreements.ts` (#8765 D4), which writes only `sign_up` rows (subject `user`) of a user it creates in the same Better Auth transaction, from the evidence `GET /v1/consent/sign-up` built with `consent.ts`'s own functions — and every reader is `src/lib/consent-store.ts` (`DISTINCT ON` current-state reads, never a window). **Two readings of a Person's rows:** the OWN reading (`sign_up`, `account_settings`, `email_unsubscribe` — a verified caller or a signed link) is what the member's screens and any send read; the ANY-SOURCE reading (adds `magnet`, `api`, `cookie_banner`, `checkout`, and linked visitors' cookie rows) is the operator's, marked `unverified` — an anonymous form's row never changes a member's own answer. **The checkout grant (#8765 D13, D14):** `checkout` is the eighth source (the `consent_grants_source_known` CHECK names all eight). The hub webhook's `checkout.session.completed` arm, after the flow-checkout Person step, writes one `account_terms` row (subject `person`, source `checkout`, `sourceRef` the Checkout Session id, locale `en`, the current statement and version) through `recordCheckoutConsent` — one transaction under the `ailk:consent:<workspaceId>:checkout:<sessionId>` lock, nothing written when that Person already holds that session's row — only when `consent.terms_of_service` is `accepted` and `metadata.ailk_terms` is `account_terms@<current version>`; the Person is `services/checkout-buyer.ts`'s `checkoutBuyerPerson` (a proposal session → the piece's Person in its `ailk_workspace` through `lib/proposal-paid-store.ts`'s `proposalPersonId`; a flow checkout → the Person the Person step's `resolveCheckoutPerson` found or created, never created here; a signed-in buyer's `ailk_user_id` → the Person of that user's verified email in the site workspace, test mode included; else, for a paid live-mode session only, `customer_details.email`'s Person). **A spoke purchase records the same grant (#8778):** a spoke's checkout runs on the spoke's own Stripe account, so the hub webhook never sees it; instead the first write of a `spokeCheckout` lead with a Person (`services/lead-routing.ts`) whose `payload.termsAccepted` equals the current `account_terms@<version>` stamp (`currentAccountTermsStamp`) calls `recordCheckoutConsent` for the lead's Person, `sourceRef` the lead's `Idempotency-Key` (the Checkout Session id) — a `null` or absent value writes nothing (`consent_absent`), any other value nothing (`terms_unstamped`), a replay never reaches it (and `termsAccepted` is left out of the replay comparison, so a redelivery across a spoke's update still replays), and the store's per-session guard keeps it to one row; it logs `{ event: "checkout-consent", outcome, siteId }` only and never changes the lead's answer. A `checkout` row is unverified: the payer is not a signed-in user, so it never satisfies the account interstitial. It logs `{ event: "checkout-consent", outcome, eventIdPrefix }` only (`consent_absent`, `terms_unstamped`, `person_unresolved`, `recorded`, `unchanged`, `version_changed` — the site registry has moved past the stamped version, so nothing is written — and `failed`, which adds the error's `errName`) and never changes the 200. A write appends only when the answer changes the reading it is checked against, under an advisory lock on the (subject, purpose) pair. `POST /v1/consent/cookie` takes `{ visitorId, choice, locale }` and never a workspace (it comes from `resolvePublicWorkspace`); `POST /v1/consent/unsubscribe` takes a token signed with `AILK_CONSENT_LINK_SECRET` (≥ 32 bytes, optional; unset ⇒ `503 link_secret_not_configured`) and answers every defect as one `400 invalid_link`. Both are top-level, outside every module gate, in their own rate-limit scopes (`consent`, `unsubscribe`) and on `FORWARDED_VISITOR_ROUTES`. **A sign-up agreement is the account's (#8765 C3, D5, D6):** a `signUp` purpose (`account_terms`) is recorded once per `User`, subject `user` / the user id in the deployment's own site workspace (`resolveSiteWorkspaceId()`, the owner platform consents' footing, read by `userPlatformRows`), never per Person, so the hub and every spoke signed in through it read one row. `GET /v1/consent/sign-up` is anonymous: `{ workspaceId, agreements: [{ purposeId, policyVersion, locale, statement, statementHash }] }` for the site workspace (`locale` default `en`, `null` workspace `503 tenant_unresolved`, `Cache-Control: no-store`), top level beside the cookie route in its own rate-limit scope `signupevidence`, outside every module gate. **Its budget is per site key (#8805):** a request presenting a valid `x-ailk-spoke-key` (`resolveCallerSite` → a registered, unrevoked spoke) is counted against that key's `site,<spokeId>` bucket at `DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE` (120; `AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE` overrides) and let through the server-wide per-IP ceiling, so a spoofed `X-Forwarded-For` cannot spend it; an invalid or absent key, or a failed lookup, keeps `DEFAULT_PER_IP_PER_MINUTE_STRICT` per IP. The key picks the bucket only — the workspace stays `resolveSiteWorkspaceId()` (`middleware/rate-limit.ts`'s `siteKeyReader`, `SITE_KEYED_ROUTES`). `GET /v1/consent/account` answers `{ agreements: [{ id, statement, policyVersion, acceptedVersion, acceptedAt, current }], complete }` for the caller (`memberCaller` — a cookie session or a hub credential; a personal token `401`); `POST` takes `{ purposeIds, locale? }`, refuses any id that is not a `signUp` agreement `404 purpose_unknown` before writing anything, writes each through `recordPersonConsent` (source `account_settings`, `sourceRef: spoke:<id>` for a hub credential) and answers the GET body; both register in `authScope` unconditionally and log `{ event: "account-agreements", outcome }` only. The member pair reads and writes that same row for a `signUp` purpose (no Person is created) and is unchanged for every other purpose; a `null` site workspace keeps the old per-Person reading; a hub credential's `via: "sign_up"` is `400 sign_up_window_closed` with nothing written (SEC-8765-B). The member pair and the account pair are in `HUB_SESSION_ROUTES` (26 entries since #8765); the anonymous `GET /v1/consent/sign-up` is not, and neither is `/v1/consent/platform` — it is the deployment owner's own consent to Working Theory, a cookie session holding `platform-consents`, under `multi-tenant`. The banner's visitor id travels in `x-ailk-consent-visitor` (only a v4 UUID counts) on the lead and flow routes and the member write, linking the visitor to the Person; the header is never echoed or logged. A `consent` flow field now stores `{ [purposeId]: boolean }` and writes the ledger at completion (source `magnet`, `sourceRef` the flow slug). The operator's `…/consents` answers `{ own, anySource, history, nextCursor }` (history 500 a page, `Cache-Control: private, no-store`, never a subject id). No MCP twin for any of them, recorded in `tests/parity/README.md`.
|
|
90
90
|
- `/v1/workspaces/:workspaceId/requests` (GET, `?limit=`), `/v1/workspaces/:workspaceId/requests/:requestId` (GET), `/v1/workspaces/:workspaceId/people/:personId/pieces/:pieceId/versions` (POST) — the provider routes (#8171, `src/routes/workspaces/{requests,pieces}.ts` over `tenantDb(tenant).memberArea`; spec `docs/specs/8171-provider-routes-the-workspace-requests-spec.md`). **The Requests read** lists the workspace's inbound member rows (#8170's messages, quote requests and reviews), newest first, `limit` 1–50 (default 50, no cursor), behind `inbox-read` (every role) checked with `can(tenant.role, …)`. It is tiered: a role holding `people-read` (owner, admin) gets the full row — the subject, body and the kind's own fields (a message's `link`, a quote's `items`, a review's `pieceId`, `version`, `review`); every other caller, every agent key included, gets `{ id, kind, person, createdAt }` and the content is never selected. A row whose payload fails its kind's schema, or whose Person is gone, is left out and counted in one `warn`. **Deliver a version** numbers version n+1 under the piece lock (`withPieceStagesLock`), behind `member-pieces-manage` through `allows` (an agent granted it delivers); the body is exactly one of an `https` link (`isHttpsLink`, else `400 link_not_https`) and a private attachable asset of this workspace (`400 asset_not_found` / `asset_not_attachable`), plus an optional note; `version`, `deliveredBy` and `deliveredAt` are the server's (a body naming one is `400 validation_failed`); a piece with `approvedAt` set is `409 piece_approved`; a malformed, absent or foreign Person or piece is `404 not_found`. There is no round count, limit or remaining count (#8140 disposition 3). It answers `201 { version: { id, pieceId, version, file, link, note, deliveredAt } }` — the file's download name, never an asset id or `deliveredBy` — and every call that reaches the store writes one `people.version.create` audit row. **The single Requests read (#9847, spec `docs/specs/9847-agent-reads-member-request-spec.md`):** `GET /v1/workspaces/:workspaceId/requests/:requestId` answers one inbound member row of the caller's workspace in the list's full shape (`workspaceRequestSchema`, `{ ok: true, data: <row> }`) through `tenantDb(tenant).memberArea.getRequest`. Gate: `inboxAllowed(tenant, "people-read")` before any read — a person whose role holds `people-read`, an agent holding BOTH the `people-read` and `inbox-act` grants (the reply grant, #9714 D5; `member-pieces-manage` is not an alternative); a refusal is `403`. A malformed, absent, outbound, other workspace's or unservable id (payload failing its kind's schema, Person gone — logged `dropped: 1` at `warn`) is one `404 not_found`. The subject, body and each quote answer pass `inbox-messages.ts`'s `stripHiddenText` (zero-width, bidi, BOM and tag characters, SEC-9847-1); the stored row is untouched. Every agent call writes exactly one `people.request.read` audit row — `refused`/`failed` against the workspace, `succeeded` against the interaction with summary `{ kind }` and thread `hub:<personId>` — carrying its session; a person's call writes none. The list's agent view stays the summary row. The thread's `request` entry carries `requestId`, the row's `intx_` id, so an agent can find it. Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). No MCP twin, by decision — member-area MCP tools are a later phase of the CRM nucleus, recorded in `tests/parity/README.md`.
|
|
@@ -234,7 +234,9 @@ sweep's own DB constraint (never a caller-side lock).
|
|
|
234
234
|
- Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
|
|
235
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.
|
|
236
236
|
|
|
237
|
-
Last updated: 2026-11-
|
|
237
|
+
Last updated: 2026-11-04 — #10819: the magnets bullet names `who` (a People type) in place of the member-area customer boolean, and the standard-magnet, onboarding, request-form and `409 onboarding_off` mentions key on `who: "user"` — verified against `packages-paid/api-signal-collectors/src/routes/magnets/index.ts`, `packages/api/src/lib/{tenant-db,member-store}.ts`, `packages/api/src/services/tenant-context.ts` and `packages-paid/api-workspace/src/routes/workspaces/messages-out.ts`.
|
|
238
|
+
|
|
239
|
+
Prior: 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
240
|
|
|
239
241
|
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`.
|
|
240
242
|
|
|
@@ -336,7 +338,7 @@ Prior: 2026-10-12 — the hub's restricted live Stripe key (#8450): the BYO Stri
|
|
|
336
338
|
|
|
337
339
|
Prior: 2026-10-11 — the spoke member admission (#8220): the hub-sign-in bullet names `HUB_SESSION_ROUTES`' 18 entries by group, the per-(person, spoke) budget, the `spokeId` log binding, the per-IP residual and the spoke's cross-site-request obligation; the #8169 and #8170 bullets say their routes are admitted, and the #8169 bullet states the one-owner-per-organization precondition — verified against `packages/api/src/middleware/auth.ts`, `packages/api/src/lib/member-store.ts`, `packages-paid/api-workspace/src/routes/member/billing.ts` and `packages-paid/api-multi-tenant/src/__tests__/member-isolation.test.ts`.
|
|
338
340
|
|
|
339
|
-
Prior: 2026-10-11 — the provider routes (#8171): added the bullet for the Requests read and Deliver a version, and extended the People (`request` and `version` timeline kinds), meetings (`customerSlugs`), magnets (`
|
|
341
|
+
Prior: 2026-10-11 — the provider routes (#8171): added the bullet for the Requests read and Deliver a version, and extended the People (`request` and `version` timeline kinds), meetings (`customerSlugs`), magnets (the member-area customer switch, now `who`), deliverables and asset-store (a version's file) bullets and added a `/v1/subscription/me` note (`?include=billing`) — verified against `apps/api/src/routes/workspaces/{requests,pieces,meetings}.ts`, `packages-paid/api-signal-collectors/src/routes/magnets/index.ts`, `packages-paid/api-commerce/src/routes/subscription/me.ts`, `packages-paid/api-commerce/src/services/subscription.ts` and `apps/api/src/lib/{tenant-db,asset-store,deliverable-asset-store,stripe}.ts`.
|
|
340
342
|
|
|
341
343
|
Prior: 2026-10-11 — the Phase 1 member writes (#8170): added the bullet for the six `/v1/member/*` writes (the inbound Interaction rows, the hourly cap under the Person lock, the review under the Person then piece lock, the booking bound, the deletion destination, the log rule) — verified against `apps/api/src/routes/member/*.ts`, `packages/api/src/lib/member-store.ts`, `packages/api/src/services/member-area.ts` and `packages/api/src/server.ts`.
|
|
342
344
|
|
|
@@ -225,6 +225,17 @@ export const prisma = {
|
|
|
225
225
|
update: jest.fn(),
|
|
226
226
|
updateMany: jest.fn(),
|
|
227
227
|
},
|
|
228
|
+
// ── Auth namespace (EmailVerification — #10820) ─────────────────────────
|
|
229
|
+
// The one email-verification service's table: `create` and `deleteMany`
|
|
230
|
+
// (mint, its expired-row sweep, a failed send's own row, submit's revoke),
|
|
231
|
+
// `findUnique` (a confirm's nonce-digest lookup and its re-read) and
|
|
232
|
+
// `updateMany` (the confirm's compare-and-swap).
|
|
233
|
+
emailVerification: {
|
|
234
|
+
create: jest.fn(),
|
|
235
|
+
deleteMany: jest.fn(),
|
|
236
|
+
findUnique: jest.fn(),
|
|
237
|
+
updateMany: jest.fn(),
|
|
238
|
+
},
|
|
228
239
|
// ── Inbox namespace (FlowDefinition, FlowSession — #3956) ────────────────
|
|
229
240
|
// findMany on both models — used by the abandon-email sweep (#3962 D4),
|
|
230
241
|
// which enumerates every FlowDefinition (to read its live abandonEmail
|
|
@@ -382,6 +393,8 @@ export const prisma = {
|
|
|
382
393
|
// `count` — the resume email's per-recipient send cap (#5406): how many
|
|
383
394
|
// drafts this address has started on this site inside the rolling window.
|
|
384
395
|
count: jest.fn(),
|
|
396
|
+
// `findUnique` — the email-verification service's address read (#10820).
|
|
397
|
+
findUnique: jest.fn(),
|
|
385
398
|
},
|
|
386
399
|
// ── Inbox namespace (WaitlistExperiment — #4661 D5) ──────────────────────
|
|
387
400
|
// `upsert` — the ONLY write path into waitlist_experiments
|
|
@@ -91,9 +91,15 @@ MCP_ABUSE_BLOCKED_USER_AGENTS=
|
|
|
91
91
|
# (the Artifact store — the server resolves its own single workspace at
|
|
92
92
|
# bootstrap; a deployment that isn't unambiguously one-workspace serves no
|
|
93
93
|
# content rather than guessing).
|
|
94
|
+
# On a SPOKE (#10658), CONTENT_ADAPTER=database reads this spoke's own
|
|
95
|
+
# published pages through the hub at AILK_API_URL under AILK_SPOKE_KEY (below),
|
|
96
|
+
# with no DATABASE_URL — set it once the spoke's page files are removed. A
|
|
97
|
+
# spoke whose key will not be sent (unset, not https or loopback, or no
|
|
98
|
+
# AILK_API_URL) serves no content; the boot writes one stderr line saying
|
|
99
|
+
# which way it went.
|
|
94
100
|
# CONTENT_ADAPTER=mdx
|
|
95
101
|
|
|
96
|
-
# Required ONLY
|
|
102
|
+
# Required ONLY on a hub's CONTENT_ADAPTER=database — never on a spoke.
|
|
97
103
|
# DATABASE_URL=
|
|
98
104
|
|
|
99
105
|
# ── Spoke key ─────────────────────────────────────────────────────────────────
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TextDecoder, TextEncoder } from "node:util";
|
|
2
|
+
import { deserialize, serialize } from "node:v8";
|
|
2
3
|
|
|
3
4
|
import "@testing-library/jest-dom";
|
|
4
5
|
|
|
@@ -17,6 +18,14 @@ if (typeof globalThis.TextEncoder === "undefined") {
|
|
|
17
18
|
(globalThis as unknown as { TextDecoder: unknown }).TextDecoder = TextDecoder;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// #10704 D3 — nor `structuredClone`, which Node exposes only as a global. The
|
|
22
|
+
// database render path clones a page's words with it (`@working-theory/
|
|
23
|
+
// validation`'s `words-codec.ts`); the v8 round trip clones the same plain data.
|
|
24
|
+
if (typeof globalThis.structuredClone === "undefined") {
|
|
25
|
+
globalThis.structuredClone = (<T>(value: T): T =>
|
|
26
|
+
deserialize(serialize(value)) as T) as typeof structuredClone;
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
// SOFT_LOCK_OK: #6510 (D1) — jsdom does not implement ResizeObserver.
|
|
21
30
|
// BannerConsentIsland (the layout's always-mounted consent Banner,
|
|
22
31
|
// placement="bottom") now constructs one when reserveSpace is set — the
|
|
@@ -88,6 +88,17 @@ jest.mock("node:crypto", () => {
|
|
|
88
88
|
};
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* #10820 D7 — the confirm page's server action. Rendering `?verify=` must
|
|
93
|
+
* spend nothing: the action is the only path to the confirm hop, so a GET that
|
|
94
|
+
* called it is the mail-scanner hole this page exists to close.
|
|
95
|
+
*/
|
|
96
|
+
const mockConfirmEmailAction = jest.fn();
|
|
97
|
+
// eslint-disable-next-line no-restricted-syntax -- the "use server" action is the page's network boundary (it reaches apps/api over HTTP); its own suite, verify-email/__tests__/actions.test.ts, exercises it.
|
|
98
|
+
jest.mock("../verify-email/actions", () => ({
|
|
99
|
+
confirmEmailAction: (...args: unknown[]) => mockConfirmEmailAction(...args),
|
|
100
|
+
}));
|
|
101
|
+
|
|
91
102
|
import en from "../../../../messages/en.json";
|
|
92
103
|
import { createAuth } from "../../../../auth";
|
|
93
104
|
import ForgotPasswordPage from "../forgot-password/page";
|
|
@@ -218,6 +229,23 @@ describe("/verify-email", () => {
|
|
|
218
229
|
expect(screen.getByTestId("verify-token-error")).toBeInTheDocument();
|
|
219
230
|
expect(screen.queryByTestId("auth-sent-body")).not.toBeInTheDocument();
|
|
220
231
|
});
|
|
232
|
+
|
|
233
|
+
it("renders a confirm button on ?verify= and spends nothing while rendering (#10820)", async () => {
|
|
234
|
+
await renderVerify({ verify: "N".repeat(43) });
|
|
235
|
+
expect(screen.getByTestId("verify-email-confirm")).toBeInTheDocument();
|
|
236
|
+
expect(screen.getByTestId("verify-email-confirm-button")).toHaveTextContent(
|
|
237
|
+
en.auth.confirmButton,
|
|
238
|
+
);
|
|
239
|
+
expect(mockConfirmEmailAction).not.toHaveBeenCalled();
|
|
240
|
+
expect(document.body.innerHTML).not.toContain("N".repeat(43));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("renders the verified card on ?confirmed=1 (#10820)", async () => {
|
|
244
|
+
await renderVerify({ confirmed: "1" });
|
|
245
|
+
expect(screen.getByTestId("verify-email-confirmed")).toHaveTextContent(
|
|
246
|
+
en.auth.verifiedHeading,
|
|
247
|
+
);
|
|
248
|
+
});
|
|
221
249
|
});
|
|
222
250
|
|
|
223
251
|
// ── The flag-alone configuration: intent set, send impossible ──────────────
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*
|
|
4
|
+
* `confirmEmailAction` (#10820 D7) — the one place a sign-up link's nonce is
|
|
5
|
+
* spent. The per-visitor limit and the bot check run before any API call; the
|
|
6
|
+
* signed hop's answer picks the redirect; the nonce is in no redirect URL.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const mockRedirect = jest.fn((url: string) => {
|
|
10
|
+
throw new Error(`REDIRECT ${url}`);
|
|
11
|
+
});
|
|
12
|
+
jest.mock("next/navigation", () => ({
|
|
13
|
+
redirect: (url: string) => mockRedirect(url),
|
|
14
|
+
}));
|
|
15
|
+
jest.mock("next/headers", () => ({
|
|
16
|
+
headers: async () => new Headers(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const mockRunBotCheck = jest.fn();
|
|
20
|
+
// eslint-disable-next-line no-restricted-syntax -- BotID is an external service boundary; lib/bot-check.ts is its one wrapper.
|
|
21
|
+
jest.mock("../../../../../lib/bot-check", () => ({
|
|
22
|
+
runBotCheck: (...args: unknown[]) => mockRunBotCheck(...args),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const mockConfirmSignUpEmail = jest.fn();
|
|
26
|
+
// eslint-disable-next-line no-restricted-syntax -- the signed hop to apps/api is the HTTP boundary; lib/auth-email.ts's own suite pins its signing.
|
|
27
|
+
jest.mock("../../../../../lib/auth-email", () => ({
|
|
28
|
+
confirmSignUpEmail: (...args: unknown[]) => mockConfirmSignUpEmail(...args),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import { __resetRateLimit } from "../../../../../src/lib/rate-limit";
|
|
32
|
+
import { confirmEmailAction } from "../actions";
|
|
33
|
+
|
|
34
|
+
const NONCE = "N".repeat(43);
|
|
35
|
+
|
|
36
|
+
async function redirectOf(nonce = NONCE): Promise<string> {
|
|
37
|
+
await expect(confirmEmailAction(nonce, "en")).rejects.toThrow(/^REDIRECT /);
|
|
38
|
+
const url = mockRedirect.mock.calls.at(-1)![0];
|
|
39
|
+
expect(url).not.toContain(nonce);
|
|
40
|
+
return url;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
jest.clearAllMocks();
|
|
45
|
+
__resetRateLimit();
|
|
46
|
+
mockRunBotCheck.mockResolvedValue("pass");
|
|
47
|
+
mockConfirmSignUpEmail.mockResolvedValue("verified");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("confirmEmailAction (#10820 D7)", () => {
|
|
51
|
+
it.each(["verified", "already_verified"])(
|
|
52
|
+
"redirects to the verified card on %s",
|
|
53
|
+
async (outcome) => {
|
|
54
|
+
mockConfirmSignUpEmail.mockResolvedValue(outcome);
|
|
55
|
+
expect(await redirectOf()).toBe("/verify-email?confirmed=1");
|
|
56
|
+
expect(mockConfirmSignUpEmail).toHaveBeenCalledWith(NONCE);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
it.each(["expired", "invalid", "failed"])(
|
|
61
|
+
"redirects to the expired-link card on %s",
|
|
62
|
+
async (outcome) => {
|
|
63
|
+
mockConfirmSignUpEmail.mockResolvedValue(outcome);
|
|
64
|
+
expect(await redirectOf()).toBe("/verify-email?error=invalid");
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
it("redirects to the expired-link card when the hop throws", async () => {
|
|
69
|
+
mockConfirmSignUpEmail.mockRejectedValue(new Error("boom"));
|
|
70
|
+
expect(await redirectOf()).toBe("/verify-email?error=invalid");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it.each(["bot", "unavailable"])(
|
|
74
|
+
"makes no API call when the bot check answers %s",
|
|
75
|
+
async (verdict) => {
|
|
76
|
+
mockRunBotCheck.mockResolvedValue(verdict);
|
|
77
|
+
expect(await redirectOf()).toBe("/verify-email?error=invalid");
|
|
78
|
+
expect(mockConfirmSignUpEmail).not.toHaveBeenCalled();
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
it("makes no API call once the visitor limit is spent", async () => {
|
|
83
|
+
// An unreadable address shares the unknown bucket, 10x the per-visitor 5.
|
|
84
|
+
for (let i = 0; i < 50; i += 1) await redirectOf();
|
|
85
|
+
mockConfirmSignUpEmail.mockClear();
|
|
86
|
+
expect(await redirectOf()).toBe("/verify-email?error=invalid");
|
|
87
|
+
expect(mockConfirmSignUpEmail).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("redirects on the default locale for a locale outside the enabled set", async () => {
|
|
91
|
+
await expect(confirmEmailAction(NONCE, "/evil.example")).rejects.toThrow(/^REDIRECT /);
|
|
92
|
+
expect(mockRedirect).toHaveBeenLastCalledWith("/verify-email?confirmed=1");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The sign-up confirm click (#10820 D7) — the one place a `/verify-email`
|
|
5
|
+
* link's nonce is spent.
|
|
6
|
+
*
|
|
7
|
+
* The page's GET renders a button and writes nothing, so a mail scanner or a
|
|
8
|
+
* link preview that fetches the link verifies nothing; only a person's POST
|
|
9
|
+
* reaches here. Before any API call it takes the per-visitor limit (5 per
|
|
10
|
+
* 10 minutes) and the bot check, because the API sees only this site's server
|
|
11
|
+
* address. Then the signed hop, `confirmSignUpEmail`, and a redirect: the
|
|
12
|
+
* verified card for `verified` and `already_verified`, the expired-link card
|
|
13
|
+
* for everything else. The nonce is never in either redirect URL, and never
|
|
14
|
+
* logged here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { localeHref } from "@working-theory/ui";
|
|
18
|
+
import { headers } from "next/headers";
|
|
19
|
+
import { redirect } from "next/navigation";
|
|
20
|
+
|
|
21
|
+
import { confirmSignUpEmail } from "../../../../lib/auth-email";
|
|
22
|
+
import { runBotCheck } from "../../../../lib/bot-check";
|
|
23
|
+
import { takeVisitorLimit } from "../../../../src/lib/rate-limit";
|
|
24
|
+
|
|
25
|
+
const CONFIRM_LIMIT = 5;
|
|
26
|
+
const CONFIRM_WINDOW_MS = 10 * 60 * 1000;
|
|
27
|
+
const NO_HEADERS = { get: () => null };
|
|
28
|
+
|
|
29
|
+
async function confirmed(nonce: string): Promise<boolean> {
|
|
30
|
+
let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
|
|
31
|
+
try {
|
|
32
|
+
requestHeaders = await headers();
|
|
33
|
+
} catch {
|
|
34
|
+
// No request scope to read — the shared unknown-visitor bucket.
|
|
35
|
+
}
|
|
36
|
+
const limit = takeVisitorLimit(
|
|
37
|
+
"verifyemail",
|
|
38
|
+
requestHeaders ?? NO_HEADERS,
|
|
39
|
+
CONFIRM_LIMIT,
|
|
40
|
+
CONFIRM_WINDOW_MS,
|
|
41
|
+
);
|
|
42
|
+
if (!limit.allowed) return false;
|
|
43
|
+
|
|
44
|
+
const bot = await runBotCheck({
|
|
45
|
+
source: "verify-email",
|
|
46
|
+
target: "confirmEmailAction",
|
|
47
|
+
scope: "write",
|
|
48
|
+
});
|
|
49
|
+
if (bot !== "pass") return false;
|
|
50
|
+
|
|
51
|
+
// `confirmSignUpEmail` never throws by contract; this guard keeps the
|
|
52
|
+
// action's own promise (a redirect, never a 500) if that ever changes.
|
|
53
|
+
try {
|
|
54
|
+
const outcome = await confirmSignUpEmail(nonce);
|
|
55
|
+
return outcome === "verified" || outcome === "already_verified";
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The locale is a bound argument a direct call could set to anything, so the
|
|
62
|
+
// redirect's segment is checked against the build-time allowlist, never
|
|
63
|
+
// interpolated raw (the `checkout.actions.ts` idiom).
|
|
64
|
+
const DEFAULT_LOCALE = "en";
|
|
65
|
+
const ENABLED_LOCALES = (process.env.NEXT_PUBLIC_ENABLED_LOCALES ?? DEFAULT_LOCALE).split(",");
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Bound by the page — `confirmEmailAction.bind(null, nonce, locale)` — so no
|
|
69
|
+
* hidden input carries the nonce; the form's own data is ignored.
|
|
70
|
+
*/
|
|
71
|
+
export async function confirmEmailAction(nonce: string, locale = "en"): Promise<never> {
|
|
72
|
+
const ok = await confirmed(nonce);
|
|
73
|
+
const safeLocale = ENABLED_LOCALES.includes(locale) ? locale : DEFAULT_LOCALE;
|
|
74
|
+
redirect(
|
|
75
|
+
localeHref(safeLocale, ok ? "/verify-email?confirmed=1" : "/verify-email?error=invalid"),
|
|
76
|
+
);
|
|
77
|
+
}
|