Demo Handoff — Anonymous → Authenticated Flow
Route: apps/api/src/routes/onboarding-demo-handoff.ts Promise: /promises#handoff Date: 2026-05-21 (lands with P1 A8)
The chef-owner parses a PDF on /demo, sees a real row, types her email, and clicks "Send sign-in link." This document is the operational record of what happens between that click and the row appearing in her ledger.
§1 — The four steps
- Mint —
POST /v1/onboarding/demo-handoff(anonymous).
The DemoClient posts { email, invoice, needs_review, needs_review_reasons, consent: true, locale }. The route stashes the parsed envelope under a one-time handoff token in KV (demo:handoff:<token>, 60-min TTL). Then mints a magic-link token (magic:<mlt>, MAGIC_LINK_TTL_SECONDS) with return_to=/inbox?welcome=1&handoff=<handoff_token> and emails it via Resend.
- Verify — chef-owner taps the link in her email. The magic-link
verify route (existing /v1/auth/verify flow) signs her in and 302s her to the return_to path. She lands on /inbox?welcome=1&handoff=<token>.
- Confirm — the inbox page reads the
handoffquery, renders
HandoffConfirmCard. The card fetches GET /v1/onboarding/demo-handoff/<token> (authenticated) to display the pending parsed row. Two equally- weighted buttons (Lens 05 §12 — no asymmetric UI weighting):
- "Add it to my ledger" → POST /v1/onboarding/demo-handoff/<token>/claim - "Discard, start fresh" → POST /v1/onboarding/demo-handoff/<token>/discard
- Resolve — claim creates a real extraction row in her org
(synthetic document_id = demo_handoff_<token>, the parsed invoice envelope, the safe-defaulted ReviewEnvelope). Discard deletes the KV entry without creating a row. Either action deletes the KV entry — no replay.
§2 — Privacy invariants
- No durable storage of file bytes. The
/v1/demo/extractroute
already enforces this via check-demo-no-persistence.mjs. The handoff KV entry holds the parsed INVOICE ENVELOPE, NOT the bytes. The original PDF was discarded after extraction.
- 60-minute TTL on the KV entry. After expiry, the handoff
token resolves to 404; she can re-extract on /demo to start fresh.
- Explicit consent at mint AND claim. The mint endpoint
requires consent === true (literal, not truthy) in the request body. The claim endpoint requires an explicit POST from a signed-in user. No silent claim.
- No PII in funnel events. Three funnel events emit during
the flow:
- funnel.demo_handoff_email — { has_demo_extraction, locale, sent } - funnel.handoff_claimed — { locale } - funnel.handoff_discarded — { locale }
None carry the email, the token, the vendor name, or the parsed invoice content. (The check-funnel-no-pii.mjs gate lands in P1 A14 and will lock this in.)
§3 — Threat model
| Threat | Mitigation |
|---|---|
| Spam (mass-mint attack on the endpoint) | Same INGEST_RATELIMIT + per-IP keyed throttle as the demo route. (Implementation TODO: extend the gate in a follow-up if the unauth endpoint exposure attracts traffic.) |
| Fabricated invoice envelopes | The mint endpoint validates the envelope via the canonical @muntin/schema InvoiceSchema. Malformed payloads → 400. A well-formed but fabricated envelope can be minted, but it lands in the attacker's OWN ledger only — they have to verify their own magic-link to claim. No cross-tenant leak. |
| Token leak via forwarded magic-link | The token is opaque + random + one-time + 60-min TTL. The magic-link itself is the protection layer; if it leaks, the attacker can already sign in as the legitimate user. The handoff adds no additional surface. |
| Replay (double-claim) | Claim deletes the KV entry on success. A second claim returns 404. |
| Discard-then-claim race | Discard deletes the KV entry; a subsequent claim returns 404. |
§4 — How to verify yourself
```bash
Mint
curl -X POST $API/v1/onboarding/demo-handoff \ -H "content-type: application/json" \ -d '{"email":"test@example.com","invoice":{...},"consent":true}'
→ { "ok": true, "dev_link": "http://localhost:8787/v1/auth/verify?t=mlt_..." }
Tap the dev_link in a browser; you should land on /inbox?welcome=1&handoff=hdf_...
In an authenticated session:
curl $API/v1/onboarding/demo-handoff/hdf_xxx \ -H "cookie: muntin_session=..."
→ { "ok": true, "handoff": { "invoice": {...} } }
Claim:
curl -X POST $API/v1/onboarding/demo-handoff/hdf_xxx/claim \ -H "cookie: muntin_session=..."
→ { "ok": true, "document_id": "demo_handoff_hdf_xxx" }
A second claim:
curl -X POST $API/v1/onboarding/demo-handoff/hdf_xxx/claim
→ 404 expired_or_unknown
```
§5 — What to do if the flow breaks
If a regression introduces silent claim (the card auto-claims without a button click) or persistence (the handoff KV entry holds bytes, not just the envelope), the recovery procedure is the same as the demo route's:
- Flip
DEMO_ANONYMOUS_EXTRACT=offto drain/demoso no new
handoffs are minted.
- Audit the regression. Revert.
- Publish a post-mortem on
/promises#handoffwith the affected
window.
The mint endpoint itself is reachable independent of /demo, so disabling the demo route does NOT disable handoff mint. If the regression is in the handoff route, an additional kill switch (HANDOFF_MINT=off, future) would isolate just that path.
§6 — Open items (P1 debt → P2)
- Per-IP rate-limit on the mint endpoint. Reuse
INGEST_RATELIMIT to cap mints/IP/day. Deferred to a P2 tightening.
- Email-suppression check on mint. Currently we ALWAYS attempt
to send. The email-suppression module exists and could short-circuit mints to suppressed addresses. Deferred.
- Localised expired-token landing page. A direct hit on
/inbox?handoff=hdf_expired after the TTL shows the HandoffConfirmCard's expired-state copy via the existing confirmCardExpired key — this works today but does not have a localised "go back to /demo" link. Polish in P2.