Skip to main content

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.

onboarding-demo-handoff.ts · Verify · Muntin Ledger · Muntin