Three layers: a short note at the top, the key lines with our take in the middle, the full source at the bottom.
API route
onboarding-demo-handoff.ts
The demo-to-account handoff route. The only path your demo row can move to a real account is this one.
Repo path apps/api/src/routes/onboarding-demo-handoff.tsLanguage TypeScript
Short note — more on the way
What this is
The demo-to-account handoff route. The only path your demo row can move to a real account is this one.
What it proves
This file backs one or more of the privacy promises. It is a HTTP API route that lives versioned in the repository. Read the promise →
What to look for in the source below
- Comments and headers that name what each section does.
- File edges: imports at the top, exports or run-blocks at the bottom.
- Any list, configuration, or assertion that looks load-bearing.
Show the full file (362 lines)
361 lines
import { Hono } from "hono";
import type { Env } from "../env";
import { requireAuth, type AuthContext } from "../middleware/require-auth";
import { rateLimit } from "../middleware/rate-limit";
import { newId } from "../lib/id";
import { sendMagicLinkEmail } from "../lib/email";
import {
defaultExtractionsStore,
type ReviewEnvelope,
} from "../lib/extractions-store";
import { log } from "../lib/logger";
import { Invoice as InvoiceSchema, type Invoice } from "@muntin/schema";
/**
* Anonymous-demo → authenticated-ledger handoff (P1.5 / A8).
*
* The chef-owner just parsed a PDF on /demo, sees a real row, and
* clicks "Save this to a real ledger." She types her email; we
* stash the parsed invoice envelope under a one-time handoff
* token, mint a magic-link with `return_to=/inbox?welcome=1&
* handoff=<token>`, and email it. After she verifies, she lands
* on /inbox where a HandoffConfirmCard offers two equally-
* weighted buttons: "Add it to my ledger" / "Discard, start
* fresh." (Lens 05 §8 + §12 — no asymmetric UI weighting.)
*
* Privacy:
* - The handoff KV entry holds the parsed INVOICE ENVELOPE
* (vendor + total + line items + needs_review), NOT the
* original bytes. The anonymous demo never persisted bytes
* (check-demo-no-persistence.mjs invariant — preserved here).
* - 60-min TTL on the handoff KV entry. After expiry, the
* handoff token resolves to "expired" and the user can re-
* extract on /demo to start fresh.
* - Claim is consent-explicit POST-VERIFY: the user must be
* authenticated AND explicitly click "Add it" to create the
* extraction row. A silent claim is impossible.
*
* Sibling-file pattern: this file deliberately does NOT live
* inside apps/api/src/routes/onboarding.ts, which applies
* requireAuth on `*`. The mint endpoint here is anonymous; the
* claim/discard/GET endpoints are authenticated via per-route
* requireAuth middleware below.
*
* Cohort source: synthesis §4 P1.5 + Lens 05 §8; p1-plan A8.
*/
export const onboardingDemoHandoff = new Hono<{
Bindings: Env;
Variables: { auth: AuthContext };
}>();
const HANDOFF_TTL_SEC = 60 * 60; // 60 minutes
const MAGIC_LINK_RETURN_TO = "/inbox?welcome=1&handoff=";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
interface HandoffEntry {
email: string;
invoice: Invoice;
needs_review?: boolean;
needs_review_reasons?: string[];
locale: "en" | "es";
created_at: string;
}
/**
* POST /v1/onboarding/demo-handoff
*
* Anonymous. Mints a handoff token from the parsed invoice envelope
* the client received on /demo, stashes it in KV (60-min TTL), and
* emails a magic-link with return_to pointing at the handoff-aware
* inbox URL.
*
* Body:
* { email: string,
* invoice: <parsed-invoice-envelope from /v1/demo/extract>,
* needs_review?: boolean,
* needs_review_reasons?: string[],
* consent: true,
* locale?: "en" | "es" }
*/
// P3 C5: per-IP rate-limit on the mint endpoint only (the :token
// sub-routes are authed via requireAuth below). 10/min per IP is
// far above any honest flow (one mint per /demo session); shuts
// down a script that tries to enumerate handoff tokens or flood
// the magic-link mailer. No auth context here so only the IP
// probe fires.
onboardingDemoHandoff.post("/", rateLimit({ ip_per_minute: 10 }), async (c) => {
const env = c.env;
if (!env.AUTH_KV) {
return c.json({ ok: false, reason: "infrastructure_unavailable" }, 503);
}
let body: unknown;
try {
body = await c.req.json();
} catch {
return c.json({ ok: false, reason: "malformed_request" }, 400);
}
const b = body as {
email?: unknown;
invoice?: unknown;
needs_review?: unknown;
needs_review_reasons?: unknown;
consent?: unknown;
locale?: unknown;
};
// Consent must be explicit + literal true (not "true" string, not
// 1, not truthy). The empowerment frame is that she opts IN to
// sharing the parsed envelope with her future account — never the
// default.
if (b.consent !== true) {
return c.json({ ok: false, reason: "consent_required" }, 400);
}
const email = typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
if (!EMAIL_RE.test(email)) {
return c.json({ ok: false, reason: "invalid_email" }, 400);
}
// Validate the invoice envelope shape via the canonical
// InvoiceSchema. A malformed envelope (or an attacker posting
// garbage) gets a 400 with the schema reason.
const parsed = InvoiceSchema.safeParse(b.invoice);
if (!parsed.success) {
return c.json({ ok: false, reason: "invalid_invoice" }, 400);
}
const locale = b.locale === "es" ? "es" : "en";
const token = newId("hdf");
const entry: HandoffEntry = {
email,
invoice: parsed.data,
needs_review:
typeof b.needs_review === "boolean" ? b.needs_review : undefined,
needs_review_reasons: Array.isArray(b.needs_review_reasons)
? (b.needs_review_reasons as unknown[]).filter(
(r): r is string => typeof r === "string",
)
: undefined,
locale,
created_at: new Date().toISOString(),
};
await env.AUTH_KV.put(`demo:handoff:${token}`, JSON.stringify(entry), {
expirationTtl: HANDOFF_TTL_SEC,
});
// Mint magic-link inline using the same KV key shape as
// /v1/auth/email-link (see apps/api/src/routes/auth.ts:285-297).
const magicLinkToken = newId("mlt");
const magicTtl = Number(env.MAGIC_LINK_TTL_SECONDS) || 900;
const returnTo = `${MAGIC_LINK_RETURN_TO}${token}`;
await env.AUTH_KV.put(
`magic:${magicLinkToken}`,
JSON.stringify({
email,
residency_region: "us-east",
return_to: returnTo,
created_at: new Date().toISOString(),
}),
{ expirationTtl: magicTtl },
);
// Send the magic-link email. Resend dev-mode returns the link in
// the result for local testing; production delivers via DKIM. The
// handoff form already resolved the visitor's locale; reuse it so
// the magic-link email lands in the same language as the demo.
const sendResult = await sendMagicLinkEmail(
env,
email,
magicLinkToken,
locale,
);
// Funnel event — privacy-clean (no email, no token, just a
// boolean indicating whether a parsed envelope was present).
log(env, {
event: "funnel.demo_handoff_email",
level: "info",
fields: {
has_demo_extraction: true,
locale,
sent: sendResult.delivered,
},
});
// Return ok + the devLink (when present) so local testing can
// bypass the mail client. In production sendResult.devLink is
// undefined.
return c.json({
ok: true,
dev_link: sendResult.devLink,
});
});
// ---- AUTHENTICATED endpoints below ----
//
// GET /:token, POST /:token/claim, POST /:token/discard all require
// the user to be signed in (they verified the magic-link). Apply
// requireAuth to these paths only — the mint endpoint above is
// anonymous by design.
onboardingDemoHandoff.use("/:token/*", requireAuth);
onboardingDemoHandoff.use("/:token", requireAuth);
/**
* GET /v1/onboarding/demo-handoff/:token
*
* Read the pending handoff (for the HandoffConfirmCard to render).
* Requires auth. The handoff token is opaque + random (newId
* "hdf"), so possession of a valid auth session + the token URL
* is the credential. We do NOT bind the handoff to a user_id at
* mint time because the user has no account yet; binding happens
* at claim time (the org_id of the verifying user receives the
* row).
*/
onboardingDemoHandoff.get("/:token", async (c) => {
const env = c.env;
const token = c.req.param("token");
const raw = await env.AUTH_KV.get(`demo:handoff:${token}`);
if (!raw) {
return c.json({ ok: false, reason: "expired_or_unknown" }, 404);
}
let entry: HandoffEntry;
try {
entry = JSON.parse(raw) as HandoffEntry;
} catch {
return c.json({ ok: false, reason: "corrupted" }, 500);
}
return c.json({
ok: true,
handoff: {
invoice: entry.invoice,
needs_review: entry.needs_review,
needs_review_reasons: entry.needs_review_reasons,
created_at: entry.created_at,
},
});
});
/**
* POST /v1/onboarding/demo-handoff/:token/claim
*
* Adds the parsed invoice envelope to the user's ledger as a real
* extraction row. The KV entry is deleted on success (no replay).
*/
onboardingDemoHandoff.post("/:token/claim", async (c) => {
const env = c.env;
const auth = c.get("auth") as AuthContext;
const token = c.req.param("token");
const raw = await env.AUTH_KV.get(`demo:handoff:${token}`);
if (!raw) {
return c.json({ ok: false, reason: "expired_or_unknown" }, 404);
}
let entry: HandoffEntry;
try {
entry = JSON.parse(raw) as HandoffEntry;
} catch {
return c.json({ ok: false, reason: "corrupted" }, 500);
}
// Build the extraction-store input. The document_id is synthetic
// (no R2 row; this is a handoff, not an upload), prefixed so a
// future audit can identify the origin.
const documentId = `demo_handoff_${token}`;
// ReviewEnvelope wants six fields. The handoff stored only the
// two the operator sees; the others default to safe values (no
// reconciliation residual, no drift, no EIN disagreement, no
// OCR-repair candidates — the demo handoff path doesn't carry
// UR3-11 typed-sidecar entries).
const needsReview = entry.needs_review ?? false;
const reviewEnvelope: ReviewEnvelope = {
needs_review: needsReview,
needs_review_reasons: entry.needs_review_reasons ?? [],
reconciliation_residual_cents: 0,
drift_reason: "none",
ein_name_disagreement: false,
ocr_repair_candidates: [],
// T0-2: the demo handoff path produces happy-path invoices only;
// never a rejection-class document.
rejected: false,
rejection_reason: null,
doc_type: "invoice",
// T1-3: demo handoffs are operator-typed manual entries; never
// an engine-validated auto-confirm candidate.
auto_confirmed: false,
// T2-2: severity follows needs_review on the demo path since
// there's no engine-derived signal to discriminate hard vs soft.
severity: needsReview ? "hard_block" : "clean",
// T3-3: demo handoffs are operator-typed manual entries; the
// statement-with-multi-invoice split path can't trigger here so
// splittable stays false.
splittable: false,
};
try {
const store = defaultExtractionsStore(env);
await store.insert({
org_id: auth.org_id,
document_id: documentId,
invoice: entry.invoice,
review: reviewEnvelope,
});
} catch (err) {
log(env, {
event: "funnel.handoff_claim_failed",
level: "error",
fields: {
reason: err instanceof Error ? err.message.slice(0, 80) : "unknown",
},
});
return c.json({ ok: false, reason: "store_failed" }, 500);
}
// Delete the KV entry — no replay.
await env.AUTH_KV.delete(`demo:handoff:${token}`);
log(env, {
event: "funnel.handoff_claimed",
level: "info",
fields: { locale: entry.locale },
});
return c.json({ ok: true, document_id: documentId });
});
/**
* POST /v1/onboarding/demo-handoff/:token/discard
*
* Deletes the KV entry; no row is created. Returns ok regardless
* of whether the entry existed (idempotent).
*/
onboardingDemoHandoff.post("/:token/discard", async (c) => {
const env = c.env;
const token = c.req.param("token");
const raw = await env.AUTH_KV.get(`demo:handoff:${token}`);
if (raw) {
let entry: HandoffEntry | null = null;
try {
entry = JSON.parse(raw) as HandoffEntry;
} catch {
entry = null;
}
await env.AUTH_KV.delete(`demo:handoff:${token}`);
if (entry) {
log(env, {
event: "funnel.handoff_discarded",
level: "info",
fields: { locale: entry.locale },
});
}
}
return c.json({ ok: true });
});This is the file as it lives at the moment of this build. The canonical history lives in git. If you want the full history or a specific commit, write to hello@muntin.digital.