From 917736ebb0eb36c86ca35de9eb0834264c389e43 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 12:42:13 +0200 Subject: [PATCH] docs(phase-9): spec for per-client intake links + project picker Phase 9 design doc. Same format as Phase 8. Headline: - intake_links.client replaces intake_links.project for new rows; old shape stays live - projects.show_in_intake default false; owners opt in per project - /intake/ becomes a picker page; selecting a project redirects to /intake//p/ for the wizard - daily cap is now per (link, project) via a new intake_link_submissions collection - Phase 8 invariants (worst a hostile body achieves is a strangely-worded card; same 404 wording for unknown/revoked/ expired; server decides created_by_role=anonymous; parseBrief whitelist) all carry forward unchanged Three plans in 9a/9b/9c. ~3 working days. --- ...-intake-links-and-project-picker-design.md | 499 ++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-21-phase-9-per-client-intake-links-and-project-picker-design.md diff --git a/docs/superpowers/specs/2026-09-21-phase-9-per-client-intake-links-and-project-picker-design.md b/docs/superpowers/specs/2026-09-21-phase-9-per-client-intake-links-and-project-picker-design.md new file mode 100644 index 0000000..20e86fe --- /dev/null +++ b/docs/superpowers/specs/2026-09-21-phase-9-per-client-intake-links-and-project-picker-design.md @@ -0,0 +1,499 @@ +# Phase 9 — Per-Client Intake Links + Project Picker + +**Date:** 2026-09-21 +**Status:** Draft for review +**Phase:** 9 +**Depends on:** Phase 8 (public anonymous intake, PWA shell, per-project markdown context) + +--- + +## 1. Purpose + +Phase 8 made the public route work end-to-end. The next thing the +developer asked for after playing with it for an hour is the link shape +itself: **one URL per client to share, with a project picker in front of +the wizard**, instead of one URL per project. The reasoning is in the +thread above this spec; the short version is that human clients do not +think in projects, they think in companies, and "send us a request — +`rjb.example.com/intake`" beats "send us a request for the SE-Sprint-2 +migration — `/intake/hkXHJPUS…`" by a mile. + +Three things change, and nothing else moves: + +1. **The link is bound to a client, not a project.** Each client has a + small set of links (typically one) that open a picker page listing the + client's active projects. +2. **The picker page is the new entry.** It is a real, anonymous, + authenticated-by-token page that lists projects the client has and + asks the user to pick one before the wizard runs. +3. **The wizard itself does not change.** It still takes a `projectId` + and still asks questions and still writes a card. It just gets that + `projectId` from the URL of the picker-selected project rather than + from the link row. + +The security property Phase 8 set up carries forward unchanged: **the +worst a hostile body achieves is a strangely-worded card in the Inbox**. +The picker is a UI change to how the link and the wizard connect — it +does not add a way for a hostile body to escape the parseBrief whitelist +or the parse-and-server-decide-fields rule. + +Out of scope for this spec: + +- **Re-introducing per-project links.** The new shape is additive; the + old per-project shape stays live. Existing Phase 8 tokens keep + working as-is and continue to point at their original project. +- **Multi-client links.** One token, one client, easy to rotate. A + token cannot span clients, today or later. +- **Reordering intake by client-defined workflow.** Each project still + gets its own card; the picker just routes which project's wizard runs. +- **Renaming projects in the picker to be friendlier than the internal + `slug`.** A later phase can add a `client_display_name`; for now the + picker shows the project's actual name. +- **Branch-by-tenant lookup of intake links, analytics, etc.** One + PocketBase superuser; one rule set. Per-tenant isolation was deliberately + out from Phase 1 onwards. + +## 2. What is already settled + +Carried forward unchanged from the Phase 8 spec and its ancestors: + +| Decision | Where from | +|---|---| +| The link is a capability, not a session. Rotation is the threat model. | Phase 8 §3 | +| The brief is re-parsed through `parseBrief`, which is a whitelist | Phase 4b | +| Column, priority, `accepted`, `created_by_role` are set by the server, never the body | Phase 1, Phase 4b | +| Submitted text is data, never instructions | Phase 1 + 3 preamble | +| The wizard is stateless — the transcript travels in the prompt | Phase 4 | +| Today's signed-in wizard does not change | Phase 8 / 8 / 8c | +| Phase 8's "minimal intake layout with no app shell" is the picker page's layout too | Phase 8 Task 9 | +| The PWA manifest, service worker, and iOS meta already cover any `/intake/*` URL | Phase 8 Tasks 3–5 | + +## 3. Threat model for the picker + +The Phase 8 threat model is the floor. Three Phase-9-specific concerns +sit on top of it; I'll name each and what we do about it. + +**Concern 1 — Listing too many projects.** +A picker that dumps every active project a client has leaks work the +client didn't know about. A picker with too few projects is useless. +Resolution: each project carries a `show_in_intake: bool` (default +`false`, owner opts in). The picker queries `project.active AND +project.show_in_intake` — never the wider set. A confidential project +("Acme Rebrand — pre-announce") simply isn't in the list until the +owner says so. + +**Concern 2 — Cross-client leakage through the picker.** +The token resolves to one client. The picker lists *that client's* +projects. Cross-client is structurally impossible because the picker +reads projects through the link's `client`, not by enumerating all +projects. + +**Concern 3 — Enumeration via the picker.** +There is no "is this token valid?" probe because the picker is the +response to an unknown token: same 404 wording as the wizard in +Phase 8. The picker cannot be used to test whether a project name +exists on the platform. + +The Phase 8 controls carry forward unchanged: +- rate limit per `(token, ip)` +- daily cap (now per `(link, project)` — see §5) +- token shape check before any DB read +- 404 not 403 on bad tokens (same wording for unknown/revoked/expired) +- server-decides-fields (the body never sets `column`, `priority`, + `created_by_role`, etc.) +- `parseBrief` whitelist on the model's output + +The token is unchanged: 24 bytes CSPRNG → base64url → 32 chars. + +## 4. Data model + +Two schema changes, both additive. The Phase 8 `intake_links.project` +shape stays live alongside the new shape; the two are distinguished by +which field is populated on the row. + +### 4.1 `intake_links.client` (new required field on intake_links) + +Replace `intake_links.project` (Phase 8) with `intake_links.client` for +new rows. The schema definition in `scripts/setup-remote.ts` flips: + +```ts +// scripts/setup-remote.ts — intake_links collection declaration +fields: [ + { + name: "client", + type: "relation", + required: true, + collectionId: (await pb.collections.getOne("clients")).id, + cascadeDelete: true, + minSelect: 0, + maxSelect: 1, + }, + // project field is gone for new rows. Migration §10 keeps + // existing rows intact via a partial-index trick. + ... +], +indexes: [ + "CREATE UNIQUE INDEX idx_intake_links_token ON intake_links (token)", + "CREATE INDEX idx_intake_links_client ON intake_links (client, active)", +], +``` + +`project` becomes an *optional* field, kept on the collection for the +migration window (§10). New rows leave it empty. The TypeScript +`IntakeLinkRecord` becomes: + +```ts +export interface IntakeLinkRecord extends BaseRecord { + client: string; + /** Migration window only: projects this link binds to directly (Phase 8 shape). + * Empty on Phase 9 rows. The wizard reads projects via the client. */ + project: string; + /** The owner's name for this link, e.g. "Acme — till phone". */ + label: string; + token: string; + active: boolean; + expires_at: string; + max_submissions_per_day: number; + submission_count: number; + // submission_count_today and submission_count_today_date become + // per-project for Phase 9 — see §5. + ... +} +``` + +### 4.2 `projects.show_in_intake` (new optional bool) + +```ts +{ name: "show_in_intake", type: "bool", required: false } +``` + +Default `false`. Owner opts a project in by toggling it on the existing +project admin page (one new field on `BackgroundMdEditor`'s sibling +panel — same shape). + +### 4.3 API rules (intake_links) + +Same as Phase 8 — owner-only at the rule level. The wizard reads +through the superuser connection; the rule exists so a signed-in client +manager cannot enumerate links, same as before. + +### 4.4 Daily cap, per `(link, project)` + +Phase 8 tracked today's submissions on the link row itself: +`submission_count_today`, `submission_count_today_date`. With the new +per-client shape, that counter now means "across all projects under +this link" — which is wrong. One busy project could lock the others +out at the cap. + +Phase 9 splits it: the daily counter moves onto a *new* collection, +`intake_link_submissions`, one row per (link, project, date): + +```ts +{ + link: string; // intake_links.id + project: string; // project id + date: string; // YYYY-MM-DD + count: number; // submitted today +} +unique index: (link, project, date) +``` + +The wizard's daily-cap check (`isLinkUsable`) becomes: + +``` +cap = max_submissions_per_day || 100 +for the chosen project: count = today's row, or 0 +return count < cap +``` + +Existing per-project Phase 8 links keep using `submission_count_today` +on the link row for that one project — the field stays, the new +collection is additive for the new shape only. + +### 4.5 What the owner must opt + +Owner-side: + +1. On each project: flip `show_in_intake` to `true` for the projects + they want in the picker. Default off, so a freshly-created project + never appears in the picker until explicitly opted in. +2. On each client: mint at least one intake link from + `/admin/clients/[clientId]` (a new page) or via a "New link for this + client" button on an existing client-detail page. + +The first link minted under the new shape is effectively the default +"send a request" link. Owners can mint more if they want segmented +URLs (e.g. one per support channel), but most clients need one. + +## 5. Public route + +Two URLs, two steps, both unauthenticated and the same minimal +layout Phase 8 Task 9 wrote. + +### 5.1 `/intake/[token]` — the picker + +Server component. Resolves the link server-side (same `resolveIntakeLink` +as Phase 8 — it does not care whether the row carries `project` or +`client`). On unknown/revoked/expired, **404 with the same wording as +the wizard** so a hostile body cannot probe. + +On success, renders a project picker: + +- Reads every project on the link's client with + `active = true AND show_in_intake = true`. +- If zero or one project qualifies, the page skips the picker and + behaves like a per-project link: + - One project → server-side redirect to `/intake/[token]/p/[projectId]`. + - Zero projects → static "no projects enabled" page with the + developer's email or phone (also owner-controlled). +- Two or more → renders a small list (`
    ` of project names). + Each item is a `` to `/intake/[token]/p/[projectId]`. + +The picker is the only place a project is exposed to the public. No +list of clients, no list of all projects, no alphabetical sort key +that would let a hostile body infer project names by timing. + +### 5.2 `/intake/[token]/p/[projectId]` — the wizard + +Server component. Same as Phase 8's `/intake/[token]` page, except: + +- The token resolves to a link (same as Phase 8). +- The URL's `projectId` is verified to belong to the link's client + AND have `show_in_intake = true`. Mismatch → 404. +- The wizard runs with that `projectId`. + +`IntakeWizard` and `IntakePublic` already accept `projectId` as a +prop (Phase 4 / Phase 8). Nothing in the wizard itself changes. + +### 5.3 Caching and PWA + +The Phase 8 service worker already intercepts `/intake/*` HTML +network-first with an offline fallback. The picker and wizard URLs +both fall under that pattern. No SW changes needed; the cache key +naturally covers both paths. + +The manifest's `start_url` is unchanged (`/intake/`). A returning PWA +user lands on the picker first; selecting a project re-uses the +cached wizard HTML for that project if available. + +## 6. Server Action & submission route + +Both change shape to carry `projectId`. The change is *additive* to +the action's inputs — older fields stay for backwards compat with the +existing Phase 8 wizard code path which never sets `projectId` (the +link's `project` carries it). + +### 6.1 `intakeStepPublicAction` + +```ts +export async function intakeStepPublicAction(input: { + token: string; + projectId: string; // NEW: required on the new shape. Empty on the + // old shape, where the link's project fills in. + turns: Turn[]; + finishNow: boolean; +}): Promise; +``` + +Internally: + +1. Resolve `link` from token. +2. If `projectId` is provided, verify it belongs to `link.client` and + has `show_in_intake = true AND active = true`. +3. If `projectId` is empty, fall back to `link.project` (Phase 8 path). +4. Rate limit + daily cap (now per `(link, project)`) + token shape — + same shape check, same reasons. +5. `nextStep({ projectId, turns, finishNow })`. + +The `clientIpFromHeaders(next/headers())` call from the recent fix +(`b02ebb7`) carries over unchanged. + +### 6.2 `POST /api/public/intake` + +```ts +export async function POST(request: Request): Promise { + // Same body whitelisting + token-shape check + 404 handling as Phase 8. + // NEW: read `projectId` from the body; verify it against the link. + // The card-create call uses the verified projectId. +} +``` + +The integration tests from Phase 8 (`intake-submit-public.test.ts`) +exercise the Phase 8 path with `projectId` left empty. New Phase 9 +tests (file `intake-submit-picker.test.ts`) exercise the picker path +with both tokens and project ids on the wire. + +## 7. Owner UI + +### 7.1 Client admin page (new) + +`/admin/clients/[clientId]` currently exists? (No — Phase 8 only added +links at the project level.) Phase 9 adds: + +- A new route `/admin/clients/[clientId]` (server component, + owner-only via the existing admin layout). +- Reads the client plus the active projects on it (the owner's view of + what's deployable for intake purposes). +- Renders three panels stacked: + 1. **Public intake links** — the same `` from + Phase 8, but now `link.project` is always empty on new rows. + Mint / rotate / revoke / per-link daily cap. + 2. **Projects** — a list of the client's projects with a small + `` next to each (one toggle calls one new + Server Action per project). Disabled + active styled. Toggling + `show_in_intake` is two clicks, not three. + 3. *(Future)* a "client URL" badge — a copyable URL of the form + `https:///intake/` — landed on the page when at + least one active link and at least one opted-in project exist. + +The admin layout already enforces owner-only access; no extra guard +needed here. + +### 7.2 Project admin page (modify) + +`/admin/projects/[projectId]` already exists. One small addition: + +- A new `` rendered + in the existing admin page, just above the + ``. It calls a new Server Action + `setProjectShowInIntakeAction({ projectId, show })`. Owner-only. + +The Public-intake-links section on the project page stays (Phase 8 +shape: a per-project link can still be minted). It just becomes the +"legacy / experimental" path; the new client-level link is the +default. + +### 7.3 No new Server Actions beyond what is listed above + +- `setProjectShowInIntakeAction` (owner-only) +- The picker page itself needs no Server Action — it's a server + component that reads the database and renders. Submission still + goes through `intakeStepPublicAction` and `POST /api/public/intake`. + +## 8. Migration of existing Phase 8 tokens + +Phase 8 left any number of per-project tokens live in PocketBase. +Most owners will want to consolidate to a single per-client link, +but doing that automatically could change the URL a client already has. +**Phase 9 deliberately does not auto-rotate Phase 8 tokens.** They keep +working with the Phase 8 behaviour: the old URL still renders the +wizard, the wizard still runs against the link's `project`, the +existing rate limit + daily cap continue to apply on the link row. + +Owners can: + +- Leave them as-is indefinitely (they're still secure). +- Manually rotate them from the project admin page (which now reads + as "this is a Phase 8 shape; consider minting a Phase 9 link + from `/admin/clients/[clientId]` instead" but does not force it). +- The owner UI surfaces a per-link `created_via` field (added to + `intake_links`): + `"phase-8-project-link" | "phase-9-client-link"` set at insert + time. + +The choice not to auto-rotate is on purpose — a leaked Phase 8 link +must remain revokable through the same mechanism it was minted +through. + +## 9. Daily cap, per `(link, project)` + +Section 4.4 named the shape. Two operational consequences: + +1. **Existing `submission_count_today` on the link row stays.** It's + the canonical counter for *Phase 8* links. Phase 9 links use the + new `intake_link_submissions` collection for *their* cap. +2. **The UI shows per-project counts.** Where Phase 8 says `1 total`, + Phase 9 says `quotes=12, billing=3` — at most one per project the + client has, summed across the lifetime of the link. The picker + itself does NOT show today's counter to the public; the counter is + owner-side only. + +## 10. Plan split + +Three plans in the same format as Phase 4b / 8a / 8b / 8c. + +| Plan | Scope | Day-estimate | +|---|---|---| +| **9a — schema + per-project opt-in + owner toggle** | Add `intake_links.client` (optional, additive), add `projects.show_in_intake`, add `intake_link_submissions`, add migration seed; new ``; `setProjectShowInIntakeAction`. Daily-cap unit tests for the new per-(link, project) shape. | 1 day | +| **9b — picker page + route split + new owner page** | `/admin/clients/[clientId]` route + panel; `/intake/[token]` picker page; `/intake/[token]/p/[projectId]` wizard page; `intakeStepPublicAction` accepts `projectId`; `POST /api/public/intake` verifies it; `` takes a `projectId` from URL; client URL badge in owner UI. | 1 day | +| **9c — integration tests + verification** | `intake-submit-picker.test.ts` mirroring Phase 8 Task 17 shape; happy path, hostile project-id, revoked project, mid-cap; full migration smoke; updated old-test signatures if needed. | 1 day | + +Total: 3 working days. The Phase 8 architecture carries the load; the +new work is narrow. + +## 11. Acceptance criteria + +1. **Single-URL UX.** A client with one Phase 9 link and three projects + gets a picker page listing those three; selecting any one runs the + wizard against it. +2. **Opt-in is the default.** A newly-added project is NOT in the + picker until the owner flips `show_in_intate` to `true`. +3. **One URL per client.** `/admin/clients/[clientId]` shows every + link minted for that client, with mint / rotate / revoke / per-link + daily cap, same `IntakeLinksPanel` UI Phase 8 shipped. +4. **Worst a hostile body achieves** on the picker path matches the + Phase 8 invariant: a strangely-worded card in the Inbox of the + chosen project. `parseBrief` whitelists the output; server decides + `column`, `priority`, `accepted`, `created_by_role="anonymous"`, + `created_by=""`. +5. **Token shape check fires first.** A 32-char base64url for `token`, + a real project id for `projectId`. Anything else → 404, same wording. +6. **Daily cap is per `(link, project)`.** Two projects under the same + link each get their own 100/day budget; one doesn't lock the other. +7. **Phase 8 tokens keep working.** A pre-existing per-project link + (with `link.project` set) still resolves the Phase 8 way; the test + suite doesn't regress. +8. **The picker page is itself a static, public, signed-out route.** + No AppShell, no theme provider, the minimal layout Phase 8 wrote. +9. **The PWA service worker continues to cache `/intake/*`.** No + service-worker change; the picker is part of the same cache + allowlist. +10. **The signed-in wizard / signed-in route / signed-in owner UI + is unchanged.** Existing tests still pass; signed-in behaviour + does not regress. + +## 12. What this spec deliberately doesn't do + +- **Cross-client links.** A token still binds to exactly one client. + Multi-tenant intake is a different problem. +- **Recategorising links automatically.** No system process that + detects a Phase 8 link and "upgrades" it. The owner does the work + explicitly if they want; otherwise the old shape lives forever. +- **Inline client-name overrides.** The picker shows the project's + actual `name`. A future Phase 10 can add `client_display_name` to + `projects` for client-branded naming if it ever matters. +- **Renaming the old URL.** `/intake/[token]` with `project` on the + link still resolves to the project; the picker is only added to + client-shaped rows. There are no redirects to introduce. +- **Search / tag filtering in the picker.** Picker is alphabetical + by project name. If a client has 30 projects the picker is a long + list — that's their problem to solve (their team should be a smaller + list), not ours. +- **Branch-by-tenant analytics on intake.** One PocketBase, one + collection, one schema. Reporting on intake still happens through + the same dashboard phase 2 shipped. + +--- + +## 13. Definition of done + +1. `npm run test:all` passes: ~459 unit, ~75 integration (existing + Phase 8 set + the new picker path). +2. `npm run build` succeeds; colour check empty (modulo the spec-known + PWA themeColor metadata exception). +3. The full picker flow worked end-to-end in the developer's browser + before merge. +4. Phase 8 manual walkthrough steps 19.3–19.8 still pass. +5. Owner can opt a project in/out from `/admin/projects/[id]`. +6. A wiped-and-rebuilt dev PocketBase from this spec produces a + working picker with one seeded link and three opted-in projects. + +--- + +## 14. Deliberately not in scope for this spec + +The owner-side UX work — drag-and-drop reorder of links, link +notes/description editor, copy-URL-to-clipboard button on the client +admin page — is a Phase 9.5 task list. This spec closes on the +minimum surface that makes the picker flow work; everything else is +polish on top.