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 (`