create-ailk 0.26.0 → 0.27.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 +2 -2
- package/dist/module-architecture.js +2 -0
- package/dist/surfaces.js +7 -2
- package/dist/update.js +52 -2
- package/package.json +3 -3
- package/templates/.env.example +7 -0
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +21 -5
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +13 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/layout.tsx +15 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- package/templates/apps/web/lib/relay.ts +116 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
- package/templates/apps/web/messages/en.json +86 -1
- package/templates/apps/web/middleware.ts +8 -1
- package/templates/apps/web/next.config.mjs +9 -1
- package/templates/apps/web/package.json +2 -1
- package/versions.json +47 -47
|
@@ -69,11 +69,11 @@ Routes are versioned under `/v1/*`:
|
|
|
69
69
|
- `/v1/content/{types,search,offerings,pricing,shown-offerings,:type,:type/:slug}` — public content reads. `GET /v1/content/search`, `GET /v1/content/:type` and `GET /v1/content/:type/:slug` are `search_content`'s, `list_pages`' and `read_content`'s twins (#7167, #7168, #7169): each parses its request with the `@working-theory/validation` exports in `content-reads.ts` (`contentSearchQuerySchema`; `contentListParamsSchema` + `contentListQuerySchema`; `contentPageParamsSchema` + `contentPageQuerySchema`) and answers through the shared builder in `@working-theory/content-adapters` the tool also calls (`searchContentItemList`, `listPagesItemList`, `readContentDocument`), so the pair takes the same arguments and returns the same result — the tool adopted the API; the `/v1` wire shapes are unchanged. `GET /v1/content/offerings` (`?type=&locale=`) is `get_offerings`' twin (#7177), answering `{ ok: true, data: { offerings } }` through `listOfferings` in `@working-theory/content-adapters`, the seam the tool also calls. `GET /v1/content/pricing` (`?slug=&locale=`) is `get_pricing`'s twin (#7178), answering `{ ok: true, data: { tiers } | null }` through `getPricingTiers`. `GET /v1/content/shown-offerings` is the `/pricing` page's Offerings read (#8924), answering `{ ok: true, data: { status: "shown", shownSet } | { status: "unconnected" } | { status: "empty" } }` behind the `commerce` claim, refreshing the snapshot on the bounded non-forced path, with no MCP twin. On a connected workspace with a current shown set, `GET /v1/content/pricing` (the `pricing` page) and `GET /v1/content/offerings` (unless `type=service`) answer that set through `readShownOfferings` (exported from `shown-offerings.ts`, the one read all three share, registered with one `shownSetRouteOptions` in `server.ts`) and `@working-theory/content-adapters`' shown-set mapping (`pricingTiersFromShownSet`, `listOfferingsWithShownSet`), with major-unit prices, `interval` and the full `lookupKey` (offerings: the Service pages, then the shown Products in place of the Product pages, each with `prices`); every other case — no connection, no claim, an expired or empty set, a failed read or mapping (one `warn { outcome: "shown_set_failed", errName }`) — answers page content as before; their MCP twins read the same answer through `apiClient.offerings.shown` (#8933).
|
|
70
70
|
- `/v1/auth/tokens`, `/v1/user/{profile,data/:key}` — local auth + user data
|
|
71
71
|
- `/v1/hub-sign-in/codes` (POST), `/v1/hub-sign-in/token` (POST), `/v1/hub-sign-in/revoke` (POST), `/v1/hub-sign-in/me` (GET) — the hub signs in its spokes (#7894, `packages-paid/api-hub/src/routes/auth/hub-sign-in.ts` over `packages-paid/api-hub/src/services/hub-sign-in.ts` — `@working-theory/api-hub` since #9571, which owns the spoke registry and the sign-in trio and hands them to `buildServer({ hub })` through `packages/api/src/lib/hub.ts`; spec `docs/specs/7894-the-hub-signs-in-its-spec.md`): an authorization code with PKCE between a spoke (a confidential client whose `OAuthClient.clientId` IS its `Spoke.id`) and this hub. `codes` is in `authScope` and answers a COOKIE session only (a personal `ApiToken` is 403, an agent key 403 at the preHandler, a hub credential 404): it mints a single-use 60 s code only for an email-verified person and a callback in the client's registered `redirectUris` (exact match), 30 a user per 10 minutes. `token` and `revoke` are in their OWN scope outside `authScope` (per-IP `rateLimitMiddleware`, scope `hubsignin`, 60/min; plus a per-client bucket of 120 exchanges a minute keyed on the VERIFIED client): the caller authenticates with `Authorization: Basic clientId:secret` AND `x-ailk-spoke-key`, which must resolve to the unrevoked spoke whose id is that client — either alone is `401 invalid_client`. `token` consumes the code first and exactly once, then checks the client, the callback and the PKCE verifier (`400 invalid_grant` for any failure, the code gone either way) and issues `ailk_hst_…`, a 24 h credential stored only as a SHA-256 digest in `OAuthAuthorization` (one row per person and spoke, so a new sign-in rotates it); it writes no `Session` row and sets no cookie. `revoke` ends a credential or burns an unexpired code of that client (`{ revoked }`, never an error). `me` answers the credential's person, read live, and is reachable only by that credential. **The credential is admitted by one branch of `middleware/auth.ts`** — only beside the same spoke's key, only while its grant is unrevoked and unexpired, its client enabled and its person's email verified, and only on `HUB_SESSION_ROUTES`, an exact list of 26 method + registered-URL strings, never a prefix (#8220): `GET /v1/hub-sign-in/me`, #7877's three member reads, #8169's eight member reads, #8170's six member writes, #8435's two contact routes, #8438's two consent routes, #8765's account-agreements pair and #8515's onboarding read and write; every other route answers `404` with no handler run (the status the account area's `read()` loads as "nothing for this caller"), and the list is extended only by a reviewed edit. **The per-person budget (#8220 D2, SEC-8169-4):** each person at each spoke gets 60 requests and 10 writes a minute and 30 writes an hour (a fixed hour window from the first admitted write, so up to twice that across a window edge), keyed on the VERIFIED spoke and person and charged only after admission (a refused credential or an off-list route never gets a bucket); over budget is `429 rate_limited` with `Retry-After`. The write limits refuse only writes, and a refused request is not counted. Every line the request logger writes for an admitted request after admission carries `spokeId` (never the key or the credential); a helper logging through the module-level `logger` carries no request binding. The server-wide per-IP limiter still counts all of one spoke's members together — a named residual whose follow-up (the visitor forward key on member calls) must land before Phase 3 (#8163) ships to a live spoke. A bearer write skips the origin check, so the cross-site-request protection for the member writes is the spoke's: its server actions that send a sealed credential carry it (Phase 3, #8163; SEC-8220-11). A completed password reset (`apps/web/auth.ts` `onPasswordReset`) revokes every credential of that person. The operator registers a spoke's client with `spokes sign-in-client --id <spoke_id> --redirect-uri <url>` (`packages-paid/api-hub/src/bin/spokes.ts`; `--disable` switches it off) — `packages-paid/api-hub/src/lib/hub-sign-in-store.ts` is the only file that names `prisma.oAuthClient` and `prisma.oAuthAuthorization`. **The `hub` claim (#9571 D4; #9568 D5):** read only inside `@working-theory/api-hub` (`src/claim.ts`), through `checkLicense()`, registry first — with no `DATABASE_URL` or no registered spoke the gate answers `not-serving` and never reads the licence; with a spoke and the claim, `claimed`; with a spoke and no claim, `unclaimed`, and `codes`, `token`, `me` (a verified credential) and `clients/:clientId` answer `403 feature_required` naming `hub`; an unreadable licence is `undetermined`, `503 license_undetermined` with `Retry-After: 300`. `revoke` is never gated; a definite answer is held 300 s, `not-serving` 30 s, `undetermined` never; `spokes register` refuses without the claim (`hub-claim-missing`). Lead capture, the resolver and magnet completion never read it, and a boot without the package answers as an empty registry (the four routes `404`). No MCP twin for any of the four — a credential exchange between a site and its hub is not an agent-facing call (#3591 exception, the `/v1/auth/tokens` footing). The browser step lives on the hub's web app: `apps/web/app/api/auth/hub/authorize/route.ts`.
|
|
72
|
-
- `/v1/checkout/sessions` — start a Stripe checkout. **Two paths (#8931, `src/routes/checkout/sessions.ts` over `src/services/offerings/checkout-authorization.ts`; spec `docs/specs/8931-offerings-checkout-authorization-server-side-spec.md`; contract A1–A11, A13).** The path comes from `resolvePublicWorkspace()` and the workspace's own Organization alone — no body field names either: a resolved workspace whose Organization has a Stripe connection takes the **Offerings path**; a presented `x-ailk-spoke-key` the resolver refuses is `503 { error: "tenant_unresolved" }` with no Stripe call; a corrupt connection, or a database read that fails while choosing, is `502 checkout-unavailable`; everything else (keyless, `ambiguous`, off the database adapter, no Organization, no connection) takes the **env path**, today's code. **The env path is the hub's own-products path only, never a spoke's (#9142):** a spoke sells only through its `apps/web` Offerings buy route, which calls this route only for a lookup key its connected workspace's shown set holds, so a spoke's buy always takes the Offerings path; the spoke's own env-key buy route and Stripe webhook are retired. The Offerings path charges `chargeWriteBudget` scope `offeringscheckout` before the body is read — 10 a minute per `request.ip` and 60 per workspace (`OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE`), charged one at a time with the IP first, so a request its IP bucket refuses never spends the workspace's, whatever the process's mode — answering `429 { error: "rate_limited", retryAfterSeconds }` with `Retry-After`. It parses `productCode` (the full lookup key) by the one grammar first, builds the connection's own client (`stripeClientForOrganization`), reads org-enabled ∩ workspace-shown from the rows (`getSellableProductIds`, never the snapshot), looks the key up live on that client, and sells only when the Price's own Product is in the set — and, for a workspace saved per Price (#9070 D5), the Price itself is one of its stored shown Prices (`getSellableProductIds`' `priceIds`; `null` for a row saved per Product) — and `normalizeCatalog` keeps that Price under that key and period. Every miss — ungrammatical, not enabled, not shown, moved, archived, nonexistent — is the one `404 { error: "unknown-product-code" }`, never echoing the key; Stripe, the rows or the connection failing is `502`, never the snapshot or the env key. The session is anonymous (no `customer`, no signed-in buyer, so no `ailk_user_id`), sells the authorized Price id, allows promotion codes, `customer_creation: "always"` in payment mode, and is stamped by the server `metadata` and `subscription_data.metadata` `{ site: <workspaceId>, ailk_offering: <lookup key> }`; it is recorded through `recordCheckoutSession` before the route answers, and a failed record expires it on the same client and answers `502`. **The env path refuses an Offerings product (A9):** `createCheckoutSession` gets `{ refuseProduct: isOfferingsProduct }`, so a Price whose Product carries `metadata.ailk_group` (or whose Product did not expand) is the same `404`. **The Offerings path refuses the hub's own plans (#9338 DD5):** right after the grammar parse, a key whose plan is `ailk-pro` or `ailk-pro-plus` (`isPlanPurchaseKey`, any period) is the same `404`, before any read or Stripe call, for every Organization but the one whose fulfilment hook mints licences (#9383 DD5, `fulfilmentFor(organizationId)?.mintsLicences`, read with no Stripe call) — anywhere else such a sale would stamp `ailk_offering` with no licence minted for it. A hub plan under another slug is not covered (the named residual). The request schema and the `201 { sessionId, url }` body are unchanged, so the `start_purchase` parity row is too. **`GET /v1/checkout/sessions/:id` (A13):** when `DATABASE_URL` is set, a session `readCheckoutSessionRecord` holds is read with that record's Organization's own client, only in the record's mode; its line-item names pass the catalog's T1 `normalizeText`; a record that cannot be read, or a connection gone, unusable or in another mode, is `502`, never the env path; no record is today's env read. Logs carry the workspace or Organization, an 8-character session prefix and a fixed reason only.
|
|
72
|
+
- `/v1/checkout/sessions` — start a Stripe checkout. **Two paths (#8931, `src/routes/checkout/sessions.ts` over `src/services/offerings/checkout-authorization.ts`; spec `docs/specs/8931-offerings-checkout-authorization-server-side-spec.md`; contract A1–A11, A13).** The path comes from `resolvePublicWorkspace()` and the workspace's own Organization alone — no body field names either: a resolved workspace whose Organization has a Stripe connection takes the **Offerings path**; a presented `x-ailk-spoke-key` the resolver refuses is `503 { error: "tenant_unresolved" }` with no Stripe call; a corrupt connection, or a database read that fails while choosing, is `502 checkout-unavailable`; everything else (keyless, `ambiguous`, off the database adapter, no Organization, no connection) takes the **env path**, today's code. **A spoke authenticates with its key on a hub that sets `OSS_API_INTERNAL_SECRET` (#9910; spec `docs/specs/9910-a-spoke-can-start-an-spec.md`):** a call with no `x-internal-auth` header and an `x-ailk-spoke-key` `resolveCallerSite` resolves to a registered, unrevoked spoke is a spoke call — it sells only that spoke's own workspace (a path naming any other is `401`), and its `successUrl` and `cancelUrl` must be on the origin of the spoke's enabled hub sign-in client's first redirect URI (`registeredSpokeOrigins`, read through `installedHub()`), else `400 validation-error`; a spoke with no such origin is `500 config-error`, both before any Stripe call. A refused key, no credential at all, or a presented `x-internal-auth` that does not match is `401 internal-auth-required` — the keyless and wrong-secret `401` before any read — and a matching `x-internal-auth` keeps its redirects. On a hub without the secret every caller is unchanged. **The env path is the hub's own-products path only, never a spoke's (#9142):** a spoke sells only through its `apps/web` Offerings buy route, which calls this route only for a lookup key its connected workspace's shown set holds, so a spoke's buy always takes the Offerings path; the spoke's own env-key buy route and Stripe webhook are retired. The Offerings path charges `chargeWriteBudget` scope `offeringscheckout` before the body is read — 10 a minute per `request.ip` and 60 per workspace (`OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE`), charged one at a time with the IP first, so a request its IP bucket refuses never spends the workspace's, whatever the process's mode — answering `429 { error: "rate_limited", retryAfterSeconds }` with `Retry-After`. It parses `productCode` (the full lookup key) by the one grammar first, builds the connection's own client (`stripeClientForOrganization`), reads org-enabled ∩ workspace-shown from the rows (`getSellableProductIds`, never the snapshot), looks the key up live on that client, and sells only when the Price's own Product is in the set — and, for a workspace saved per Price (#9070 D5), the Price itself is one of its stored shown Prices (`getSellableProductIds`' `priceIds`; `null` for a row saved per Product) — and `normalizeCatalog` keeps that Price under that key and period. Every miss — ungrammatical, not enabled, not shown, moved, archived, nonexistent — is the one `404 { error: "unknown-product-code" }`, never echoing the key; Stripe, the rows or the connection failing is `502`, never the snapshot or the env key. The session is anonymous (no `customer`, no signed-in buyer, so no `ailk_user_id`), sells the authorized Price id, allows promotion codes, `customer_creation: "always"` in payment mode, and is stamped by the server `metadata` and `subscription_data.metadata` `{ site: <workspaceId>, ailk_offering: <lookup key> }`; it is recorded through `recordCheckoutSession` before the route answers, and a failed record expires it on the same client and answers `502`. **The env path refuses an Offerings product (A9):** `createCheckoutSession` gets `{ refuseProduct: isOfferingsProduct }`, so a Price whose Product carries `metadata.ailk_group` (or whose Product did not expand) is the same `404`. **The Offerings path refuses the hub's own plans (#9338 DD5):** right after the grammar parse, a key whose plan is `ailk-pro` or `ailk-pro-plus` (`isPlanPurchaseKey`, any period) is the same `404`, before any read or Stripe call, for every Organization but the one whose fulfilment hook mints licences (#9383 DD5, `fulfilmentFor(organizationId)?.mintsLicences`, read with no Stripe call) — anywhere else such a sale would stamp `ailk_offering` with no licence minted for it. A hub plan under another slug is not covered (the named residual). The request schema and the `201 { sessionId, url }` body are unchanged, so the `start_purchase` parity row is too. **`GET /v1/checkout/sessions/:id` (A13):** when `DATABASE_URL` is set, a session `readCheckoutSessionRecord` holds is read with that record's Organization's own client, only in the record's mode; its line-item names pass the catalog's T1 `normalizeText`; a record that cannot be read, or a connection gone, unusable or in another mode, is `502`, never the env path; no record is today's env read. Logs carry the workspace or Organization, an 8-character session prefix and a fixed reason only.
|
|
73
73
|
- `/v1/workspaces/:workspaceId/{theme,identity,seo}` (GET/PUT) — per-workspace site config (multi-tenant module). PUT needs `workspace-settings` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)), checked before the body parse; GET stays open to every member.
|
|
74
74
|
- `/v1/workspaces/:workspaceId/meetings` (GET/PUT) — the operator's enabled-slug gate for meeting types (#6183 S9b, `src/routes/workspaces/meetings.ts`). Beside the three siblings above in URL shape and tenant-isolation posture, but registered in its OWN scope, gated on `isModuleEnabled("scheduling")` ALONE — not the `workspace`/`multi-tenant` scope its siblings share, because its GET reaches `services/scheduling/` (see below), which a scaffold without that module does not carry (#5701; since #9565 the package boundary — `@working-theory/api-scheduling` owns both files). PUT needs `meeting-types-manage` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)) because this blob decides which pages the public site serves. GET answers `meeting-types-manage` with the full join and `meeting-types-read` (every role, a member included) with the ENABLED rows only (#7636 D6) — since it does NOT just return the stored blob, it echoes the deployment's whole provider catalog, including the rows the operator chose not to publish (security review, 2026-09-19), which a member never sees. GET is the one segment in this file's family that does NOT just return the stored blob: it ALSO lists the scheduling provider's own bookable event types (`listEventTypes`, #6182), joined with `enabled: boolean`; an unconfigured/unreachable provider answers the same `SchedulingErrorKind` outcome `/v1/schedule/*` uses, distinct from an empty list. **`customerSlugs` (#8171 D5)** — who may book from the member area, independent of `enabledSlugs`: `GET ?include=customers` adds `customer: boolean` per row, and without it the body is unchanged; the PUT carries `customerSlugs`, and a PUT without it keeps the stored list (a read then a write, last-writer-wins), so the public toggle never erases it. `enabledSlugs` keeps its whole-list meaning, so a writer of `customerSlugs` sends the `enabledSlugs` it read. **`include` is a comma list (#9128)** — `?include=customers,private`, in either order, or a repeated `include` key; an unknown value is ignored, so `?include=customers` alone reads as before. **`private` adds `private: boolean` per row** — membership of the stored `privateSlugs` (#8843 D2), the Admin meeting page's Private switch — on the `meeting-types-manage` full join only: a `meeting-types-read` caller never receives it, whatever it asks. The PUT carries `privateSlugs` whole-list and keeps a stored list a body leaves out, as it does `customerSlugs`. An agent's PUT is audited `meetings.set_customers` when it carries `customerSlugs`, else `meetings.set_enabled`. No MCP twin — identity-bound tenant route (#3591 exception).
|
|
75
75
|
- `/v1/workspaces/:workspaceId/meetings/connection` (GET, PUT, DELETE) — the workspace's own scheduling connection (#7533, `src/routes/workspaces/meetings.ts`; data access and the cipher in `src/services/scheduling/connections.ts`, the only file that touches `prisma.schedulingConnection`). Same registration, scope and tenant guard as the meetings route above. GET, PUT and DELETE all need `scheduling-connection` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2), the operator's 2026-10-04 decision overturning SEC-7533-11's owner-only write). GET answers `null` or a summary — `provider`, `baseUrl`, `webhookPath`, `createdAt`, `updatedAt` — with no credential, secret, ciphertext or hint of one. PUT takes a strict `{ provider, apiKey, baseUrl? }`; create and replace are one operation, and every PUT mints a new webhook token and secret, returning the secret this once (`Cache-Control: no-store`) so the operator can paste it into the provider — a replace cuts off the previous entry point. `baseUrl` must be `https://api.cal.com` or the deployment's own `CAL_API_BASE_URL` origin, with no path, query or userinfo (`400 base_url_not_allowed`); only the bare origin is stored. The credential and the secret are AES-256-GCM ciphertext under `SCHEDULING_CREDENTIAL_KEY`, bound to the workspace and the column; with the key unset, PUT answers `503 credential_key_not_configured` before any write. DELETE is idempotent (`{ revoked }`) and unconfigures the workspace at once. Because a connection replaces the account every provider call uses, every successful PUT, and every DELETE that revoked a row, writes one `recordAgentAction` audit row (`scheduling.connection.{connect,replace,remove}`; a PUT is `replace` whenever the workspace was served by any provider before it — its own row, or the env credential on a one-workspace deployment) and emails every OTHER owner of the organization through `@working-theory/email`'s `sendSchedulingConnectionChangedEmail` (#7636 D7). Neither step can fail the write: a failed audit is logged at `error`, and the notify step runs in its own `try`, so the one-time secret is always returned; with no sender address or no Resend key it is skipped with a `warn`. Logs carry the recipient count, never an address. The meetings GET above, and the three anonymous `/v1/schedule/*` provider routes below, resolve their provider through `src/services/scheduling/resolve.ts`: a workspace's own row always wins; `CAL_API_KEY` serves a workspace only on a one-workspace deployment; otherwise `not_configured`. No MCP twin — identity-bound tenant routes (#3591 exception).
|
|
76
|
-
- `/v1/workspaces/:workspaceId/slack-connection` (GET, POST, DELETE) — the Organization's own Slack connection (#9748, `packages-paid/api-workspace/src/routes/workspaces/slack-connection.ts`; data access, the Slack calls and the cipher in `packages/api/src/services/slack/connection.ts`, the only file that touches `prisma.slackConnection`). One `TENANCY` row in the `tenant:settings` scope, so it loads on single-tenant Pro, multi-tenant Pro Plus and the hub, and a selected but unlicensed build answers `403 feature_required`; the tenant guard refuses another Organization's `:workspaceId`, and every route acts on `request.tenant.organizationId` only. GET needs any tenant (an agent included) and answers exactly `{ connected: false }` or `{ connected: true, teamName, connectedAt }` — a missing row, a row failing its grammar and a value that does not decrypt all read as not connected, never a throw (there is no environment credential). POST and DELETE need `billing-manage` (the owner alone) and refuse an agent tenant (`403`). POST's order: tenant → owner → write budget (`slackconn`, 10 an hour per Organization, 20 per person, `429` with `Retry-After`) → `SLACK_CONNECTION_KEY` (`503 credential_key_not_configured`) → the strict `{ botToken, signingSecret }` body (4 KiB, `400 validation_failed`) → Slack: `auth.test` (validity, a bot token of one workspace, the granted scopes) then `team.billing.info` (the plan), one 10 s budget, the token only in the `Authorization` header with redirects refused. Refusals: `400 slack_token_refused` (`reason` `token_refused`, `not_bot_token` or `enterprise_install`), `400 slack_scopes_missing` naming every missing one of the
|
|
76
|
+
- `/v1/workspaces/:workspaceId/slack-connection` (GET, POST, DELETE) — the Organization's own Slack connection (#9748, `packages-paid/api-workspace/src/routes/workspaces/slack-connection.ts`; data access, the Slack calls and the cipher in `packages/api/src/services/slack/connection.ts`, the only file that touches `prisma.slackConnection`). One `TENANCY` row in the `tenant:settings` scope, so it loads on single-tenant Pro, multi-tenant Pro Plus and the hub, and a selected but unlicensed build answers `403 feature_required`; the tenant guard refuses another Organization's `:workspaceId`, and every route acts on `request.tenant.organizationId` only. GET needs any tenant (an agent included) and answers exactly `{ connected: false }` or `{ connected: true, teamName, connectedAt }` — a missing row, a row failing its grammar and a value that does not decrypt all read as not connected, never a throw (there is no environment credential). POST and DELETE need `billing-manage` (the owner alone) and refuse an agent tenant (`403`). POST's order: tenant → owner → write budget (`slackconn`, 10 an hour per Organization, 20 per person, `429` with `Retry-After`) → `SLACK_CONNECTION_KEY` (`503 credential_key_not_configured`) → the strict `{ botToken, signingSecret }` body (4 KiB, `400 validation_failed`) → Slack: `auth.test` (validity, a bot token of one workspace, the granted scopes) then `team.billing.info` (the plan), one 10 s budget, the token only in the `Authorization` header with redirects refused. Refusals: `400 slack_token_refused` (`reason` `token_refused`, `not_bot_token` or `enterprise_install`), `400 slack_scopes_missing` naming every missing one of the nine bot scopes (`conversations.connect:write`, `groups:write`, `channels:manage`, `chat:write`, `team.billing:read`, and since #9854 `groups:read`, `groups:history`, `users:read`, `users:read.email` — an Organization connected before #9854 still reads connected, but its owner lookup answers `missing_scope` and no events arrive until the owner reconnects), `400 slack_paid_plan_required` on Slack's free plan, `502 slack_unavailable`, `409 slack_team_connected_elsewhere` (one Slack team, one Organization) and `409 connection_changed` (the row moved during verification) — each storing nothing. Success answers the status shape; the token and secret are AES-256-GCM ciphertext under `SLACK_CONNECTION_KEY` (`_PREVIOUS` decrypts during a rotation), bound to the Organization, the Slack team and the column. DELETE needs no key, touches no prospect-channel settings or channel record, and answers `{ removed }` — it does NOT revoke the token at Slack, which stays valid until the owner rotates it or uninstalls the app. Connect, replace and remove each write one `integrations.slack_connection.{connect,replace,remove}` audit row (summary: the team id) and email every owner, the actor included, through `sendSlackConnectionChangedEmail`; neither can change the answer. No response, log line, audit row or email carries the token, the secret or a ciphertext. No MCP twin for connect or disconnect — a person's act, recorded as no-tool rows in `tests/parity/README.md`; the status read's twin is the signed-in `get_slack_connection_status` (#9749), for which GET parses its `:workspaceId` (`400 validation_failed` when malformed).
|
|
77
77
|
- `/v1/workspaces/:workspaceId/prospect-channel` (GET, PUT) — the workspace's prospect-channel settings (#9749, spec `docs/specs/9749-workspace-prospect-channel-settings-get-spec.md`; `packages-paid/api-scheduling/src/routes/workspaces/meetings.ts`, beside the meetings routes, so the same `SCHEDULING` row and `tenant:schedule` scope; data access only through `tenantDb(tenant).prospectChannelSettings`). The blob is `prospectChannelSettingsSchema` (#9747): `enabled`, `memberIds` (Slack member ids invited in addition to the owner who connected Slack, who the channel step always invites), `welcomeTemplate?`, `channelPrefix`, `archiveDays`. GET needs `meeting-types-read` and answers the schema's defaults (`{ enabled: false, memberIds: [], channelPrefix: "prospect", archiveDays: 30 }`) when no row — or a row the schema refuses, logged `settings_unreadable` — is stored. PUT needs `meeting-types-manage` (owner, admin, or an agent an owner or admin granted it; a member is `403` before the body is read), replaces the whole blob after the strict schema accepts the body (32 KiB limit; an omitted field returns to its default; last writer wins), and is refused `409 slack_not_connected` whenever `getSlackConnectionStatus(tenant.organizationId)` answers not connected, whatever the body. An agent's PUT may change `enabled`, `channelPrefix` and `archiveDays` only: a `memberIds` or `welcomeTemplate` different from what is stored is `403 prospect_channel_needs_person`. Every saved PUT writes one `scheduling.prospect_channel.update` audit row naming its actor, person or agent, with the saved `enabled` and `memberIds` and whether the welcome changed (never its text). Every answer is `Cache-Control: private, no-store`; an unexpected error is `500 internal`, logged by class name. Twins: the signed-in `get_prospect_channel_settings` and `set_prospect_channel_settings`, served from `@working-theory/mcp-scheduling`.
|
|
78
78
|
- `/v1/workspaces/:workspaceId/general` (GET/PUT) — the workspace's own display name, the Pro `workspace` module's general-settings surface (#5371). Registered under `isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")` alongside the site-config routes above, behind the same tenant guard; the scope is always `request.tenant.workspaceId`, never the path param (the guard 403s a mismatch before any handler runs). PUT takes a strict `{ name }` body (1–200 chars, trimmed) and answers `{ name }`; GET answers `{ name, emailFrom }` (#7519 D6), where `emailFrom` is the deployment's sender (`deliverableSenderAddress()` — `AILK_DELIVERABLE_EMAIL_FROM ?? AILK_LEAD_NOTIFICATION_FROM`, the resolver the fulfillment service sends with) or `null` when neither is set, so the Admin's magnet wizard can show it read-only. Role floor on PUT: `workspace-settings` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)) — renaming is an org-level settings write; GET stays open to every member. No MCP twin — identity-bound tenant route (#3591 exception, same shape as `/v1/billing/*`).
|
|
79
79
|
- `/v1/workspaces/:workspaceId/messages/threads` (GET, `?view=&page=`), `/v1/workspaces/:workspaceId/messages` (GET, `?limit=`), `/v1/workspaces/:workspaceId/messages/:leadId` (GET, PATCH) — the Inbox message API (#6216 S7e, `src/routes/workspaces/messages.ts`): the thread list (#9686 — a client with a message, or a message with no client, newest first, 25 to a page, with a true count per view `open`/`resolved`/`all`/`archived`; twin `list_inbox_threads`), the workspace's messages whose thread is not archived newest first, the item read (resolves a message handled or not, serving `handledAt`), and Done, which archives the message's thread (#9686 D4). Registered under `isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")`, the same scope as the two routes above. A message is a `Lead` whose `source` is in #6204's closed `MESSAGE_LEAD_SOURCE_LIST` allowlist and that is not an order (#9170, the orders bullet below) — both checked only in `tenantDb(tenant).leads`, never in this file; a non-message lead is a 404 on the item read and the PATCH and absent from the list, the SAME 404 body as an absent id, another workspace's id, or an unbackfilled `workspaceId`. **One served row across all four routes** — `id`, `name`, `email`, `source`, `createdAt`, `handledAt`, `personId`, `body`, `excerpt` — and nothing else (#9349, superseding the 2026-09-19 six-key ruling): `personId` is the lead's Person or `null`; `body` is `payload.message` alone — cut to 16384 code units, line breaks normalized, control, bidi, zero-width and tag characters stripped (`\t` and `\n` kept), trimmed and cut at 4096 code points (`inbox-messages.ts`'s `readMessageBody`), else `null`; `excerpt` is its first line, cut at 140 code points. `payload` itself and every other payload key never cross, and every success body is re-parsed against `inboxMessageSchema` (`inboxMessageListSchema` for the list) — a mismatch is `500 internal_error` logged with issue codes and paths only. `leads-inbox.ts`'s `GET /v1/leads` row is unchanged. PATCH takes `{ "handled": true }` only, `.strict()`, so `{ "handled": false }` is a 400 — Unarchive is #9681's `PATCH …/people/:personId/thread`. **Since #9686 Done is Archive:** it archives the message's client thread through `tenantDb.people.setThreadState` (idempotent) and writes no `handledAt` — that column is read-only legacy, loaded as Archived by the `20261030120000_inbox_thread_state_backfill` migration — and a message with no client is `409 no_thread`; accept no longer marks the message handled. Role floor: the inbox gate `inboxAllowed` (`services/inbox-messages.ts`) — `inbox-read` on the GETs and `inbox-act` on PATCH and accept (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included), and an AGENT also needs the per-agent `inbox-act` grant on every inbox route, the member-area `…/people/:personId/messages` pair included (#9686 D9, closing SEC-9679-1); checked before any read or write. An Admin listing decision that changes the listing resolves the applicant's thread (`services/thread-auto-resolve.ts`, #9686 D8). The role is not self-servable: since #7523 `multi-tenant`'s null-binding fallback grants the default tenant only to the pinned, verified operator, the same check the base `workspace`-only guard applies (see the `AILK_OPERATOR_EMAIL` paragraph below). No anonymous MCP twin — identity-bound tenant routes, and the anonymous `/mcp` carries no caller identity. Each route's twin is a signed-in tool on `/mcp/workspace` (#3591 D13), which forwards the caller's agent key here: `list_inbox_messages` (list), `get_inbox_message` (item read), `mark_inbox_message_done` (PATCH) and `accept_inbox_message` (`POST …/:leadId/accept`). **Agent-session audit (#9683, spec `docs/specs/9683-agent-session-audit-knowledge-spec.md`):** an AGENT's call to any of these four routes, or to the member-area `GET`/`POST …/people/:personId/messages`, writes exactly one audit row whatever its outcome (`inbox.list_messages`, `inbox.get_message`, `inbox.mark_message_done`, `inbox.accept_message`, `people.message.list`, `people.message.create`; a `403`/`400` is `refused` against the workspace), carrying `metadata.agentSession` `{ sessionId, threadId, reason }` from `X-Agent-Session` / `X-Agent-Reason` through `api-workspace/src/lib/agent-session.ts`. `threadId` is derived by the route, never a header: `hub:<personId>` when the call resolved that Person in this workspace, else `workspace:<workspaceId>`. A person's call writes what it wrote before — nothing on these routes (#3591 § 2.3) — except Done, which is a state change and writes one `inbox.mark_message_done` row for every actor (#9686 D10); the thread list writes `inbox.list_threads` per agent call. **Accept links, never creates (#9690 D5, closing #8601):** `POST …/messages/:leadId/accept` stamps `promotedUserId` with the user the lead's Person is already linked to, and only when `personAddressProved` holds; a sender with no proved account is `409 no_account` ("Invite them to sign up"), nothing written — no code path creates a user from an inbox message, and nothing reads the deployment-wide `User` table by a typed address.
|
|
@@ -89,7 +89,7 @@ Routes are versioned under `/v1/*`:
|
|
|
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`.
|
|
91
91
|
- `/v1/workspaces/:workspaceId/callbacks` (GET, `?limit=`) — the Inbox callbacks read (#9254, `src/routes/workspaces/callbacks.ts` over `tenantDb(tenant).callbacks`; spec `docs/specs/9254-callback-rows-inbox-spec.md`): the workspace's request-and-confirm `Callback` rows (#5970), newest first, `limit` 1–50 (default 50, the Requests read's own query; any other key `400 validation_failed`), behind `inbox-read` (every role, a member and an agent key included) through `permitted`, checked before any read. Same registration, scope and tenant guard as the Requests read (`workspace || multi-tenant`); the scope is always `request.tenant.workspaceId`. The row is `id`, `name`, `phone`, `preferredTime` (the window asked for, as typed), `personId` (`null` for a callback captured with no email), `confirmedAt`, `confirmedTime`, `paidAt` and `createdAt` — read through the fixed `CALLBACK_ROW_SELECT`, so never `payload`, the confirm digest or expiry, `buyerEmail`, `amountMinor`, `currency` or a Stripe id. The confirm state is the reader's to derive (`paidAt` → paid, else `confirmedAt` → confirmed, else awaiting). No item read, no cursor, no stamp, no audit row; the body is re-parsed against its own `.strict()` schema. The Admin Inbox lists each row under Interested. No MCP twin, recorded in `tests/parity/README.md`.
|
|
92
|
-
- `/v1/workspaces/:workspaceId/submissions/:leadId/trial` (POST), `/v1/workspaces/:workspaceId/people/:personId/trial` (GET), `/v1/workspaces/:workspaceId/people/:personId/trial/extend` (POST) — the operator's Start, Get and Extend trial (#9287 C3, `src/routes/workspaces/trial.ts` over `src/services/trial.ts`; spec `docs/specs/9287-operator-started-trials-spec.md` DD2–DD7; Start trial since #9342, spec `docs/specs/9342-start-trial-needs-customers-and-spec.md` DD1–DD7). Same registration, scope and tenant guard as the callbacks read (`workspace || multi-tenant`); the scope is always `request.tenant`. All three need `billing-manage` by role — the owner alone; an admin, a member and every agent key are `403` before any read or Stripe call — and the two POSTs charge a write budget of 30 an hour per person (`chargeWriteBudget` scope `trial`, `429 rate_limited` with `Retry-After`) before their strict body (`bodyLimit` 4 KiB, `400 validation_failed`). The Stripe client is the tenant's own Organization's, `stripeClientForOrganization` only, never the env key (`409 stripe_not_connected`; unusable, or any thrown Stripe error, a fixed `502 stripe_unavailable`, logged by class name only). **Start trial** is a hosted Checkout trial (#9342): it accepts a lead only when it is a `flow:trial` submission of this workspace whose `trial` magnet is a `diagnostic` and names a Person (else one `404 not_found`); the `{ lookupKey }` must be a `plan` `listPlanOfferings` lists (`400 plan_unknown`; a failed read `503 offerings_unavailable`); with no usable `FRONTEND_URL` it is `503 checkout_not_configured` before any Stripe call. It then makes exactly three Stripe calls, all within the Offerings key contract (#8879 §3.3, unchanged): `prices.list` for exactly one active recurring Price under the key (`409 plan_unavailable`); `checkout.sessions.list` of the Person's email's completed sessions, where one tagged `ailk_origin: ailk-trial` is `409 trial_exists` with nothing created; and one session through `lib/stripe.ts`'s `createTrialCheckoutSession` (`@working-theory/stripe-client`) — subscription mode, `trial_period_days: 14`, `payment_method_collection: "if_required"`, `trial_settings.end_behavior.missing_payment_method: "pause"`, the terms box required (`409 terms_not_configured` when the account has no Terms of service URL), `customer_email` the Person's, `subscription_data.metadata` `{ ailk_origin: "ailk-trial", ailk_workspace, ailk_person, site }` with `site` the workspace id (`TRIAL_ORIGIN_TAG`), returning to `<FRONTEND_URL origin>/trial`, keyed `trial-checkout:<leadId>:<lookupKey>`. It calls no Customers or Subscriptions endpoint and writes nothing to the Person: Stripe creates the customer and the trialing subscription when the person completes Checkout, and the hub's licensing module (`@working-theory/api-hub`'s `src/licensing/`, #9383 DD3) mints from `customer.subscription.created` only for a subscription traced to the session Start trial recorded. A session URL `isStripeCheckoutUrl` refuses is `502 stripe_unavailable` with nothing sent. Before anyone is sent the link the session is recorded (`recordCheckoutSession`, #9383) with the workspace, the Organization, the Price and key it read and the connection's mode; a failed record expires the session on the same client and is `502 stripe_unavailable` with nothing sent, and a `conflict` whose stored record is equal in every field is the idempotent replay and succeeds. The person is then emailed the link (`@working-theory/email`'s `sendTrialCheckoutEmail`, from `deliverableSenderAddress()`); a failed send is one `warn` with its kind and still a success. The answer is `201 { checkout: { url, emailed } }` with `Cache-Control: private, no-store`, re-parsed against `startTrialResponseSchema`. **Get trial** lists the customer's ten newest subscriptions (the stored id, or — with none, or one missing on this client — the first customer whose email is the Person's) and serves the newest tagged `ailk-trial` as `{ subscriptionId, status, trialEnd, lookupKey }`, or `null`. **Extend trial** moves that trial's `trial_end` with `proration_behavior: "none"` only while it is `trialing` (`409 trial_not_active`), to a `trialEnd` strictly later than the current one and no later than 90 days after the request (`400 trial_end_invalid`), nothing written on a refusal. **Get and Extend trial need Customers and Subscriptions access, which a key that keeps §3.3 lacks** (#9342 DD7): on such a key Stripe refuses, the route answers `502 stripe_unavailable` and the Person page hides its Trial box; they work on a key that grants that access. Each success writes one `recordAuditAction` row naming the actor and the Person — `people.trial.start` (summary `{ emailed }`, `"true"` or `"false"`) and `people.trial.extend`; a refusal writes none, and Start trial does not mark the lead handled. No log line or audit summary carries the Checkout URL, and no response, log line or audit summary carries an email, a key or a `cus_`/`price_` id (the one `cs_` id served is inside Start trial's URL); Get and Extend's bodies are re-parsed against `trialResponseSchema` (`@working-theory/validation`'s `trial.ts`). No MCP twin — owner-only, recorded in `tests/parity/README.md`. **The managed-service licence** (#9402, spec `docs/specs/9402-managed-service-trials-deliver-the-spec.md` DD5): `/v1/workspaces/:workspaceId/people/:personId/trial/licence` (GET) and `…/trial/licence/reveal` (POST) sit in the same plugin and, like the trial routes, need `billing-manage` by role (#9841, narrowing #9402 DD5's staff audience) — the owner alone; an admin, a member and every agent key are `403` before any read. They read a managed-service trial's licence through the fulfilment seam's optional `readManagedLicence` (`services/offerings/fulfilment-hooks.ts`, built with `fulfilmentContextFor` from the tenant's own client): the GET answers `{ licence }` — `null`, `{ live: false }` or `{ live: true, tier, expiresAt }`, never a key — and the POST charges its own write budget (scope `licencereveal`, 30 an hour per person), takes a strict empty body, and answers `{ key }` only after one `people.trial.licence_reveal` audit row is written (`503 audit_unavailable` and no key when it is not). `none`/`not_live` on the reveal, and an Organization whose hook has no staff read, are `404 licence_not_found`; a read that cannot be made is `502 licence_unavailable`. Both answer `Cache-Control: private, no-store`; no log line or audit row carries the key. No MCP twin, recorded in `tests/parity/README.md`.
|
|
92
|
+
- `/v1/workspaces/:workspaceId/submissions/:leadId/trial` (POST), `/v1/workspaces/:workspaceId/people/:personId/trial` (GET), `/v1/workspaces/:workspaceId/people/:personId/trial/extend` (POST) — the operator's Start, Get and Extend trial (#9287 C3, `src/routes/workspaces/trial.ts` over `src/services/trial.ts`; spec `docs/specs/9287-operator-started-trials-spec.md` DD2–DD7; Start trial since #9342, spec `docs/specs/9342-start-trial-needs-customers-and-spec.md` DD1–DD7). Same registration, scope and tenant guard as the callbacks read (`workspace || multi-tenant`); the scope is always `request.tenant`. All three need `billing-manage` by role — the owner alone; an admin, a member and every agent key are `403` before any read or Stripe call — and the two POSTs charge a write budget of 30 an hour per person (`chargeWriteBudget` scope `trial`, `429 rate_limited` with `Retry-After`) before their strict body (`bodyLimit` 4 KiB, `400 validation_failed`). The Stripe client is the tenant's own Organization's, `stripeClientForOrganization` only, never the env key (`409 stripe_not_connected`; unusable, or any thrown Stripe error, a fixed `502 stripe_unavailable`, logged by class name only). **Start trial** is a hosted Checkout trial (#9342): it accepts a lead only when it is a `flow:trial` submission of this workspace whose `trial` magnet is a `diagnostic` and names a Person (else one `404 not_found`); the `{ lookupKey }` must be a `plan` `listPlanOfferings` lists (`400 plan_unknown`; a failed read `503 offerings_unavailable`); with no usable `FRONTEND_URL` it is `503 checkout_not_configured` before any Stripe call. It then makes exactly three Stripe calls, all within the Offerings key contract (#8879 §3.3, unchanged): `prices.list` for exactly one active recurring Price under the key (`409 plan_unavailable`); `checkout.sessions.list` of the Person's email's completed sessions, where one tagged `ailk_origin: ailk-trial` is `409 trial_exists` with nothing created; and one session through `lib/stripe.ts`'s `createTrialCheckoutSession` (`@working-theory/stripe-client`) — subscription mode, `trial_period_days: 14`, `payment_method_collection: "if_required"`, `trial_settings.end_behavior.missing_payment_method: "pause"`, the terms box required (`409 terms_not_configured` when the account has no Terms of service URL), `customer_email` the Person's, `subscription_data.metadata` `{ ailk_origin: "ailk-trial", ailk_workspace, ailk_person, site }` with `site` the workspace id (`TRIAL_ORIGIN_TAG`), returning to `<FRONTEND_URL origin>/trial`, keyed `trial-checkout:<leadId>:<lookupKey>`. It calls no Customers or Subscriptions endpoint and writes nothing to the Person: Stripe creates the customer and the trialing subscription when the person completes Checkout, and the hub's licensing module (`@working-theory/api-hub`'s `src/licensing/`, #9383 DD3) mints from `customer.subscription.created` only for a subscription traced to the session Start trial recorded. A session URL `isStripeCheckoutUrl` refuses is `502 stripe_unavailable` with nothing sent. Before anyone is sent the link the session is recorded (`recordCheckoutSession`, #9383) with the workspace, the Organization, the Price and key it read and the connection's mode; a failed record expires the session on the same client and is `502 stripe_unavailable` with nothing sent, and a `conflict` whose stored record is equal in every field is the idempotent replay and succeeds. The person is then emailed the link (`@working-theory/email`'s `sendTrialCheckoutEmail`, from `deliverableSenderAddress()`); a failed send is one `warn` with its kind and still a success. The answer is `201 { checkout: { url, emailed } }` with `Cache-Control: private, no-store`, re-parsed against `startTrialResponseSchema`. **Get trial** lists the customer's ten newest subscriptions (the stored id, or — with none, or one missing on this client — the first customer whose email is the Person's) and serves the newest tagged `ailk-trial` as `{ subscriptionId, status, trialEnd, lookupKey }`, or `null`. **Extend trial** moves that trial's `trial_end` with `proration_behavior: "none"` only while it is `trialing` (`409 trial_not_active`), to a `trialEnd` strictly later than the current one and no later than 90 days after the request (`400 trial_end_invalid`), nothing written on a refusal. **Get and Extend trial need Customers and Subscriptions access, which a key that keeps §3.3 lacks** (#9342 DD7): on such a key Stripe refuses, the route answers `502 stripe_unavailable` and the Person page hides its Trial box; they work on a key that grants that access. Each success writes one `recordAuditAction` row naming the actor and the Person — `people.trial.start` (summary `{ emailed }`, `"true"` or `"false"`) and `people.trial.extend`; a refusal writes none, and Start trial does not mark the lead handled. No log line or audit summary carries the Checkout URL, and no response, log line or audit summary carries an email, a key or a `cus_`/`price_` id (the one `cs_` id served is inside Start trial's URL); Get and Extend's bodies are re-parsed against `trialResponseSchema` (`@working-theory/validation`'s `trial.ts`). No MCP twin — owner-only, recorded in `tests/parity/README.md`. **The managed-service licence** (#9402, spec `docs/specs/9402-managed-service-trials-deliver-the-spec.md` DD5): `/v1/workspaces/:workspaceId/people/:personId/trial/licence` (GET) and `…/trial/licence/reveal` (POST) sit in the same plugin and, like the trial routes, need `billing-manage` by role (#9841, narrowing #9402 DD5's staff audience) — the owner alone; an admin, a member and every agent key are `403` before any read. They read a managed-service trial's licence through the fulfilment seam's optional `readManagedLicence` (`services/offerings/fulfilment-hooks.ts`, built with `fulfilmentContextFor` from the tenant's own client): the GET answers `{ licence }` — `null`, `{ live: false }` or `{ live: true, tier, expiresAt }`, never a key — and the POST charges its own write budget (scope `licencereveal`, 30 an hour per person), takes a strict empty body, and answers `{ key }` only after one `people.trial.licence_reveal` audit row is written (`503 audit_unavailable` and no key when it is not). A licence the issuer recorded for the customer at mint reads as `none` (#9402 AC 5, entitlements#19), whatever the Product is today. `none`/`not_live` on the reveal, and an Organization whose hook has no staff read, are `404 licence_not_found`; a read that cannot be made is `502 licence_unavailable`. Both answer `Cache-Control: private, no-store`; no log line or audit row carries the key. No MCP twin, recorded in `tests/parity/README.md`.
|
|
93
93
|
- `/v1/workspaces/:workspaceId/members` (GET) — the org's member roster (`userId`, `name`, `email`, `role`), the Pro `workspace` module's Team tab (#5371). Read-only by design: granting a Membership is the invite flow, which is its own spec with a mandatory security review (§2.3), so no POST/PUT/DELETE is routed at this path. Scoped to `request.tenant.organizationId` — Membership is org-level — server-resolved from the validated session. Role floor: `members-read` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)): the roster carries every member's email, and who the other members are is not a member's job. No MCP twin — identity-bound tenant route (#3591 exception).
|
|
94
94
|
- `GET /v1/workspaces/:workspaceId/knowledge` — the workspace knowledge read (#9683, spec `docs/specs/9683-agent-session-audit-knowledge-spec.md`; `packages-paid/api-workspace/src/routes/workspaces/knowledge.ts`, `// oss: false`): what the business offers, runs and serves, as `workspaceKnowledgeSchema` — `offerings` (the shown offering set, only while the commerce claim is held), `magnets` (the newest flows page's `live` rows), `meetingTypes` (the enabled slugs minus `privateSlugs`, titled by slug), `sitesServed` (the installed hub's live spokes for this workspace, read only when the hub's own gate answers `claimed`, through the optional `HubRegistry.listLiveSpokesForWorkspace`) and `strategyDocs` (`lib/strategy-docs.ts`, `[]` until a store exists) — every key always present, each entry an id, a title and a summary (`null` when it fails its check; an entry whose id or title fails is skipped and counted). Takes no query. Gate: `allows(people-read) || allows(people-memory-propose)` — a person member holds both; an agent only by grant. Every authenticated call writes one `workspace.knowledge.read` audit row (counts only; an agent's carries its session, thread `workspace:<workspaceId>`); `Cache-Control: private, no-store`. Same registration scope as the routes above (`workspace || multi-tenant`, `tenant:settings`). MCP twin: `get_workspace_knowledge` on `/mcp/workspace`.
|
|
95
95
|
- `/v1/workspaces/:workspaceId/agents` (GET `?userId=`, POST), `/v1/workspaces/:workspaceId/agents/:agentId/key` (POST), `/v1/workspaces/:workspaceId/agents/:agentId` (PATCH `{ enabled }`), `PUT /v1/workspaces/:workspaceId/agents/:agentId/grants` (`{ grants }`, #7864), and `/v1/agent/context` (GET) — the agent seat (#3591, `src/routes/workspaces/agents.ts` over `src/lib/agent-store.ts`, `// oss: false`). An agent is a stored delegate of one person, bound to one workspace. The five management routes refuse an agent caller outright (403), check `agents-manage` (owner, admin) before any read, and scope every read and write to `tenant.organizationId`, never a body or query value; an agent of another organization is the same 404 as a missing one. Create returns 201 with the key shown once; re-key returns the new key once. Each successful create, re-key, switch and grant change writes an `agents.*` audit record. **Grants (#7864, `docs/specs/7864-agent-key-grants-spec.md`).** An agent acts at `member`, plus any of nine permissions an owner or admin granted it one by one — `content-edit`, `content-publish`, `assets-write`, `magnets-manage`, `meeting-types-manage`, `people-read` since #852, `member-pieces-manage` since #7878, and `people-memory-propose` and `inbox-act` since #9679 (`@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, held to that set by the `agents_grants_allowed` CHECK). The grant PUT replaces the whole set (an empty list removes every grant) and refuses a grant the agent's person's own role does not hold, `409 grant_exceeds_person`. The clamp is one function both tenant guards call (`services/default-tenant.ts`'s `agentTenant`): an agent holds a stored grant only while its person's role holds it, so a demoted person's agent keeps the row but not the permission. The routes for the nine check `allows(tenant, "<permission>")`, which reads the grants — except that the six existing inbox `inbox-act` checks still read `can(tenant.role, …)` until #9686 moves them, and `people-memory-propose` is read today only by the workspace knowledge read (#9683, beside `people-read`); every other check keeps `can(tenant.role, …)`, so no other owner or admin permission is reachable. Every agent write on content, assets and meeting types writes one `recordAgentWrite` row naming the agent and its person (magnets already did); a person's writes there stay unaudited. The member-area writes audit every caller through `recordAuditAction`, whose actor names an agent as the agent. No grant raises the ceiling. `/v1/agent/context` is the agent's own read, unchanged by grants, and needs no permission beyond the workspace guard. Same registration scope as the routes above (`workspace || multi-tenant`).
|
|
@@ -105,6 +105,8 @@ Routes are versioned under `/v1/*`:
|
|
|
105
105
|
- `/v1/schedule/meetings` (GET) — the meetings read (#8843 D1–D3, `src/routes/schedule/index.ts`; spec `docs/specs/8843-the-meet-index-and-meeting-spec.md`): the caller's workspace's Live meetings with every fact a booking page shows, so no title, length, description or price is copied into content. Anonymous, in the same `scheduling`-gated schedule scope and strict per-IP limit as its siblings. It takes no argument (a query parameter is `400 invalid_input`, as its twin refuses one): the workspace comes from `resolvePublicWorkspace()`, the provider from `resolvePublicSchedulingProvider`, a refused spoke key, no provider or an unresolved workspace answering `503 not_configured`. The Live set is the stored row's `enabledSlugs` that pass `MeetingSlugSchema`; with no row a spoke's is empty (and no provider call is made), and a keyless caller's is every event type the account lists. Rows: each listed event type whose slug is Live, in the provider's order, `bookable: true` with `calLink` (`<account handle>/<slug>`), `title`, `durationMinutes` and the optional `description` and `price`; then each Live slug the provider does not list, `bookable: false` (a `null` handle makes every row non-bookable); each with `visibility` (`private` when the row's `privateSlugs` holds it, else `public`) and `position`, at most 20 rows. These rules live in `src/services/scheduling/meetings.ts`'s `listWorkspaceMeetings` (#9129), which the route calls with the account handle read; `GET /v1/member/meeting-types` calls the same function's `customers` list without it, so the member read makes no `/v2/me` call. **`privateSlugs` (#8843 D2)** is a fourth optional list on the meetings blob — what a site lists on `/private` rather than `/meet`; the meetings PUT keeps a stored list a body leaves out, as it keeps `customerSlugs`, and `meeting-types` ignores it. `enabledSlugs` keeps its whole-list meaning (a body without it parses to `[]`), so a writer of `privateSlugs` sends the `enabledSlugs` it read. **Private means unlisted, not access-controlled:** the read and its twin serve private rows to any caller, and `availability`, `event-meta` and `book` accept a private Live slug like a public one. The body is re-parsed against `MeetingListSchema` before it is sent (a failure is `502 provider_error`); a provider failure logs `{ route: "meetings", kind, detail }`, and nothing is logged on success. MCP twin: `list_meetings` (D5).
|
|
106
106
|
- `POST /v1/webhooks/calcom` — the scheduling booking-event receiver (#6085, brief H7): verifies the provider's HMAC-SHA256 signature (`x-cal-signature-256`) over the raw body, then normalizes one of six `BOOKING_*` trigger events into an `Appointment` row (`src/services/scheduling/appointments.ts`). `CAL_WEBHOOK_SECRET` is read server-side only (`config/index.ts`); an unset secret answers `503`, never a silent accept. Registered on the same `webhooksScope` as `/v1/webhooks/stripe` (its raw-body content-type parser is what a signed receiver needs), module-conditional (`scheduling`, `src/routes/webhooks/index.ts`). No commerce-claim read: a booking event is not a money event (D-67 puts the payment on the provider's own side). No MCP twin — an inbound receiver for one named provider, not an endpoint an agent could reasonably call, the same footing `POST /v1/webhooks/stripe` already takes.
|
|
107
107
|
- `POST /v1/webhooks/calcom/:connectionToken` — the same receiver for ONE workspace's own connection (#7533 D6, `calcomConnectionWebhookHandler`). The 43-character token in the path names one `SchedulingConnection` row, whose own decrypted secret verifies `x-cal-signature-256` and whose `workspaceId` the Appointment is written to — nothing in the body, query or headers reaches the workspace. A malformed or unknown token, or another provider's connection, is `404 not-found` (logged nothing); a secret that will not decrypt is `503 webhook-not-configured`; the rest of the ladder is the legacy route's, by the same code. Same module gate and `loadSlice` call as the legacy route, its own rate-limit bucket. The legacy `POST /v1/webhooks/calcom` is unchanged: the env account's receiver, writing into the `AILK_SITE_ID` workspace (SEC-7533-2, accepted). No MCP twin — the same inbound-receiver footing as the legacy route.
|
|
108
|
+
- `POST /v1/slack/events` — the prospect channel's Slack Events API receiver (#9854, `packages-paid/api-scheduling/src/routes/webhooks/slack-events.ts`). Every Organization's Slack app points its Request URL here; the path carries no parameter and no token (#9339). The event's `team_id` finds the one `SlackConnection` (`findSlackConnectionByTeam`, which answers the Organization, the team and the decrypted signing secret — never the bot token — and logs nothing), and THAT connection's own signing secret verifies Slack's `v0` signature over the raw body with a 300 s timestamp tolerance; the Organization is always the connection's, never a payload value (#7522). `url_verification` (no `team_id`) is answered `200 { challenge }` from a shape check alone, reading and writing nothing; a malformed challenge or body is `400 invalid-payload`. A missing or repeated header, a stale timestamp, a malformed or unknown team and a bad signature all answer one identical empty `401`; a connection-store failure is `503`, so Slack retries. A verified `member_joined_channel` from outside the connection's team stamps `prospectJoinedAt`, and a later `message` from the connection's own team by a person or another app stamps `firstAgentMessageAt`, on the record found by (team, channel) inside that Organization (`stampProspectChannelActivity`); no message content is read or stored. One `SCHEDULING` row in the `webhooks` scope (`slackEventsRoutes`, `loadSlice`), so it loads on every build that carries the scheduling module and on no other, with its own 6,000 req/min/IP bucket. Logs a `reason` kind only. No MCP twin — a signed inbound receiver for one provider, the same footing as `POST /v1/webhooks/calcom` and `/v1/slack/actions`. The booking side is `src/services/scheduling/prospect-channel.ts`: both Cal.com receivers await its step, bounded at 8 s, after a lifecycle create committed, and its outcome never changes their reply.
|
|
109
|
+
- `GET /v1/cron/prospect-channel-archive` — the prospect-channel archive sweep's cron entry (#9855, `packages-paid/api-scheduling/src/routes/cron/prospect-channel-archive.ts` over `src/services/scheduling/prospect-channel-archive.ts`; spec `docs/specs/9855-the-prospect-channel-archive-sweep-spec.md`). The scaffold's `apps/api/vercel.json` (`packages/create-ailk/template-overlay/apps/api/vercel.json`, delivered to existing sites by `create-ailk update`) schedules it daily at `0 7 * * *`; this repo's own `apps/api/vercel.json` does not, since neither of its deployments runs the prospect channel. The ladder: `CRON_SECRET` (`config`) unset or shorter than 32 characters → `503 { error: "cron-not-configured" }`; an `Authorization` that is not exactly `Bearer <token>` → empty `401` (`missing_bearer`); a token whose SHA-256 digest does not `timingSafeEqual` the secret's → empty `401` (`bad_secret`); otherwise the sweep runs with a 12 s deadline and answers `200 { summary }` — or `500 { error: "sweep-failed" }` for a failed or throwing sweep, never a throw. The query string and the body are never read; no `HEAD` twin. The sweep archives, with each Organization's own bot token, a channel the prospect never joined once their latest booking (a start, or a cancellation with no rebook) is more than the workspace's `archiveDays` old, after Slack confirms it is neither shared nor archived, and stamps `archivedAt`; a second run makes no Slack call. One `SCHEDULING` row in the `webhooks` scope (`prospectChannelArchiveCronRoute`), so it loads only with the scheduling module (a build without it answers the daily call `404`), with its own 10 req/min/IP bucket. Logs a refusal `reason`, the summary or the error kind — never the header, the secret or a token. No MCP twin — a scheduler's entry behind a deployment secret, the footing of `POST /v1/webhooks/calcom` and `POST /v1/slack/events`.
|
|
108
110
|
- `POST /v1/webhooks/stripe/:connectionToken` — the per-Organization Stripe receiver (#8932, `src/routes/webhooks/stripe-offerings.ts`; spec `docs/specs/8932-the-per-organization-stripe-webhook-spec.md`; contract W1, W3–W11, G5, T8). The 43-character token names one `StripeConnection` row (`findStripeConnectionByWebhookToken`, #8907), whose own decrypted secret verifies the signature through `verifyWebhookSignatureKeyless` (`@working-theory/stripe-client`, no env key) and whose `organizationId` is the only Organization the event can act for — nothing in the body, query or headers changes it. It acts only on `checkout.session.completed` / `.async_payment_succeeded` for a session the hub's own checkout recorded (`readCheckoutSessionRecord`, #8930) for that Organization, whose signed `metadata.site` is the record's workspace, whose mode is the row's, and whose one line item — re-read with the connection's own client, never the env key — has the record's Price id; then it writes ONE lead through `routeLead` (key `"stripe-offerings " + sessionId`, a space no `Idempotency-Key` header can carry; `offeringsCheckout: true`; offering the record's full lookup key), marked `spokeCheckout` for a live session so #8793's release holds the plan, unmarked for a test one. A malformed or unknown token is `404 not-found` and logs nothing; a store failure `503 webhook-unavailable`; no usable secret `503 webhook-not-configured`; the claim, signature and livemode steps are the legacy route's; an unreadable record, Stripe read or lead write is a non-2xx that is never marked seen, so Stripe retries. Every `200` body is `{ received: true }` (the outcome is the log line's: `unrecorded`, `foreign_organization`, `offering_mismatch`, `fulfilled`, …). Dedup is its own per-Organization store (`hasSeenForOrganization`); the legacy `POST /v1/webhooks/stripe` acks any `ailk_offering`-stamped event `offerings_path` before `markSeen` and writes nothing. Log lines carry `organizationId`, `eventType`, an 8-character event-id prefix and the outcome only, and the request and error lines log the path with the token replaced whole (`lib/error-serializer.ts`'s `redactLogPath`, which also covers the calcom token route). No billing identity is bound from a connection's event (W11). **The fulfilment seam (#9383 DD1, `src/services/offerings/fulfilment-hooks.ts`):** for an Organization a paid package registered a hook for (the hub's licensing module, for the one Organization it is configured to mint for), `onFulfilledSession` runs after `fulfilled` / `already_fulfilled` and the event is marked seen only once it answers without `retry`; the five licence types (`customer.subscription.{created,updated,deleted}`, `invoice.paid`, `charge.refunded`) are deduplicated after the livemode step and handed to `onLifecycleEvent` in place of `ignored_type`; a `retry` answer, an unusable connection or a throwing hook is `503 licence-unavailable`, never seen. Every other Organization answers as before. A recorded session tagged `ailk_origin: ailk-trial` writes no lead (`trial_recorded`). Its own 100/min per-IP bucket. No MCP twin — the same inbound-receiver footing as the legacy route.
|
|
109
111
|
- `/v1/schedule/appointments` (GET, `?limit=&cursor=&window=`) — the SITE's own appointments (#6085), tenant-guarded and role-floored at `appointments-read` (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included; the rows carry attendee contact PII, which the operator opened to the member who works the inbox). Registered in its OWN tenant scope, gated on `isModuleEnabled("scheduling")` alone, behind `tenantPreHandlerForBuild()` — NOT the anonymous schedule scope above (a prospect booking a call has no account). Scope is ALWAYS `request.tenant.workspaceId`, NEVER `currentSiteId()` (the receiver's own write scope) — and deliberately carries NO platform-operator escape hatch, unlike `/v1/waitlist-experiments/*`: an appointment book is a seller's customer list. `window` defaults to `upcoming`; `cursor` is the previous page's last row id, and one that does not resolve within the caller's scope is a `400`, never a silent page 1 (the `/v1/waitlist-experiments/*/signups` cursor discipline). Nine fields per row and nothing more — `id`, `startTime`, `endTime`, `title`, `attendeeName`, `attendeeEmail`, `status`, `paid`, `personId` (#9348 — the Person the booking was stored against when it was created, or `null`; a later provider event that rewrites the attendee does not move it); `siteId`, `providerBookingUid`, `provider`, the three watermark columns, `paymentInitiatedAt`, `noShow`, `cancelledAt`, `createdAt` and `updatedAt` are never selected (`src/services/scheduling/appointments.ts`'s `listAppointments` is the only read path). No MCP twin — identity-bound tenant route (#3591 exception, the same shape as `/v1/waitlist-experiments/*`).
|
|
110
112
|
- `/v1/aeo/score` (POST), `/v1/aeo/score/example` (GET) — anonymous instant AEO score for a respondent-supplied URL, and the bundled pre-launch example, both fed by the SSRF-guarded crawler at `@working-theory/aeo/fetch` and the pure scorer at `@working-theory/aeo` (`src/services/aeo-score.ts`, #4666). No MCP twin — recorded exception, brief D7 (exposing an arbitrary-URL crawler to anonymous agents is a security-posture decision; `apps/mcp/src/tools/audit.ts` deliberately stays same-origin-locked instead).
|
|
@@ -154,7 +156,8 @@ Stateless, idempotent bins an external scheduler runs (Vercel Cron / GitHub
|
|
|
154
156
|
Actions cron / plain crontab) — this repo has no queue or in-process timer
|
|
155
157
|
(D1). Dev: `pnpm --filter @working-theory/api-<module> <script>` for a module
|
|
156
158
|
bin (#9565 D2 — eight are `@working-theory/api-signal-collectors`'s,
|
|
157
|
-
`member-request:match` is `@working-theory/api-workspace`'s
|
|
159
|
+
`member-request:match` is `@working-theory/api-workspace`'s,
|
|
160
|
+
`prospect-channel-archive:sweep` is `@working-theory/api-scheduling`'s; each package's
|
|
158
161
|
`src/bin/` holds its source and its `package.json` the script) and
|
|
159
162
|
`pnpm --filter @working-theory/api <script>` for a base bin (`abandon:sweep`,
|
|
160
163
|
`rate-limit:probe`, `spokes`, `stripe-connections:rekey` — their source is
|
|
@@ -193,6 +196,15 @@ sweep's own DB constraint (never a caller-side lock).
|
|
|
193
196
|
run rather than losing them. Per-listing failures are logged (PII-safe) and
|
|
194
197
|
never stop the listings after them. The CONSUMER schedules this weekly;
|
|
195
198
|
this repo adds no cron of its own.
|
|
199
|
+
- `prospect-channel-archive:sweep` (`src/bin/prospect-channel-archive-sweep.ts`
|
|
200
|
+
in `@working-theory/api-scheduling`, #9855) — archives each prospect
|
|
201
|
+
channel the prospect never joined once their latest booking is more than
|
|
202
|
+
the workspace's `archiveDays` old, and stamps `archivedAt`; prints one
|
|
203
|
+
`[prospect-channel-archive-sweep] <summary>` line (an error kind and exit
|
|
204
|
+
code 1 on failure). The same sweep as `GET /v1/cron/prospect-channel-archive`,
|
|
205
|
+
with no deadline and no secret. Idempotent via `archivedAt` (a
|
|
206
|
+
compare-and-set) and Slack's own `is_archived`, so a second run makes no
|
|
207
|
+
Slack call.
|
|
196
208
|
|
|
197
209
|
## Error handling
|
|
198
210
|
|
|
@@ -219,7 +231,11 @@ sweep's own DB constraint (never a caller-side lock).
|
|
|
219
231
|
- Don't put session state in `apps/api`. It's stateless.
|
|
220
232
|
- Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
|
|
221
233
|
|
|
222
|
-
Last updated: 2026-10-
|
|
234
|
+
Last updated: 2026-10-25 — #9910: the `/v1/checkout/sessions` bullet names the spoke caller class on a hub that sets `OSS_API_INTERNAL_SECRET` and its registered-origin redirect lock — verified against `packages/api/src/routes/checkout/sessions.ts`, `packages/api/src/services/public-site-resolver.ts` and `packages/api/src/lib/hub.ts`.
|
|
235
|
+
|
|
236
|
+
Prior: 2026-10-25 — #9854: the `POST /v1/slack/events` receiver, the prospect channel step behind the Cal.com receivers, and the nine Slack connection scopes — verified against `packages-paid/api-scheduling/src/routes/webhooks/{slack-events,calcom}.ts`, `packages-paid/api-scheduling/src/services/scheduling/prospect-channel.ts`, `packages/api/src/services/slack/connection.ts` and `packages/validation/src/schemas/slack.ts`.
|
|
237
|
+
|
|
238
|
+
Prior: 2026-10-24 — #9749: the prospect-channel settings routes (`GET`/`PUT /v1/workspaces/:workspaceId/prospect-channel`) and the twins of them and of the Slack status read — verified against `packages-paid/api-scheduling/src/routes/workspaces/meetings.ts`, `packages-paid/api-workspace/src/routes/workspaces/slack-connection.ts`, `packages/api/src/lib/tenant-db.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
223
239
|
|
|
224
240
|
Prior: 2026-10-24 — #9683: the workspace knowledge read (`GET /v1/workspaces/:workspaceId/knowledge`) and its twin, and agent-session audit rows on the four Inbox message routes and the member-area messages pair — verified against `packages-paid/api-workspace/src/routes/workspaces/{knowledge,messages,messages-out}.ts`, `packages-paid/api-workspace/src/lib/agent-session.ts` and `packages/api/src/lib/hub.ts`.
|
|
225
241
|
|
|
@@ -47,6 +47,19 @@ CONTENT_ADAPTER=mdx
|
|
|
47
47
|
# NEXT_PUBLIC_; see the repo-root .env.example, #7394). A cross-site post is
|
|
48
48
|
# refused before the hub is called.
|
|
49
49
|
#
|
|
50
|
+
# THE BOOKING BOT CHECK (#9889). On Vercel, POST /v1/schedule/book asks Vercel
|
|
51
|
+
# BotID for a verdict before the hub is called: a bot is refused 403
|
|
52
|
+
# bot_detected, so no booking and no Slack invite exist. A real visitor sees no
|
|
53
|
+
# extra step. It is ON by default; set this to `off` to turn it off (any other
|
|
54
|
+
# value leaves it on). Off Vercel it cannot run, so it is inert and the server
|
|
55
|
+
# logs that once. On Vercel it needs the project's OIDC token (on by default):
|
|
56
|
+
# without it every booking answers 503 bot_check_unavailable, as it does when
|
|
57
|
+
# BotID cannot answer. A self-hosted or local `next start` must not carry
|
|
58
|
+
# VERCEL=1 (as `vercel env pull` writes it), or every booking answers 503.
|
|
59
|
+
# Basic or Deep Analysis is the Vercel project's own setting; Deep Analysis is
|
|
60
|
+
# billed per check.
|
|
61
|
+
# AILK_BOOKING_BOT_CHECK=
|
|
62
|
+
#
|
|
50
63
|
# EDGE PRECONDITION: the relay forwards x-forwarded-for exactly as this site
|
|
51
64
|
# received it, so run the site behind an edge that OVERWRITES the client-address
|
|
52
65
|
# headers (as Vercel does), not one that appends to them. Otherwise a caller
|
|
@@ -14,7 +14,7 @@ Inherits root `CLAUDE.md`. This file adds web-specific rules.
|
|
|
14
14
|
|
|
15
15
|
## Always do first
|
|
16
16
|
|
|
17
|
-
- For any UI work: read § Theme & styling and § Iterate visually below before writing code. To add a whole page, run
|
|
17
|
+
- For any UI work: read § Theme & styling and § Iterate visually below before writing code. To add a whole page, run `/ailk:growth-ops page add`.
|
|
18
18
|
- For any page-rendering or schema-emission work: read `packages/schema/README.md` (catalog) and `packages/schema/CLAUDE.md` (operational layer + component map) if you haven't this session.
|
|
19
19
|
- For content authoring questions: read `content/CLAUDE.md` first.
|
|
20
20
|
- For a full page build, follow the per-page loop (Content intent → Layout → Author content → Visual iterate). The visual-iterate step uses `screenshots/` as scratch (see § Iterate visually).
|
|
@@ -489,10 +489,12 @@ one `[offerings-checkout] refused reason=<reason>` warning; no response or
|
|
|
489
489
|
log line carries the key. It sends `OSS_API_INTERNAL_SECRET` as
|
|
490
490
|
`x-internal-auth` only from a site that is not a spoke and only to an
|
|
491
491
|
`https:` or loopback API, so the hub's own `apps/web` must hold the API's
|
|
492
|
-
value when the API sets it. A spoke never holds that secret:
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
492
|
+
value when the API sets it. A spoke never holds that secret: on a hub that
|
|
493
|
+
sets `OSS_API_INTERNAL_SECRET` its spoke key authenticates the buy, which
|
|
494
|
+
sells when the spoke's hub sign-in client is enabled with its callback on the
|
|
495
|
+
spoke's own origin (#9910); on a hub that does not set it, the buy sells when
|
|
496
|
+
the hub lists the spoke's origin in `AILK_TRUSTED_ORIGINS`. Otherwise every
|
|
497
|
+
spoke buy returns to `/pricing`.
|
|
496
498
|
|
|
497
499
|
An existing scaffold adopts the new page by bringing
|
|
498
500
|
`lib/offerings-pricing.ts`, `app/api/offerings/checkout/[lookupKey]/route.ts`
|
|
@@ -552,6 +554,7 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
|
|
|
552
554
|
- **No cross-site POST.** A `Sec-Fetch-Site` other than `same-origin`, or an `Origin` not on this site's own host, is refused 403 before the hub is called.
|
|
553
555
|
- **Headers are an allow-list.** The visitor's `x-forwarded-for` (else `x-real-ip`) and `user-agent` pass through so per-visitor rate limits still count each visitor; no `authorization`, client-sent `x-ailk-*` or `host` is forwarded, and a `cookie` goes only on `POST /v1/commerce/invoices`, the one session route.
|
|
554
556
|
- **The relay has its own per-visitor budget (#7707).** Each visitor gets 8 calls per 10 minutes across the five visitor routes, and the session route (`POST /v1/commerce/invoices`) gets its own, wider budget of 30 per 10 minutes; the call that exceeds either gets `429 rate_limited` before any hub call. The budget is held in this instance's own process (no shared store, no env variable) and holds only where the site's edge overwrites `x-forwarded-for` and `x-real-ip` — Vercel does; a bare self-hosted Next server does not.
|
|
557
|
+
- **A bot check on the booking (#9889).** On Vercel, `POST /v1/schedule/book` asks Vercel BotID (`checkBotId`, from `BOT_CHECKED_ROUTES`) for a verdict after every refusal above and before the hub is called: a bot is `403 bot_detected`, and a check that throws, answers no boolean or takes over 5 seconds is `503 bot_check_unavailable`, so a scripted booking never reaches Cal.com or Slack. It is on unless `AILK_BOOKING_BOT_CHECK=off` and inert off Vercel; the locale layout mounts `BotIdClient` on the same list and predicate, `next.config.mjs` wraps `withBotId`, and `middleware.ts` keeps BotID's proxy prefix out of its matcher.
|
|
555
558
|
- `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
|
|
556
559
|
|
|
557
560
|
## AEO & performance
|
|
@@ -570,7 +573,11 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
|
|
|
570
573
|
- Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
|
|
571
574
|
- Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
|
|
572
575
|
|
|
573
|
-
Last updated: 2026-10-
|
|
576
|
+
Last updated: 2026-10-25 — the Offerings buy route's spoke sentence: on a hub that sets `OSS_API_INTERNAL_SECRET` a spoke's key authenticates its buy, held to its hub sign-in client's origin (#9910) — verified against `app/api/offerings/checkout/[lookupKey]/route.ts` and `packages/api/src/routes/checkout/sessions.ts`.
|
|
577
|
+
|
|
578
|
+
Prior: 2026-10-25 — § The same-origin relay: the booking bot check — Vercel BotID on `POST /v1/schedule/book`, `bot_detected` / `bot_check_unavailable`, the `AILK_BOOKING_BOT_CHECK` switch and the middleware's BotID exclusion (#9889) — verified against `lib/relay.ts`, `app/[locale]/layout.tsx`, `next.config.mjs` and `middleware.ts`.
|
|
579
|
+
|
|
580
|
+
Prior: 2026-10-24 — § The `(auth)` route group: a magic link can create the account while sign-up is open, carrying the ticked agreements bound to its token (#9667) — verified against `lib/auth-email.ts`, `lib/sign-up-agreements.ts` and `app/[locale]/(auth)/sign-up/{page,sign-up-card}.tsx`.
|
|
574
581
|
|
|
575
582
|
Prior: 2026-10-21 — § Pricing page and § Magnet pages: `/pricing`, the magnet pages and the results permalink read the browser-facing API origin through `browserApiOrigin()` (#7874 D12), so a spoke that enables them passes its own `env:check` (#9480) — verified against `app/[locale]/pricing/page.tsx`, `app/[locale]/flows/[slug]/{magnet-page.tsx,results/[payload]/page.tsx}` and `lib/api-origin.generated.ts`.
|
|
576
583
|
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Tests for `resolveNextPath` — the /sign-in `?next=` sanitizer (#4984).
|
|
3
3
|
* Foreign, protocol-relative, backslash, control-char, and non-string inputs
|
|
4
4
|
* all fall back; plain in-app paths pass through unchanged.
|
|
5
|
+
*
|
|
6
|
+
* `hrefWithNext` (#9912 D4) — a toggle carries `?next=` only when the page's
|
|
7
|
+
* resolved `next` is not its fallback.
|
|
5
8
|
*/
|
|
6
|
-
import { resolveNextPath } from "../next-path";
|
|
9
|
+
import { hrefWithNext, resolveNextPath } from "../next-path";
|
|
7
10
|
|
|
8
11
|
const FALLBACK = "/account";
|
|
9
12
|
|
|
@@ -29,3 +32,23 @@ describe("resolveNextPath()", () => {
|
|
|
29
32
|
expect(resolveNextPath(raw, FALLBACK)).toBe(FALLBACK);
|
|
30
33
|
});
|
|
31
34
|
});
|
|
35
|
+
|
|
36
|
+
describe("hrefWithNext()", () => {
|
|
37
|
+
it("the fallback gives the bare href", () => {
|
|
38
|
+
expect(hrefWithNext("/sign-up", "/account", "/account")).toBe("/sign-up");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("any other next rides as ?next=, URL-encoded", () => {
|
|
42
|
+
expect(
|
|
43
|
+
hrefWithNext("/sign-up", "/api/auth/hub/continue/abc_-1", "/account"),
|
|
44
|
+
).toBe("/sign-up?next=%2Fapi%2Fauth%2Fhub%2Fcontinue%2Fabc_-1");
|
|
45
|
+
expect(
|
|
46
|
+
hrefWithNext("/fr/sign-in", "/fr/account?tab=x", "/fr/account"),
|
|
47
|
+
).toBe("/fr/sign-in?next=%2Ffr%2Faccount%3Ftab%3Dx");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("a refused next resolves to the fallback, so its toggle is bare", () => {
|
|
51
|
+
const next = resolveNextPath("//evil.example", FALLBACK);
|
|
52
|
+
expect(hrefWithNext("/sign-up", next, FALLBACK)).toBe("/sign-up");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
* the deployment's own data — a crafted query parameter cannot change
|
|
29
29
|
* the name — the "Don't have an account? Sign up" line linking
|
|
30
30
|
* /sign-up, and the Terms + Privacy acknowledgement.
|
|
31
|
+
* (k) the Sign up toggle (#9912 D4): it carries the validated `?next=` to
|
|
32
|
+
* /sign-up; a `next` resolveNextPath refuses, or none, gives the bare
|
|
33
|
+
* /sign-up.
|
|
31
34
|
*
|
|
32
35
|
* `next-intl/server` is mocked here against the real en.json, with `{name}`
|
|
33
36
|
* placeholders filled from the values passed, so `signIn` copy is asserted
|
|
@@ -524,3 +527,51 @@ describe("/sign-in page — the one card (#9662)", () => {
|
|
|
524
527
|
expect(within(footnote).getAllByRole("link")).toHaveLength(2);
|
|
525
528
|
});
|
|
526
529
|
});
|
|
530
|
+
|
|
531
|
+
describe("/sign-in page — the Sign up toggle keeps the next (#9912 D4)", () => {
|
|
532
|
+
beforeEach(() => {
|
|
533
|
+
mockClientInfo.mockReset().mockResolvedValue({ ok: false, status: 404 });
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("(k) a continuation next rides the toggle to /sign-up, URL-encoded", async () => {
|
|
537
|
+
const ref = mintContinuation("client_id=spoke_1", TEST_SECRET);
|
|
538
|
+
const next = `/api/auth/hub/continue/${ref}`;
|
|
539
|
+
await renderPage(next);
|
|
540
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
541
|
+
"href",
|
|
542
|
+
`/sign-up?next=${encodeURIComponent(next)}`,
|
|
543
|
+
);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("(k) an in-app next rides the toggle too", async () => {
|
|
547
|
+
await renderPage("/account/billing");
|
|
548
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
549
|
+
"href",
|
|
550
|
+
"/sign-up?next=%2Faccount%2Fbilling",
|
|
551
|
+
);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it.each([
|
|
555
|
+
["a protocol-relative next", "//evil.example"],
|
|
556
|
+
["an absolute next", "https://evil.example/x"],
|
|
557
|
+
["a backslash next", "/\\evil.example"],
|
|
558
|
+
["a control character", "/x\r\ny"],
|
|
559
|
+
])(
|
|
560
|
+
"(k) %s fails resolveNextPath, so the toggle is the bare /sign-up",
|
|
561
|
+
async (_label, next) => {
|
|
562
|
+
await renderPage(next);
|
|
563
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
564
|
+
"href",
|
|
565
|
+
"/sign-up",
|
|
566
|
+
);
|
|
567
|
+
},
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
it("(k) no next: the bare /sign-up", async () => {
|
|
571
|
+
await renderPage();
|
|
572
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
573
|
+
"href",
|
|
574
|
+
"/sign-up",
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
@@ -3,5 +3,18 @@
|
|
|
3
3
|
* `lib/next-path.ts` (#7894 D7) so the hub-session callback, which a spoke
|
|
4
4
|
* keeps when it drops this route group, shares it. Re-exported here so this
|
|
5
5
|
* group's importers are unchanged.
|
|
6
|
+
*
|
|
7
|
+
* `hrefWithNext` (#9912 D4) builds the /sign-in ↔ /sign-up toggle: it carries
|
|
8
|
+
* the page's validated `next` only when that is not the page's fallback, so a
|
|
9
|
+
* refused or absent `next` gives today's bare link.
|
|
6
10
|
*/
|
|
7
11
|
export { resolveNextPath } from "../../../../lib/next-path";
|
|
12
|
+
|
|
13
|
+
/** `href` with `?next=<next>` when `next` is not the page's fallback, else `href`. */
|
|
14
|
+
export function hrefWithNext(
|
|
15
|
+
href: string,
|
|
16
|
+
next: string,
|
|
17
|
+
fallback: string,
|
|
18
|
+
): string {
|
|
19
|
+
return next === fallback ? href : `${href}?next=${encodeURIComponent(next)}`;
|
|
20
|
+
}
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
* The one card (#9662): "Sign in to <site>" (the name is the deployment's,
|
|
25
25
|
* read in `resolveSignInCardText`, never the request's), the "Don't have an
|
|
26
26
|
* account? Sign up" line to `/sign-up`, and the Terms + Privacy
|
|
27
|
-
* acknowledgement.
|
|
27
|
+
* acknowledgement. The Sign up line carries the validated `next` (#9912 D4),
|
|
28
|
+
* so a buyer who switches to sign-up keeps the destination; a refused or
|
|
29
|
+
* absent `next` gives the bare `/sign-up`.
|
|
28
30
|
*
|
|
29
31
|
* Signed-in visitors are not redirected here — the Nav's session-aware action
|
|
30
32
|
* cluster (Account / Sign out) is the affordance for them.
|
|
@@ -48,7 +50,7 @@ import {
|
|
|
48
50
|
} from "../../../api/auth/hub/continuation";
|
|
49
51
|
import { legalFootnote } from "../legal-footnote";
|
|
50
52
|
|
|
51
|
-
import { resolveNextPath } from "./next-path";
|
|
53
|
+
import { hrefWithNext, resolveNextPath } from "./next-path";
|
|
52
54
|
import { SignInCard, type SignInProvider } from "./sign-in-card";
|
|
53
55
|
import { resolveSignInCardText, type SignInCardText } from "./sign-in-text";
|
|
54
56
|
|
|
@@ -147,7 +149,8 @@ export default async function SignInPage({
|
|
|
147
149
|
);
|
|
148
150
|
const method = active.includes("email") ? "password" : "sso";
|
|
149
151
|
const emailLink = active.includes("magic-link");
|
|
150
|
-
const
|
|
152
|
+
const fallback = localeHref(locale, "/account");
|
|
153
|
+
const next = resolveNextPath(rawNext, fallback);
|
|
151
154
|
// A failed link returns here, keeping the visitor's destination.
|
|
152
155
|
const signInHref = `${localeHref(locale, "/sign-in")}?next=${encodeURIComponent(next)}`;
|
|
153
156
|
// Only the email link produces `?error=` here; with it off, today's render.
|
|
@@ -167,7 +170,10 @@ export default async function SignInPage({
|
|
|
167
170
|
},
|
|
168
171
|
}
|
|
169
172
|
: {})}
|
|
170
|
-
prospect={{
|
|
173
|
+
prospect={{
|
|
174
|
+
...text.prospect,
|
|
175
|
+
href: hrefWithNext(localeHref(locale, "/sign-up"), next, fallback),
|
|
176
|
+
}}
|
|
171
177
|
footnote={legalFootnote(locale, text.acknowledgement)}
|
|
172
178
|
providers={providers}
|
|
173
179
|
method={method}
|