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

auth.ts

The auth route — magic-link sign-in. No passwords, no SMS, no third-party identity providers.

Repo path apps/api/src/routes/auth.tsLanguage TypeScript

What this is

The code that handles sign-in. It accepts an email address, generates a one-time link that expires in 15 minutes, and emails the link to that address. There are no passwords; there is no third-party identity provider; the visitor's session begins only when they click the link in their own inbox.

What it proves

Backs the promise that signing out really signs out. The same route that mints a session also revokes it on demand; revoked sessions cannot mint new access tokens. Read the promise →

What to look for in the source below

  • A 15-minute expiry on every link — codified, not configurable per request.
  • Rate limits per IP and per email — the file imports a rate-limiter and uses it on this endpoint.
  • No password field anywhere in the route's input schema.
Show the full file (2110 lines)

2109 lines

import { Hono } from "hono";
import type { Context } from "hono";
import { setCookie, getCookie, deleteCookie } from "hono/cookie";
import type { Env } from "../env";
import {
  signJwt,
  verifyJwt,
  sha256HmacHexOf,
  newJti,
  JWT_AUDIENCE_SESSION,
  JWT_ISSUER_DEFAULT,
} from "../lib/auth";
import { rateLimit, rateLimitEmail } from "../middleware/rate-limit";
import { sendMagicLinkEmail } from "../lib/email";
import { log } from "../lib/logger";
import { isProductionEnv } from "../lib/is-production-env";
import { newId } from "../lib/id";
import {
  assertSignInAllowed,
  isSignInAllowed,
  SignInNotAllowedError,
} from "../lib/signin-allowlist";
import { D1AuditStore } from "../lib/audit-d1";
import { recordAuditEvent } from "../lib/audit";
import { defaultSessionsStore } from "../lib/sessions-store";
import { requireAuth, type AuthContext } from "../middleware/require-auth";
import { CSRF_COOKIE_NAME, generateCsrfToken } from "../middleware/csrf";
import { isSupportedRegion, regionUnsupportedBody } from "../lib/region-router";
import { resolvePasskeyEnrolmentPolicy } from "../lib/passkey-tier-policy";
import { defaultPasskeysStore } from "../lib/passkeys-store";
import { defaultPasswordsStore } from "../lib/passwords-store";
import {
  PasswordCapacityError,
  hashPassword,
  sentinelHash,
  verifyPassword,
} from "../lib/passwords";
import {
  PASSWORD_HISTORY_DEPTH,
  PASSWORD_LOCKOUT_DURATION_MS,
  PASSWORD_LOCKOUT_THRESHOLD,
  PASSWORD_LOCKOUT_WINDOW_MS,
  validatePassword,
} from "../lib/passwords-policy";

export const auth = new Hono<{ Bindings: Env }>();

// Security audit MED: in production the session/refresh/csrf cookies
// MUST carry an explicit Domain attribute so a sign-out on one
// subdomain (api.muntin.digital) drops them cleanly from every host
// under the apex (app.muntin.digital, muntin.digital). Without
// SESSION_COOKIE_DOMAIN set, residual host-only cookies linger on a
// shared browser and the next user lands in a half-authenticated
// state. Logged once per Worker isolate when an APP_ORIGIN that looks
// like prod (muntin.digital) is paired with an unset domain.
let cookieDomainWarned = false;
function maybeWarnCookieDomain(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
): void {
  if (cookieDomainWarned) return;
  if (isProductionEnv(c.env) && !c.env.SESSION_COOKIE_DOMAIN) {
    log(c.env, {
      event: "auth.session_cookie_domain_unset",
      level: "warn",
      fields: {
        app_origin: c.env.APP_ORIGIN ?? "",
        muntin_env: c.env.MUNTIN_ENV ?? "",
      },
    });
    cookieDomainWarned = true;
  }
}

const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
// Defensive body-length caps. RFC 5321 puts the mailbox max at 256
// (320 with quoted) but no honest customer uses that. Cap at 320 to
// match the RFC ceiling without false-positive rejection. Password
// upper bound mirrors PASSWORD_MAX_LEN in passwords-policy.ts —
// duplicated here so the auth route doesn't import the policy module
// solely for that constant.
const EMAIL_MAX_LEN = 320;
const PASSWORD_BODY_MAX_LEN = 256;
const RESET_TOKEN_RE = /^prt_[A-Za-z0-9_-]{1,64}$/;
const COOKIE_NAME = "muntin_session";
const REFRESH_COOKIE_NAME = "muntin_refresh";
const DEFAULT_ACCESS_TTL = 15 * 60; // 15 minutes (B-priv-1)
const DEFAULT_REFRESH_TTL = 30 * 24 * 60 * 60; // 30 days (B-priv-1)
const DEFAULT_KID = "v1";

function ttlAccess(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
): number {
  return Number(c.env.ACCESS_TOKEN_TTL_SECONDS) || DEFAULT_ACCESS_TTL;
}

function ttlRefresh(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
): number {
  return Number(c.env.REFRESH_TOKEN_TTL_SECONDS) || DEFAULT_REFRESH_TTL;
}

function kidFor(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
): string {
  return c.env.JWT_KEY_KID || DEFAULT_KID;
}

function issuerFor(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
): string {
  return c.env.JWT_ISSUER || JWT_ISSUER_DEFAULT;
}

function sharedCookieOpts(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
  ttl: number,
  sameSite: "Strict" | "Lax",
): {
  secure: true;
  sameSite: "Strict" | "Lax";
  maxAge: number;
  path: "/";
  domain?: string;
} {
  const domain = c.env.SESSION_COOKIE_DOMAIN;
  return {
    secure: true,
    sameSite,
    maxAge: ttl,
    path: "/",
    ...(domain ? { domain } : {}),
  };
}

/**
 * Issue or refresh the shared muntin.digital session cookie.
 *
 * In production SESSION_COOKIE_DOMAIN is "muntin.digital" so the
 * cookie is honoured on every host under the apex (covers both
 * muntin.digital/ledger and ledger.muntin.digital). In dev the
 * variable is unset and the cookie stays host-only.
 *
 * SameSite policy (the B-priv-2 Strict hardening, refined for the
 * magic-link landing):
 *
 *  - muntin_session = **Lax**. A magic-link click originates in
 *    webmail (cross-site, e.g. mail.google.com). The whole top-level
 *    navigation chain — verify → 302 → app.muntin.digital/today — is
 *    cross-site-INITIATED, so a Strict session cookie, though stored,
 *    is WITHHELD on the redirected /today GET and the first
 *    authenticated paint fails (user bounced back to sign-in). Lax IS
 *    sent on top-level GET navigations (the canonical Lax carve-out),
 *    which is exactly and only what the landing needs.
 *  - muntin_refresh = **Strict**. Only ever sent to /v1/auth/refresh,
 *    a same-site SPA POST; it never participates in a cross-site
 *    top-level navigation, so it keeps the tighter setting.
 *  - muntin_csrf = **Strict**. The double-submit defense depends on a
 *    cross-site origin being unable to auto-send or read it.
 *
 * Loosening only the session cookie does NOT reopen the B-priv-2
 * CSRF surface: state-changing POST/PUT/DELETE/PATCH still require
 * the double-submit CSRF token (apps/api/src/middleware/csrf.ts),
 * and a Lax cookie is not sent on cross-site POSTs anyway.
 */
function issueSessionCookie(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
  jwt: string,
  ttl: number,
): void {
  setCookie(c, COOKIE_NAME, jwt, {
    httpOnly: true,
    ...sharedCookieOpts(c, ttl, "Lax"),
  });
}

function issueRefreshCookie(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
  jwt: string,
  ttl: number,
): void {
  setCookie(c, REFRESH_COOKIE_NAME, jwt, {
    httpOnly: true,
    ...sharedCookieOpts(c, ttl, "Strict"),
  });
}

function issueCsrfCookie(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
  ttl: number,
): void {
  setCookie(c, CSRF_COOKIE_NAME, generateCsrfToken(), {
    httpOnly: false,
    ...sharedCookieOpts(c, ttl, "Strict"),
  });
}

function ensureCsrfCookie(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
  ttl: number,
): void {
  if (!getCookie(c, CSRF_COOKIE_NAME)) issueCsrfCookie(c, ttl);
}

/**
 * Mint an access + refresh JWT pair, record the session row, and
 * set all three cookies (access + refresh + csrf). Called from
 * /verify (fresh sign-in) and /refresh (post-rotation).
 *
 * device_fingerprint is SHA-256 of the User-Agent header. Not
 * IP-tracking; just enough to label "this device's session" in
 * the future /settings/security UI (H-priv-5).
 */
async function mintAndPersistSession(
  c:
    | Context<{ Bindings: Env; Variables: Record<string, unknown> }>
    | Context<{ Bindings: Env }>,
  user: { id: string; org_id: string },
  email: string,
  jti: string,
  // Wave D.6: stamp the originating auth method into both JWTs so
  // /refresh can enforce the accountant passkey floor across
  // rotations. Defaults to "magic" (the fail-closed value for the
  // accountant tier when the origin is unknown / pre-D.6).
  // UR auth-2: "password" added — also non-passkey, also rejected by
  // the accountant /refresh gate. The /v1/auth/password sign-in
  // handler refuses for accountant tier upfront so this codepath
  // never mints a password session for that tier.
  authMethod: "magic" | "passkey" | "password" = "magic",
): Promise<{ accessJwt: string; refreshJwt: string }> {
  const nowSec = Math.floor(Date.now() / 1000);
  const accessTtl = ttlAccess(c);
  const refreshTtl = ttlRefresh(c);
  const kid = kidFor(c);
  const iss = issuerFor(c);

  const accessJwt = await signJwt(
    {
      sub: user.id,
      org_id: user.org_id,
      email,
      iat: nowSec,
      exp: nowSec + accessTtl,
      jti,
      kid,
      token_use: "access",
      amr: authMethod,
      aud: JWT_AUDIENCE_SESSION,
      iss,
    },
    c.env.JWT_SECRET,
    { kid },
  );
  const refreshJwt = await signJwt(
    {
      sub: user.id,
      org_id: user.org_id,
      email,
      iat: nowSec,
      exp: nowSec + refreshTtl,
      jti,
      kid,
      token_use: "refresh",
      amr: authMethod,
      aud: JWT_AUDIENCE_SESSION,
      iss,
    },
    c.env.JWT_SECRET,
    { kid },
  );

  issueSessionCookie(c, accessJwt, accessTtl);
  issueRefreshCookie(c, refreshJwt, refreshTtl);
  issueCsrfCookie(c, refreshTtl);

  const userAgent = c.req.header("user-agent") ?? "";
  // Privacy-regression auditor BLOCKER #3: HMAC-keyed fingerprint
  // so an operator with DB read access cannot rainbow-table back
  // to the originating UA. Falls back to JWT_SECRET when the
  // dedicated SESSION_FINGERPRINT_SECRET is not yet provisioned;
  // both are server-side-only secrets so the property holds in
  // either case.
  const fingerprintSecret =
    c.env.SESSION_FINGERPRINT_SECRET || c.env.JWT_SECRET;
  const fingerprint = await sha256HmacHexOf(fingerprintSecret, userAgent);

  const sessions = defaultSessionsStore(c.env);
  await sessions.insert({
    id: jti,
    user_id: user.id,
    org_id: user.org_id,
    kid,
    device_fingerprint: fingerprint,
    device_label: null,
    access_expires_at: new Date((nowSec + accessTtl) * 1000).toISOString(),
    refresh_expires_at: new Date((nowSec + refreshTtl) * 1000).toISOString(),
    created_at: new Date(nowSec * 1000).toISOString(),
  });

  return { accessJwt, refreshJwt };
}

/**
 * Lens 11 P1: validate a `return_to` path before stashing it in the
 * magic-link KV blob. Open-redirect prevention.
 *
 * Accepts only path-rooted local URLs:
 *   - must START with "/"
 *   - must NOT start with "//" (would be protocol-relative → external)
 *   - must NOT contain "://" (external URL embedded after a /)
 *   - must NOT contain "\\" (backslash; some browsers normalise this)
 *
 * Returns the cleaned path, or null when the input fails any check.
 * Null causes the verify callback to fall through to the default
 * landing page (/inbox?welcome=1 for new users, /today for returning).
 */
function sanitizeReturnTo(raw: unknown): string | null {
  if (typeof raw !== "string") return null;
  const trimmed = raw.trim();
  if (!trimmed) return null;
  if (!trimmed.startsWith("/")) return null;
  if (trimmed.startsWith("//")) return null;
  if (trimmed.includes("://")) return null;
  if (trimmed.includes("\\")) return null;
  // Cap the length to bound abusive cookie / KV payloads.
  if (trimmed.length > 256) return null;
  return trimmed;
}

/**
 * POST /v1/auth/lookup — methods-available probe (UR auth-2).
 *
 * The unified front door asks "what sign-in methods are on file for
 * this email?" so the UI can branch — auto-prompt the passkey, show
 * the password field, or fall through to magic-link. The response
 * shape is CONSTANT regardless of whether the email is registered:
 *   { has_passkey: bool, has_password: bool, tier: "..." }
 *
 * Enumeration defence: for an unregistered email the response is
 *   { has_passkey: false, has_password: false, tier: "unknown" }
 * which is indistinguishable from a registered user who happens to
 * have no methods enrolled (they came in via magic-link only). The
 * UI surfaces magic-link as the always-available path regardless.
 *
 * The endpoint deliberately does NOT confirm or deny existence in any
 * error code or timing channel — body shape is fixed and rate-limit
 * caps both per-IP (10/min) and per-email (5/min) attempts.
 *
 * Accountant-tier disclosure: tier is returned as "accountant" only
 * for accounts with an active accountant subscription. This is NOT
 * an enumeration leak — knowing a firm uses Muntin is not sensitive,
 * and the UI needs the hint to render the passkey-required messaging.
 */
auth.post("/lookup", rateLimit({ ip_per_minute: 10 }), async (c) => {
  let body: unknown;
  try {
    body = await c.req.json();
  } catch {
    return c.json({ error: "invalid json" }, 400);
  }
  if (
    !body ||
    typeof body !== "object" ||
    typeof (body as { email?: unknown }).email !== "string" ||
    (body as { email: string }).email.length > EMAIL_MAX_LEN
  ) {
    return c.json({ error: "email required" }, 400);
  }
  const email = (body as { email: string }).email.toLowerCase().trim();
  if (!EMAIL_RE.test(email)) {
    return c.json({ error: "invalid email" }, 400);
  }

  // Per-email cap so a single address can't be probed for methods at
  // industrial scale from rotating IPs.
  const emailCap = await rateLimitEmail(c.env, email, 5);
  if (!emailCap.allowed) {
    c.header("Retry-After", String(emailCap.retry_after_seconds));
    return c.json({ error: "rate_limited", scope: "email" }, 429);
  }

  const unknownShape = {
    has_passkey: false,
    has_password: false,
    tier: "unknown",
  } as const;

  if (!c.env.DB) return c.json(unknownShape);

  const userRow = await c.env.DB.prepare(
    "SELECT id, org_id FROM users WHERE email = ?",
  )
    .bind(email)
    .first<{ id: string; org_id: string }>();

  // SECURITY HIGH-01 audit (architect pass): the previous shape
  // returned `unknownShape` immediately for an unregistered email,
  // skipping the three downstream D1 queries the registered path
  // runs. That ~30–80 ms timing delta was enough for a competitor
  // to enumerate the customer base by bucketing response times.
  // Equalise by ALWAYS running the same three queries against a
  // fixed sentinel user/org id when the row is missing. The sentinel
  // queries return null/empty (no real row exists), so the response
  // shape is correct; the timing is what we are buying.
  const lookupUserId = userRow?.id ?? "usr_unknown_sentinel";
  const lookupOrgId = userRow?.org_id ?? "org_unknown_sentinel";

  let tier = "owner";
  try {
    const policy = await resolvePasskeyEnrolmentPolicy(c.env, lookupOrgId);
    // Only ADOPT the resolved tier when the user actually exists;
    // otherwise the policy lookup result is discarded so unknown
    // emails always report tier="unknown".
    if (userRow) tier = policy.tier;
  } catch {
    /* tier stays default; UI degrades gracefully */
  }

  const [passkeys, password] = await Promise.all([
    defaultPasskeysStore(c.env)
      .listActivePasskeysForUser(lookupUserId)
      .catch(() => []),
    defaultPasswordsStore(c.env)
      .find(lookupUserId)
      .catch(() => null),
  ]);

  if (!userRow) return c.json(unknownShape);

  return c.json({
    has_passkey: passkeys.length > 0,
    has_password: password !== null,
    tier,
  });
});

// Security audit MED: per-IP rate limits on auth endpoints. The
// middleware fails open when INGEST_RATELIMIT is unbound (dev/test),
// so existing test fixtures are unaffected. Magic-link gets an extra
// per-email probe inside the handler (after the email is parsed) so a
// botnet spraying one address across many IPs still trips the cap.
auth.post("/magic-link", rateLimit({ ip_per_minute: 10 }), async (c) => {
  maybeWarnCookieDomain(c);
  let body: unknown;
  try {
    body = await c.req.json();
  } catch {
    return c.json({ error: "invalid json" }, 400);
  }
  if (
    !body ||
    typeof body !== "object" ||
    typeof (body as { email?: unknown }).email !== "string"
  ) {
    return c.json({ error: "email required" }, 400);
  }
  const email = (body as { email: string }).email.toLowerCase().trim();
  if (!EMAIL_RE.test(email)) {
    return c.json({ error: "invalid email" }, 400);
  }

  // Pre-launch (replan §B): sign-in is closed to the SIGNIN_ALLOWLIST.
  // Return the SAME uniform 202 as success WITHOUT minting a token or
  // sending an email — so we neither sign a stranger in nor expose
  // account existence. No-op (open) when SIGNIN_ALLOWLIST is unset, so
  // launch reopens public sign-in with one env change.
  if (!isSignInAllowed(c.env, email)) {
    return c.json({ status: "sent" }, 202);
  }

  // Per-email cap: a single address may only request 5 magic links
  // per minute regardless of source IP. Blocks email-enumeration
  // sprays where the attacker rotates IPs but keeps spamming the same
  // address. Fails open if INGEST_RATELIMIT is unbound (dev / test).
  const emailCap = await rateLimitEmail(c.env, email, 5);
  if (!emailCap.allowed) {
    c.header("Retry-After", String(emailCap.retry_after_seconds));
    return c.json(
      {
        error: "rate_limited",
        detail: `magic-link per-email cap exceeded; retry in ${emailCap.retry_after_seconds}s`,
        scope: "email",
      },
      429,
    );
  }

  // H-priv-7 + H-comp-1: residency-region refusal at signup. The
  // orgs table has carried `residency_region` since the first D1
  // migration but nothing READ the column until now. Reject any
  // non-supported region with HTTP 451 (RFC 7725, Unavailable For
  // Legal Reasons) -- the refusal is jurisdiction-specific, not a
  // request-shape error, so 400 would be wrong and 403 would imply
  // an authz problem. See apps/api/src/lib/region-router.ts.
  const rawRegion = (body as { region?: unknown }).region;
  const requestedRegion =
    typeof rawRegion === "string" && rawRegion.length > 0
      ? rawRegion
      : "us-east";
  if (!isSupportedRegion(requestedRegion)) {
    return c.json(regionUnsupportedBody(requestedRegion), 451);
  }

  // Lens 11 P1 fix: capture the operator's intended landing path so
  // the magic-link callback can redirect them there instead of
  // dropping them on /today. Pre-fix, "open invoice X" emails
  // always landed on /today because the `return` query was lost
  // between the sign-in form and the /verify callback.
  //
  // Only path-rooted local URLs are accepted (defence-in-depth
  // against an open-redirect via a poisoned email). Anything that
  // doesn't start with "/" — or contains "://", "\\", or starts
  // with "//" — is dropped silently and the redirect falls back
  // to the default /today / /inbox?welcome=1.
  const rawReturnTo = (body as { return_to?: unknown }).return_to;
  const returnTo = sanitizeReturnTo(rawReturnTo);

  // Locale for the email body. The web sign-in action resolves this
  // server-side from the muntin_locale cookie + Accept-Language and
  // forwards it; "es" → Spanish copy, anything else → English. The
  // sender stream and Reply-To are locale-agnostic.
  const rawLocale = (body as { locale?: unknown }).locale;
  const emailLocale: "en" | "es" = rawLocale === "es" ? "es" : "en";

  const token = newId("mlt");
  const ttl = Number(c.env.MAGIC_LINK_TTL_SECONDS) || 900;

  await c.env.AUTH_KV.put(
    `magic:${token}`,
    JSON.stringify({
      email,
      residency_region: requestedRegion,
      return_to: returnTo,
      created_at: new Date().toISOString(),
    }),
    { expirationTtl: ttl },
  );

  const result = await sendMagicLinkEmail(c.env, email, token, emailLocale);

  // Funnel instrumentation (red-team: without it the founder is
  // blind to which on-ramp cliff is real). Content-only, privacy-
  // safe by construction: NO email, NO ip, NO user-agent, NO user
  // id — just the boundary crossing + whether the provider accepted
  // the send. Counts come from the existing structured-log drain;
  // this introduces zero client tracking and no new infra, and is
  // consistent with the no-behavioural-tracking promise. Pairs with
  // funnel.verify_ok to give the sent→activated conversion.
  log(c.env, {
    event: "funnel.magic_link_sent",
    level: "info",
    fields: { delivered: result.delivered },
  });

  // Always 202 -- the existence/non-existence of an account is not a
  // signal we expose. devLink is included only in dev mode (no real
  // RESEND_API_KEY); production never returns it.
  return c.json(
    {
      status: "sent",
      ...(result.devLink ? { dev_link: result.devLink } : {}),
    },
    202,
  );
});

interface MagicTokenPayload {
  email: string;
  // H-priv-7: region chosen at signup, persisted into the KV blob so
  // the /verify side can mint the org row with the matching
  // residency_region. Optional for backwards compatibility with KV
  // blobs minted before this change rolled out.
  residency_region?: string;
  // Lens 11 P1: the operator's intended landing path, captured at
  // /magic-link time + replayed at /verify time. Optional for
  // backwards compatibility with KV blobs minted before this rolled
  // out — null / absent falls through to the default
  // /inbox?welcome=1 (new) / /today (returning) destinations.
  return_to?: string | null;
  created_at: string;
}

interface UserRow {
  id: string;
  org_id: string;
}

/**
 * Look up an existing user by email or create org + user atomically.
 * Returns the user identity. The D1 batch is atomic per Cloudflare
 * D1 semantics; if it throws, callers handle as a 500.
 *
 * Used by both the magic-link verify path and the cross-site JIT
 * path (/me) -- the only difference between them is which side first
 * issued the JWT, and that side's local user IDs are not portable.
 */
async function findOrCreateUser(
  env: Env,
  email: string,
  region: string = "us-east",
): Promise<UserRow & { is_new: boolean }> {
  // Pre-launch (replan §B): this is the account-creation + cross-site
  // JIT chokepoint (callers: /verify magic-link, /me cross-site SSO).
  // Closing sign-in to SIGNIN_ALLOWLIST here covers BOTH new and
  // existing users on those paths. Throws SignInNotAllowedError, which
  // /verify maps to the waitlist and /me to a 401. No-op (open) when
  // SIGNIN_ALLOWLIST is unset — so launch reopens sign-in with one env
  // change.
  assertSignInAllowed(env, email);
  const existing = await env.DB.prepare(
    "SELECT id, org_id FROM users WHERE email = ?",
  )
    .bind(email)
    .first<UserRow>();
  // is_new drives the post-verify landing: a first-ever sign-in goes
  // to the camera-first /inbox (the activation moment — "point your
  // camera at any invoice"), not the empty /today dashboard a
  // non-technical owner can't act on yet.
  if (existing) return { ...existing, is_new: false };

  const orgId = newId("org");
  const userId = newId("usr");
  const now = new Date().toISOString();
  const orgName = email.split("@")[0] || email;

  // H-priv-7: persist the residency_region passed in from the magic-
  // link KV blob. The /magic-link entry point already rejected
  // unsupported regions with 451, so by the time we reach this
  // INSERT the value is guaranteed to be in SUPPORTED_REGIONS.
  await env.DB.batch([
    env.DB.prepare(
      "INSERT INTO orgs (id, name, residency_region, plan, created_at) VALUES (?, ?, ?, 'free', ?)",
    ).bind(orgId, orgName, region, now),
    env.DB.prepare(
      "INSERT INTO users (id, email, org_id, role, created_at) VALUES (?, ?, ?, 'owner', ?)",
    ).bind(userId, email, orgId, now),
  ]);

  await recordAuditEvent(new D1AuditStore(env.DB), {
    org_id: orgId,
    actor_id: userId,
    action: "org.created",
    target_kind: "org",
    target_ref: orgId,
  });

  return { id: userId, org_id: orgId, is_new: true };
}

auth.get("/verify", rateLimit({ ip_per_minute: 20 }), async (c) => {
  maybeWarnCookieDomain(c);
  // /verify is hit by a BROWSER clicking a link in an email. On
  // failure we must not strand a non-technical owner on a raw JSON
  // body in a blank tab. Content-negotiated: a browser navigation
  // (Accept: text/html) is 302'd to the friendly sign-in recovery
  // page (?expired=1&reason=...) which explains it and offers a
  // one-tap resend; a programmatic/API caller (no text/html in
  // Accept — including every existing test and /v1/auth/me clients)
  // still gets the exact JSON contract it had. The success path is
  // unchanged (it already redirects).
  const wantsHtml = (c.req.header("accept") ?? "")
    .toLowerCase()
    .includes("text/html");
  // Returns a 302 to the friendly recovery page for a browser
  // navigation, or null so the caller falls through to its exact
  // existing JSON response (contract + tests unchanged).
  const htmlFail = (reason: string) => {
    if (!wantsHtml) return null;
    const webOrigin = c.env.WEB_APP_ORIGIN ?? c.env.APP_ORIGIN;
    return c.redirect(`${webOrigin}/sign-in?expired=1&reason=${reason}`, 302);
  };

  const token = c.req.query("token");
  if (!token)
    return htmlFail("missing") ?? c.json({ error: "token required" }, 400);

  const raw = await c.env.AUTH_KV.get(`magic:${token}`);
  if (!raw)
    return (
      htmlFail("expired") ?? c.json({ error: "invalid or expired token" }, 401)
    );

  let parsed: MagicTokenPayload;
  try {
    parsed = JSON.parse(raw) as MagicTokenPayload;
  } catch {
    return (
      htmlFail("payload") ?? c.json({ error: "invalid token payload" }, 500)
    );
  }
  const email = parsed.email;
  if (typeof email !== "string") {
    return (
      htmlFail("payload") ?? c.json({ error: "invalid token payload" }, 500)
    );
  }

  // H-priv-7: forward the region the user chose at /magic-link into
  // the org-creation step. The KV blob holds it; older blobs minted
  // before this change have no region field and default to us-east.
  const region =
    typeof parsed.residency_region === "string" &&
    isSupportedRegion(parsed.residency_region)
      ? parsed.residency_region
      : "us-east";
  let user: UserRow & { is_new: boolean };
  try {
    user = await findOrCreateUser(c.env, email, region);
  } catch (err) {
    if (err instanceof SignInNotAllowedError) {
      // Pre-launch: closed to the allowlist. A browser goes to the
      // waitlist; an API caller gets a clean 403. (Reversible.)
      if (wantsHtml) {
        const webOrigin = c.env.WEB_APP_ORIGIN ?? c.env.APP_ORIGIN;
        return c.redirect(`${webOrigin}/waitlist`, 302);
      }
      return c.json({ error: "sign-in is not open yet" }, 403);
    }
    throw err;
  }

  // ── Wave D.6: accountant-tier passkey downgrade gate ──────────
  // Accountant-tier handles many clients' books, so a hardware-
  // bound passkey is mandatory for that tier (founder-locked /
  // privacy-audit). The real threat the magic link opens is a
  // downgrade: an attacker with access to the accountant's INBOX
  // signs in by email and bypasses the hardware key entirely.
  // Close it: once an accountant user HAS an active passkey, the
  // weaker email-link path is denied for them — they must use the
  // passkey. A user with NO passkey yet is still allowed in via
  // magic link so they can reach enrolment (chicken-and-egg). The
  // HARD "must enrol within N or lose access" mandate needs a
  // forced-enrolment session state machine and is a tracked D.6
  // follow-up; this downgrade-closure is the load-bearing control.
  // Gated before the token delete so a denied attempt doesn't burn
  // the link.
  // Hotfix 2026-06-01: the outer try/catch previously failed
  // closed on ANY exception, which bounced solo / team / new
  // users to /sign-in?expired=1&reason=unavailable whenever the
  // policy resolver itself hiccuped (Neon brown-out, fresh-
  // account race on the subscriptions row, etc.). The downgrade
  // prevention floor was load-bearing for accountant-tier users
  // who had ALREADY enrolled a passkey; for every other case the
  // magic-link must proceed (chicken-and-egg + UX).
  //
  // Restructured into a two-step fall-through:
  //
  //   1. Try resolvePasskeyEnrolmentPolicy. On success → existing
  //      accountant-only fail-closed below.
  //
  //   2. On resolver throw → log, then check listActivePasskeysForUser
  //      as the secondary signal. If the user HAS passkeys enrolled
  //      they are presumed to need them (either policy-mandated
  //      accountant OR a solo/team who chose passkey on principle);
  //      fail closed. If they have NO passkeys enrolled, allow
  //      magic-link through — they are either a brand-new account
  //      (where the link IS the sign-up path) or a not-yet-enrolled
  //      accountant the existing chicken-and-egg semantic admits.
  //
  //   3. If the secondary passkey check ALSO throws, fail closed —
  //      we have no signal either way; safe default is refuse.
  let policy: Awaited<ReturnType<typeof resolvePasskeyEnrolmentPolicy>>;
  try {
    policy = await resolvePasskeyEnrolmentPolicy(c.env, user.org_id);
  } catch (e) {
    log(c.env, {
      event: "auth.magic_link.policy_unknown",
      level: "warn",
      fields: { reason: e instanceof Error ? e.message : "unknown" },
    });
    let alreadyEnrolled = false;
    try {
      const activePasskeys = await defaultPasskeysStore(
        c.env,
      ).listActivePasskeysForUser(user.id);
      alreadyEnrolled = activePasskeys.length > 0;
    } catch {
      // Both resolver AND passkey-store unavailable — safest
      // posture is fail closed.
      return (
        htmlFail("unavailable") ??
        c.json({ error: "passkey_gate_unavailable" }, 503)
      );
    }
    if (alreadyEnrolled) {
      return (
        htmlFail("unavailable") ??
        c.json({ error: "passkey_gate_unavailable" }, 503)
      );
    }
    // No passkeys on file → allow the magic-link through. New
    // accounts and not-yet-enrolled users get the chicken-and-egg
    // admission they always had.
    policy = null as unknown as typeof policy;
  }
  if (policy && policy.tier === "accountant") {
    // F2: key on the REAL accountant tier, NOT on
    // hardware_bound_required (which is entitlement-gated). The
    // downgrade closure is a security floor and must not switch
    // off because a card lapsed; resolvePasskeyEnrolmentPolicy
    // already preserves the true tier label across
    // past_due/canceled.
    //
    // Inner try/catch: a passkey-store brown-out on an accountant
    // MUST fail closed (we cannot prove "no passkey enrolled"
    // safely). For every other tier the outer policy gate already
    // dropped us out before reaching this code.
    try {
      const activePasskeys = await defaultPasskeysStore(
        c.env,
      ).listActivePasskeysForUser(user.id);
      if (activePasskeys.length > 0) {
        if (c.env.DB) {
          await recordAuditEvent(new D1AuditStore(c.env.DB), {
            org_id: user.org_id,
            actor_id: user.id,
            action: "session.magic_link.reject.passkey_required",
            target_kind: "user",
            target_ref: user.id,
          });
        }
        return (
          htmlFail("passkey") ??
          c.json(
            {
              error: "passkey_required",
              detail:
                "Your firm requires a passkey to sign in. Use your passkey on this device.",
            },
            403,
          )
        );
      }
    } catch {
      return (
        htmlFail("unavailable") ??
        c.json({ error: "passkey_gate_unavailable" }, 503)
      );
    }
  }

  // Single-use: delete after we know we have a valid user.
  await c.env.AUTH_KV.delete(`magic:${token}`);

  await recordAuditEvent(new D1AuditStore(c.env.DB), {
    org_id: user.org_id,
    actor_id: user.id,
    action: "session.created",
    target_kind: "user",
    target_ref: user.id,
  });

  // B-priv-1: mint access + refresh pair, persist session row.
  // setCookie writes Set-Cookie onto c.res before the redirect;
  // Hono preserves response headers across c.redirect(), so the
  // 302 carries the session/refresh/csrf cookies (Domain=
  // muntin.digital, valid on app. + api. + apex).
  const jti = newJti();
  await mintAndPersistSession(c, user, email, jti, "magic");

  // The magic link is clicked in a browser, so /verify must land
  // the human IN the app, not on a raw JSON body. Redirect to the
  // web app's home (Today). WEB_APP_ORIGIN is the Next app that
  // owns the product routes; APP_ORIGIN fallback keeps non-prod /
  // older deploys working. (The {user_id,org_id} JSON contract
  // lives on /v1/auth/me, not here — see docs/auth-integration.md.)
  // Cold-start fix: a brand-new owner lands on the camera-first
  // /inbox (the activation moment), not the empty /today dashboard
  // wrapped in enterprise chrome they can't use yet. Returning users
  // keep going to /today (verdicts / what-needs-you).
  // Funnel: the magic link actually worked and the human is in.
  // is_new is a non-PII boolean; no email/ip/ua/user-id. With
  // funnel.magic_link_sent this is the sent→activated conversion the
  // red-team flagged as currently unobservable.
  log(c.env, {
    event: "funnel.verify_ok",
    level: "info",
    fields: { is_new: user.is_new },
  });

  const webOrigin = c.env.WEB_APP_ORIGIN ?? c.env.APP_ORIGIN;
  // Lens 11 P1: replay the operator's intended return path when one
  // was captured at /magic-link time. Re-sanitise on read so an old
  // KV blob with a since-invalid return_to (or one minted before
  // sanitizeReturnTo existed) can never cause an open redirect.
  // New users are always sent to /inbox?welcome=1 regardless of
  // return_to so the activation moment isn't skipped — the
  // operator's stated intent matters less than the first successful
  // upload.
  const verifiedReturnTo = sanitizeReturnTo(parsed.return_to);
  // UR1-5 (closes onboarding O1): cold sign-up lands on the rebuilt
  // day-one /today (Lens 15 §7 composition) instead of the old
  // /inbox?welcome=1. Pre-rebuild, /inbox carried a WelcomeCapture
  // surface and /today was a 520px panel on a void; post-rebuild the
  // day-one /today IS the orientation map (metaphor + offer + arc +
  // trust), so a brand-new user gets the map, not an upload form.
  //
  // The demo-handoff path is preserved verbatim: when the captured
  // return_to carries a handoff token, honour it so the operator
  // who parsed an invoice on /demo lands on the HandoffConfirmCard.
  // sanitizeReturnTo keeps the query string intact, so the token
  // survives the round-trip.
  const isHandoff =
    verifiedReturnTo?.startsWith("/inbox?welcome=1&handoff=") ?? false;
  const dest = isHandoff
    ? (verifiedReturnTo as string)
    : user.is_new
      ? "/today?firstRun=1"
      : (verifiedReturnTo ?? "/today");
  return c.redirect(`${webOrigin}${dest}`, 302);
});

/**
 * POST /v1/auth/refresh -- exchange the refresh cookie for a new
 * 15-minute access cookie. The refresh cookie itself is rotated
 * on every call to bound the refresh-token reuse window.
 *
 * Privacy semantics:
 *  - The session must exist + not be revoked. Revoked session = 401.
 *  - The refresh JWT must verify against JWT_SECRET AND its kid
 *    must match the current env.JWT_KEY_KID; mismatched kid = 401
 *    (typically after a secret rotation).
 *  - On success, a NEW jti is minted and the OLD jti is revoked
 *    with reason="rotation". This prevents refresh-token re-use:
 *    if a refresh JWT leaks AND the legitimate user has since
 *    refreshed, the leaked token's jti is revoked.
 *
 * State-changing POST so the double-submit CSRF middleware
 * applies (apps/api/src/middleware/csrf.ts).
 */
auth.post("/refresh", rateLimit({ ip_per_minute: 30 }), async (c) => {
  const refresh = getCookie(c, REFRESH_COOKIE_NAME);
  if (!refresh) return c.json({ error: "no refresh token" }, 401);

  const payload = await verifyJwt(refresh, c.env.JWT_SECRET, {
    audience: JWT_AUDIENCE_SESSION,
    issuer: issuerFor(c),
  });
  if (!payload || payload.token_use !== "refresh" || !payload.jti) {
    return c.json({ error: "invalid refresh token" }, 401);
  }
  if (payload.kid && payload.kid !== kidFor(c)) {
    return c.json({ error: "kid mismatch (re-auth required)" }, 401);
  }

  const sessions = defaultSessionsStore(c.env);
  const row = await sessions.find(payload.jti);
  if (!row) return c.json({ error: "session not found" }, 401);
  if (row.revoked_at) return c.json({ error: "session revoked" }, 401);

  // ── Wave D.6 audit F1: close the /refresh downgrade ───────────
  // A non-passkey-origin session (magic-link, cross-site JIT, or
  // any pre-D.6 token with no `amr`) must NOT be rotatable for an
  // accountant who now holds a hardware passkey — otherwise the
  // 30-day refresh cookie self-renews forever and never touches
  // the mandatory key (the F1 bypass that falsified the whole
  // claim). F2 fix folded in: key on the REAL accountant tier, not
  // on `hardware_bound_required` (a billing lapse must not disable
  // the security floor). Passkey-origin sessions rotate freely.
  // Fail CLOSED on a policy/passkey-store brown-out.
  if (payload.amr !== "passkey") {
    try {
      const policy = await resolvePasskeyEnrolmentPolicy(c.env, row.org_id);
      if (policy.tier === "accountant") {
        const passkeys = await defaultPasskeysStore(
          c.env,
        ).listActivePasskeysForUser(row.user_id);
        if (passkeys.length > 0) {
          if (c.env.DB) {
            await recordAuditEvent(new D1AuditStore(c.env.DB), {
              org_id: row.org_id,
              actor_id: row.user_id,
              action: "session.refresh.reject.passkey_required",
              target_kind: "session",
              target_ref: payload.jti,
            });
          }
          // Do NOT revoke/rotate — the user must re-auth via the
          // passkey path; the old session stays valid until it
          // naturally expires or is signed out.
          return c.json({ error: "passkey_required" }, 401);
        }
      }
    } catch {
      return c.json({ error: "passkey_gate_unavailable" }, 503);
    }
  }

  // Rotate: revoke the old jti with reason="rotation" and mint a
  // fresh pair with a new jti. The reused-leak window for the old
  // refresh JWT is now bounded by the rotation cadence. Carry the
  // origin auth method forward so a passkey session stays passkey
  // across rotations (and a magic session stays gated).
  const nowIso = new Date().toISOString();
  await sessions.revoke(payload.jti, "rotation", nowIso);
  const newJtiValue = newJti();
  await mintAndPersistSession(
    c,
    { id: row.user_id, org_id: row.org_id },
    payload.email,
    newJtiValue,
    payload.amr === "passkey"
      ? "passkey"
      : payload.amr === "password"
        ? "password"
        : "magic",
  );

  // S-F6 (audit batch 2): record session.rotated so an admin can
  // trace the lineage `old_jti -> new_jti` and detect anomalous
  // rotation cadence (a leaked refresh JWT rotates eagerly).
  try {
    await recordAuditEvent(new D1AuditStore(c.env.DB), {
      org_id: row.org_id,
      actor_id: row.user_id,
      action: "session.rotated",
      target_kind: "session",
      target_ref: `${payload.jti}|->|${newJtiValue}`,
    });
  } catch {
    // Audit best-effort; rotation already persisted in sessions table.
  }

  return c.json({
    user_id: row.user_id,
    org_id: row.org_id,
    refreshed: true,
  });
});

/**
 * POST /v1/auth/sign-out -- revoke the session and clear cookies.
 *
 * Reads the access cookie's jti (defensive: also accepts the
 * refresh cookie's jti if the access cookie is already expired).
 * Idempotent: a second call with a missing or already-revoked
 * session returns 200 with `revoked: false`.
 */
auth.post("/sign-out", rateLimit({ ip_per_minute: 30 }), async (c) => {
  let jti: string | undefined;
  const verifyOpts = {
    audience: JWT_AUDIENCE_SESSION,
    issuer: issuerFor(c),
  };
  const access = getCookie(c, COOKIE_NAME);
  if (access) {
    const p = await verifyJwt(access, c.env.JWT_SECRET, verifyOpts);
    if (p?.jti) jti = p.jti;
  }
  if (!jti) {
    const refresh = getCookie(c, REFRESH_COOKIE_NAME);
    if (refresh) {
      const p = await verifyJwt(refresh, c.env.JWT_SECRET, verifyOpts);
      if (p?.jti) jti = p.jti;
    }
  }

  let revoked = false;
  let revokedRow: { user_id: string; org_id: string } | null = null;
  if (jti) {
    const sessions = defaultSessionsStore(c.env);
    const before = await sessions.find(jti);
    revoked = await sessions.revoke(jti, "sign_out", new Date().toISOString());
    if (revoked && before) {
      revokedRow = { user_id: before.user_id, org_id: before.org_id };
    }
  }

  // S-F6 (audit batch 2): record session.revoked so the customer's
  // audit log shows when each device signed out. Best-effort: if
  // the audit chain hiccups, the revocation is still durable.
  if (revoked && revokedRow) {
    try {
      await recordAuditEvent(new D1AuditStore(c.env.DB), {
        org_id: revokedRow.org_id,
        actor_id: revokedRow.user_id,
        action: "session.revoked",
        target_kind: "session",
        target_ref: `${jti}|reason=sign_out`,
      });
    } catch {
      // ignore
    }
  }

  const domain = c.env.SESSION_COOKIE_DOMAIN;
  deleteCookie(c, COOKIE_NAME, { path: "/", ...(domain ? { domain } : {}) });
  deleteCookie(c, REFRESH_COOKIE_NAME, {
    path: "/",
    ...(domain ? { domain } : {}),
  });
  deleteCookie(c, CSRF_COOKIE_NAME, {
    path: "/",
    ...(domain ? { domain } : {}),
  });
  // Wave D.4-audit MED: the sticky workspace-switcher cookie
  // (set by POST /v1/cockpit/switch) outlives the session at 30d.
  // Without clearing it here, the next user on a shared browser is
  // either locked out by a confusing `workspace_not_member` 403 or
  // (if also a member) silently mis-oriented into the prior user's
  // workspace. Sign-out owns clearing every auth-scoped cookie.
  //
  // Security audit: mirror the write-time attributes (Secure, SameSite)
  // on the delete so browsers that key cookie identity off the full
  // attribute set still recognise the expiry. Domain falls through the
  // same `domain ?? undefined` shape as the writes.
  deleteCookie(c, "muntin_workspace", {
    path: "/",
    secure: true,
    sameSite: "Lax",
    ...(domain ? { domain } : {}),
  });

  return c.json({ revoked });
});

/**
 * Cross-site session resolution + JIT provisioning.
 *
 * Used when a request arrives carrying a session cookie that was
 * minted on the other side of muntin.digital (the marketing /
 * restaurant-tools app). The JWT is signature-verified with the
 * shared JWT_SECRET, the email claim is treated as canonical, and a
 * Ledger user/org is provisioned on first sight. The mirror route on
 * the muntin.digital Worker does the same in the other direction.
 *
 * Returns the *local* identity -- callers downstream (audit log,
 * extractions, exports) should always use the IDs from this response
 * rather than the JWT's sub/org_id, which are not portable.
 */
auth.get("/me", async (c) => {
  const token = getCookie(c, COOKIE_NAME);
  if (!token) return c.json({ error: "unauthenticated" }, 401);

  const payload = await verifyJwt(token, c.env.JWT_SECRET, {
    audience: JWT_AUDIENCE_SESSION,
    issuer: issuerFor(c),
  });
  if (!payload) return c.json({ error: "unauthenticated" }, 401);

  const email = payload.email.toLowerCase().trim();
  if (!EMAIL_RE.test(email)) {
    return c.json({ error: "invalid session" }, 401);
  }

  let user: UserRow & { is_new: boolean };
  try {
    user = await findOrCreateUser(c.env, email);
  } catch (err) {
    if (err instanceof SignInNotAllowedError) {
      // Pre-launch: cross-site JIT provisioning is closed to the
      // allowlist too. Uniform 401 (no enumeration).
      return c.json({ error: "unauthenticated" }, 401);
    }
    throw err;
  }

  // Reasons we need to mint a fresh local session+cookies on /me:
  //   1. JIT-provisioned a new local user (sub/org_id differ) -- the
  //      cross-site SSO case the comment block at the top of this
  //      handler describes.
  //   2. JWT carries no jti at all (legacy pre-B-priv-1 token from
  //      the other side of the muntin.digital deploy) -- treat as
  //      cross-site, mint a fresh local jti.
  //   3. JWT carries a jti but no matching local session row exists
  //      (cookie was minted by the other Worker on a different D1)
  //      -- same JIT path; mint a local row.
  const sessions = defaultSessionsStore(c.env);
  const localRow = payload.jti ? await sessions.find(payload.jti) : null;
  const needsRemint =
    payload.sub !== user.id ||
    payload.org_id !== user.org_id ||
    !payload.jti ||
    localRow === null;

  if (needsRemint) {
    const jti = newJti();
    // Wave D.6: a cross-site JIT remint is a session continuation —
    // preserve the original auth method (passkey stays passkey;
    // anything else is "magic", the fail-closed value so an
    // accountant cannot launder a magic session into a fresh one
    // via the cross-site path).
    await mintAndPersistSession(
      c,
      user,
      email,
      jti,
      payload.amr === "passkey"
        ? "passkey"
        : payload.amr === "password"
          ? "password"
          : "magic",
    );

    await recordAuditEvent(new D1AuditStore(c.env.DB), {
      org_id: user.org_id,
      actor_id: user.id,
      action: "session.jit_provisioned",
      target_kind: "user",
      target_ref: user.id,
    });
  } else if (localRow && localRow.revoked_at) {
    // Local session was revoked (e.g., another device signed out
    // this jti). Force re-auth. /sign-out clears cookies; we mirror.
    return c.json({ error: "session revoked" }, 401);
  } else {
    // Session unchanged; backfill the CSRF cookie for clients whose
    // session predates B-priv-2 (or who landed via cross-site SSO
    // mint that hasn't rotated yet). Re-using the remaining JWT TTL
    // keeps both cookies aligned in expiry.
    const remaining = Math.max(60, payload.exp - Math.floor(Date.now() / 1000));
    ensureCsrfCookie(c, remaining);
  }

  return c.json({ user_id: user.id, org_id: user.org_id, email });
});

// ─────────────────────────────────────────────────────────────────────
// UR auth-2: password sign-in surface
//
// Mirrors the magic-link contract: 202 on accepted, 401 on rejected
// (intentionally vague — never reveals whether the email exists or
// whether the password was the wrong field), 403 for accountant-tier
// downgrade refusal (same shape as the magic-link gate), 429 for rate
// limit / lockout.
//
// Accountant tier gate: same closure as /verify and /refresh — once
// the firm has an active passkey, password sign-in is refused with
// "passkey_required". A brown-out on the policy store fails CLOSED.
// ─────────────────────────────────────────────────────────────────────

/**
 * POST /v1/auth/password — sign in with email + password.
 *
 * Body: { email, password }
 *
 * The web client tracks the post-sign-in redirect locally (it has
 * the `return_to` query already). The handler does NOT accept
 * `return_to` because doing so would invite a server-side
 * open-redirect surface that needs sanitising for no UX benefit.
 *
 * On success: session minted with amr="password", access + refresh +
 * csrf cookies set, response carries { user_id, org_id, email }.
 *
 * On failure: always 401 with body { error: "invalid_credentials" }.
 * Lockout returns 429 with Retry-After; never says "wrong password
 * count is N" (don't help the attacker estimate progress). The
 * /v1/auth/lookup endpoint is the only place a UI sees method
 * availability.
 */
auth.post("/password", rateLimit({ ip_per_minute: 10 }), async (c) => {
  maybeWarnCookieDomain(c);
  let body: unknown;
  try {
    body = await c.req.json();
  } catch {
    return c.json({ error: "invalid json" }, 400);
  }
  if (
    !body ||
    typeof body !== "object" ||
    typeof (body as { email?: unknown }).email !== "string" ||
    typeof (body as { password?: unknown }).password !== "string"
  ) {
    return c.json({ error: "email and password required" }, 400);
  }
  const rawEmail = (body as { email: string }).email;
  const password = (body as { password: string }).password;
  // Defensive caps BEFORE any expensive work. An attacker submitting
  // a 10 MiB email or a 1 MiB password would waste Worker CPU /
  // memory on bookkeeping (toLowerCase, trim, parse). Both arms keep
  // the constant-shape 401 so the cap isn't an enumeration hint.
  if (
    typeof rawEmail !== "string" ||
    rawEmail.length > EMAIL_MAX_LEN ||
    typeof password !== "string" ||
    password.length > PASSWORD_BODY_MAX_LEN
  ) {
    return c.json({ error: "invalid_credentials" }, 401);
  }
  const email = rawEmail.toLowerCase().trim();
  if (!EMAIL_RE.test(email)) {
    // Same vague shape as a wrong password — never confirm "the email
    // was malformed" since that's an enumeration hint.
    return c.json({ error: "invalid_credentials" }, 401);
  }

  // Pre-launch (replan §B): password sign-in is closed to the
  // SIGNIN_ALLOWLIST too. Same vague 401 as a wrong password (no
  // enumeration). No-op (open) when the allowlist is unset.
  if (!isSignInAllowed(c.env, email)) {
    return c.json({ error: "invalid_credentials" }, 401);
  }

  // Per-email cap. Same shape as magic-link: an attacker spraying one
  // address from many IPs trips this before the per-IP cap.
  const emailCap = await rateLimitEmail(c.env, email, 10);
  if (!emailCap.allowed) {
    c.header("Retry-After", String(emailCap.retry_after_seconds));
    return c.json({ error: "rate_limited", scope: "email" }, 429);
  }

  // Resolve user. If no row, do constant-shape work so the response
  // time doesn't reveal whether the email is registered. We still run
  // Argon2id on the candidate password against a sentinel hash to
  // burn the same CPU as the real path.
  const userRow = c.env.DB
    ? await c.env.DB.prepare("SELECT id, org_id FROM users WHERE email = ?")
        .bind(email)
        .first<{ id: string; org_id: string }>()
    : null;

  const passwords = defaultPasswordsStore(c.env);
  const stored = userRow ? await passwords.find(userRow.id) : null;

  // Lockout check. Cleared lazily — if locked_until is in the past
  // we clear the row and continue.
  if (userRow) {
    const att = await passwords.attempts(userRow.id);
    if (att?.locked_until) {
      const lockedUntilMs = Date.parse(att.locked_until);
      if (Number.isFinite(lockedUntilMs) && lockedUntilMs > Date.now()) {
        const retrySec = Math.max(
          1,
          Math.ceil((lockedUntilMs - Date.now()) / 1000),
        );
        c.header("Retry-After", String(retrySec));
        return c.json({ error: "locked_out" }, 429);
      }
      if (Number.isFinite(lockedUntilMs) && lockedUntilMs <= Date.now()) {
        await passwords.clearAttempts(userRow.id);
      }
    }
  }

  // Timing-equaliser: ALWAYS run exactly one verifyPassword call so
  // the response time of "unknown email" vs "wrong password" is
  // statistically indistinguishable. sentinelHash() returns a cached
  // PHC hash computed ONCE per Worker isolate — subsequent calls are
  // effectively free. The earlier sentinel pattern called
  // hashPassword() on every unregistered-email attempt (adding ~1-2 s
  // of Argon2id work) which itself was a strong enumeration channel
  // (security audit C-1).
  let verified = false;
  try {
    const hashToVerify = stored?.hash ?? (await sentinelHash());
    verified = await verifyPassword(password, hashToVerify);
  } catch (e) {
    if (e instanceof PasswordCapacityError) {
      c.header("Retry-After", "2");
      return c.json({ error: "service_busy" }, 503);
    }
    throw e;
  }
  const ok = verified && stored !== null && userRow !== null;

  if (!ok || !userRow) {
    // Record the failure + apply lockout if threshold tripped.
    if (userRow) {
      // Security audit H-4: previously a per-attempt `insideWindow`
      // check on `effectiveFails` opened a sustained brute-force at
      // 1 guess per (window+ε) — the stored `fail_count` kept growing
      // but the LOCK check used the reset-on-stale-attempt value of
      // 1, never tripping the threshold. Fix: when the most-recent
      // failure is older than the window, hard-RESET the stored
      // counter BEFORE recording the new failure. That way the
      // post-record `fail_count` always reflects only failures
      // INSIDE the current window, and the lockout trips when a real
      // attacker bursts inside it.
      const priorAtt = await passwords.attempts(userRow.id);
      if (priorAtt?.last_fail_at) {
        const ageMs = Date.now() - Date.parse(priorAtt.last_fail_at);
        if (Number.isFinite(ageMs) && ageMs > PASSWORD_LOCKOUT_WINDOW_MS) {
          await passwords.clearAttempts(userRow.id);
        }
      }
      const cur = await passwords.attempts(userRow.id);
      const nextCount = (cur?.fail_count ?? 0) + 1;
      let lockedUntil: string | null = null;
      if (nextCount >= PASSWORD_LOCKOUT_THRESHOLD) {
        lockedUntil = new Date(
          Date.now() + PASSWORD_LOCKOUT_DURATION_MS,
        ).toISOString();
      }
      await passwords.recordFailure({
        user_id: userRow.id,
        at: new Date().toISOString(),
        locked_until: lockedUntil,
      });

      // Security audit H-3: record failed password sign-in in the
      // audit chain so a brute-force / credential-stuffing burst is
      // visible to compliance + forensics. The audit row carries the
      // post-record fail_count + whether THIS attempt tripped the
      // lock so a SIEM can alert on `fail_count >= N` per user. We
      // do NOT log the email or password — both are off-limits per
      // the privacy regression posture; the actor_id + org_id are
      // sufficient correlation keys.
      if (c.env.DB) {
        try {
          await recordAuditEvent(new D1AuditStore(c.env.DB), {
            org_id: userRow.org_id,
            actor_id: userRow.id,
            action: lockedUntil
              ? "session.password.fail.locked"
              : "session.password.fail",
            target_kind: "user",
            target_ref: `${userRow.id}|count=${nextCount}`,
          });
        } catch {
          /* audit best-effort — failure must not change the response */
        }
      }
    }
    return c.json({ error: "invalid_credentials" }, 401);
  }

  // Accountant-tier downgrade closure. Identical pattern to /verify.
  try {
    const policy = await resolvePasskeyEnrolmentPolicy(c.env, userRow.org_id);
    if (policy.tier === "accountant") {
      const activePasskeys = await defaultPasskeysStore(
        c.env,
      ).listActivePasskeysForUser(userRow.id);
      if (activePasskeys.length > 0) {
        if (c.env.DB) {
          await recordAuditEvent(new D1AuditStore(c.env.DB), {
            org_id: userRow.org_id,
            actor_id: userRow.id,
            action: "session.password.reject.passkey_required",
            target_kind: "user",
            target_ref: userRow.id,
          });
        }
        return c.json(
          {
            error: "passkey_required",
            detail:
              "Your firm requires a passkey to sign in. Use your passkey on this device.",
          },
          403,
        );
      }
    }
  } catch {
    return c.json({ error: "passkey_gate_unavailable" }, 503);
  }

  // Success — clear failed-attempt counter, mint session.
  await passwords.clearAttempts(userRow.id);

  if (c.env.DB) {
    try {
      await recordAuditEvent(new D1AuditStore(c.env.DB), {
        org_id: userRow.org_id,
        actor_id: userRow.id,
        action: "session.created",
        target_kind: "user",
        target_ref: userRow.id,
      });
    } catch {
      /* audit best-effort — failure must not prevent sign-in */
    }
  }

  const jti = newJti();
  await mintAndPersistSession(
    c,
    { id: userRow.id, org_id: userRow.org_id },
    email,
    jti,
    "password",
  );

  log(c.env, {
    event: "auth.password_signin_ok",
    fields: { method: "password" },
  });

  return c.json({ user_id: userRow.id, org_id: userRow.org_id, email });
});

/**
 * POST /v1/auth/password/set — authenticated user adds / changes
 * their password.
 *
 * Body: { new_password, current_password? }
 *   - current_password REQUIRED when the user already has a password
 *     on file (change flow). Omitted on first-time set (add flow).
 *
 * Returns 200 { ok: true } on success, 400 { error, code? } on
 * policy violation, 401 if the auth check fails (inline JWT verify
 * below), 409 { error: "wrong_current_password" } if current_password
 * doesn't match.
 *
 * Reuse check: rejects a new password that matches any of the last
 * N=3 prior hashes for this user.
 *
 * SECURITY — CSRF: This endpoint is a state-changing POST behind the
 * global double-submit `csrf()` middleware mounted in
 * apps/api/src/index.ts:142. Every state-changing request from a
 * cookie-authed origin must carry `X-CSRF-Token` matching the
 * `muntin_csrf` cookie. We deliberately do NOT call the CSRF check
 * locally because the global mount runs FIRST — by the time this
 * handler executes, the CSRF gate has already passed. If a future
 * refactor moves /v1/auth out from under the global mount, this
 * comment is the breadcrumb to wire csrf() inline here.
 */
// SECURITY (CRITICAL-01 + HIGH-02 audit): /password/set MUST use the
// full requireAuth middleware, not a hand-rolled inline JWT verify.
// requireAuth performs THREE checks the inline path was missing:
//   1. JTI revocation lookup against the sessions store — without this
//      a /sign-out-revoked-but-not-yet-expired access token can still
//      authorize a password change for the full 15-min access TTL.
//   2. kid match against env.JWT_KEY_KID — without this a pre-rotation
//      token authorizes password changes after a JWT_SECRET rotation,
//      defeating the rotation-pairs-with-kid-bump invariant.
//   3. Workspace membership resolution (defense-in-depth for
//      multi-tenant accountant accounts).
// All three are LOAD-BEARING. The session cookie is the only credential
// for this endpoint, so the full check must apply.
auth.post(
  "/password/set",
  rateLimit({ ip_per_minute: 10 }),
  requireAuth,
  async (c) => {
    const ctx = c.get("auth") as AuthContext;
    let body: unknown;
    try {
      body = await c.req.json();
    } catch {
      return c.json({ error: "invalid json" }, 400);
    }
    const rawNew =
      body &&
      typeof body === "object" &&
      typeof (body as { new_password?: unknown }).new_password === "string"
        ? (body as { new_password: string }).new_password
        : null;
    const rawCurrent =
      body &&
      typeof body === "object" &&
      typeof (body as { current_password?: unknown }).current_password ===
        "string"
        ? (body as { current_password: string }).current_password
        : null;
    // MEDIUM-02 audit: enforce body length caps BEFORE any expensive
    // work. validatePassword bounds new_password, but current_password
    // would otherwise reach verifyPassword with an unbounded length —
    // memory pressure × the 5-slot Argon2 pool is exploitable as a
    // mild local DoS / pool-pressure signal.
    if (rawNew !== null && rawNew.length > PASSWORD_BODY_MAX_LEN) {
      return c.json({ error: "password_policy", code: "too_long" }, 400);
    }
    if (rawCurrent !== null && rawCurrent.length > PASSWORD_BODY_MAX_LEN) {
      return c.json({ error: "wrong_current_password" }, 409);
    }
    const newPassword = rawNew;
    const currentPassword = rawCurrent;
    if (!newPassword) {
      return c.json({ error: "new_password required" }, 400);
    }

    // We need the caller's email for the contains-email policy check.
    // requireAuth resolves user_id + org_id but not email; pull it
    // straight off the verified JWT instead of an extra DB hop.
    // The session cookie is the source — verified by requireAuth — so
    // re-verify the cookie's signature here just to retrieve email
    // (cached parse, no second crypto round trip in practice).
    const sessionToken = getCookie(c, COOKIE_NAME);
    const sessionPayload = sessionToken
      ? await verifyJwt(sessionToken, c.env.JWT_SECRET, {
          audience: JWT_AUDIENCE_SESSION,
          issuer: c.env.JWT_ISSUER || JWT_ISSUER_DEFAULT,
        })
      : null;
    const callerEmail =
      typeof sessionPayload?.email === "string" ? sessionPayload.email : "";

    const policyResult = validatePassword(newPassword, {
      email: callerEmail,
    });
    if (!policyResult.ok) {
      return c.json({ error: "password_policy", code: policyResult.code }, 400);
    }

    const passwords = defaultPasswordsStore(c.env);
    const existing = await passwords.find(ctx.user_id);

    // Security audit M-3 (second pass): per-user cooldown on the
    // success path so a logged-in user cannot burn Argon2id CPU /
    // spam the history table with rapid changes. 5 minutes between
    // changes; covers the legitimate flow (set once at sign-up, set
    // a fresh value if compromised). 429 with Retry-After lets the
    // UI render a calm "please wait" instead of a generic error.
    if (existing) {
      const lastChangeMs = Date.parse(existing.updated_at);
      const CHANGE_COOLDOWN_MS = 5 * 60 * 1000;
      if (
        Number.isFinite(lastChangeMs) &&
        Date.now() - lastChangeMs < CHANGE_COOLDOWN_MS
      ) {
        const retrySec = Math.max(
          1,
          Math.ceil((CHANGE_COOLDOWN_MS - (Date.now() - lastChangeMs)) / 1000),
        );
        c.header("Retry-After", String(retrySec));
        return c.json({ error: "password_change_cooldown" }, 429);
      }
      if (!currentPassword) {
        return c.json({ error: "current_password_required" }, 400);
      }
      const matches = await verifyPassword(currentPassword, existing.hash);
      if (!matches) {
        return c.json({ error: "wrong_current_password" }, 409);
      }
      // Reuse check across the recent-N history.
      if (await verifyPassword(newPassword, existing.hash)) {
        return c.json({ error: "password_reuse" }, 400);
      }
      const history = await passwords.recentHistory(
        ctx.user_id,
        PASSWORD_HISTORY_DEPTH,
      );
      for (const h of history) {
        if (await verifyPassword(newPassword, h.hash)) {
          return c.json({ error: "password_reuse" }, 400);
        }
      }
    }

    const hash = await hashPassword(newPassword);
    await passwords.set({
      user_id: ctx.user_id,
      hash,
      at: new Date().toISOString(),
    });
    await passwords.clearAttempts(ctx.user_id);

    if (c.env.DB) {
      await recordAuditEvent(new D1AuditStore(c.env.DB), {
        org_id: ctx.org_id,
        actor_id: ctx.user_id,
        action: existing ? "password.changed" : "password.set",
        target_kind: "user",
        target_ref: ctx.user_id,
      });
    }

    return c.json({ ok: true });
  },
);

/**
 * POST /v1/auth/password/reset/begin — request a password reset.
 *
 * Body: { email }
 *
 * Always returns 202 (constant shape — never confirms whether the
 * email exists). On a valid registered email, mints a KV-backed
 * single-use token, 15-minute TTL, and emails a reset link.
 *
 * Rate-limited per-IP (5/min) and per-email (3/min) — tighter than
 * magic-link because reset is a less-common operation and the abuse
 * surface is more attractive.
 */
auth.post(
  "/password/reset/begin",
  rateLimit({ ip_per_minute: 5 }),
  async (c) => {
    let body: unknown;
    try {
      body = await c.req.json();
    } catch {
      return c.json({ error: "invalid json" }, 400);
    }
    if (
      !body ||
      typeof body !== "object" ||
      typeof (body as { email?: unknown }).email !== "string" ||
      (body as { email: string }).email.length > EMAIL_MAX_LEN
    ) {
      // Constant-shape 202 even on shape failures so the caller
      // cannot distinguish "valid request for unknown email" from
      // "malformed request".
      return c.json({ status: "sent" }, 202);
    }
    const email = (body as { email: string }).email.toLowerCase().trim();
    if (!EMAIL_RE.test(email)) {
      // Same 202 shape as the happy path. Never reveal "invalid email".
      return c.json({ status: "sent" }, 202);
    }

    const emailCap = await rateLimitEmail(c.env, email, 3);
    if (!emailCap.allowed) {
      c.header("Retry-After", String(emailCap.retry_after_seconds));
      return c.json({ error: "rate_limited", scope: "email" }, 429);
    }

    // Look up user; only mint a real reset token if the row exists.
    // SECURITY MEDIUM-01 audit: the prior shape only ran the KV put
    // + email-send work for KNOWN users, leaking existence via a
    // ~100-300 ms timing delta. Now both arms run equivalent work:
    // the unknown arm mints a discardable sentinel token + does a
    // KV put against a discardable key (1-second TTL so it auto-
    // garbage-collects) + DOES NOT call Resend. The Resend call is
    // the only path that touches the network egress, and skipping
    // it on unknown keeps the email-deliverability bill predictable
    // — but we still pay the KV write so the cumulative timing is
    // close enough to defeat enumeration.
    const userRow = c.env.DB
      ? await c.env.DB.prepare("SELECT id FROM users WHERE email = ?")
          .bind(email)
          .first<{ id: string }>()
      : null;

    const ttl = Number(c.env.PASSWORD_RESET_TTL_SECONDS) || 900;
    const token = newId("prt"); // password-reset-token

    if (userRow) {
      await c.env.AUTH_KV.put(
        `pwreset:${token}`,
        JSON.stringify({
          email,
          user_id: userRow.id,
          created_at: new Date().toISOString(),
        }),
        { expirationTtl: ttl },
      );
      const result = await sendPasswordResetEmail(c.env, email, token);
      log(c.env, {
        event: "auth.password_reset_begin",
        fields: { delivered: result.delivered },
      });
      return c.json(
        {
          status: "sent",
          ...(result.devLink ? { dev_link: result.devLink } : {}),
        },
        202,
      );
    }

    // Unknown-arm sentinel: 1-second TTL so the KV namespace doesn't
    // accumulate garbage. The key uses a "sentinel:" prefix so an
    // operator inspecting the KV cannot accidentally treat it as a
    // real reset token.
    try {
      await c.env.AUTH_KV.put(`pwreset_sentinel:${token}`, "1", {
        expirationTtl: 1,
      });
    } catch {
      /* sentinel best-effort; the response timing parity matters more */
    }

    log(c.env, {
      event: "auth.password_reset_begin_noop",
      fields: { delivered: false },
    });
    return c.json({ status: "sent" }, 202);
  },
);

/**
 * POST /v1/auth/password/reset/finish — complete a reset.
 *
 * Body: { token, new_password, revoke_other_sessions? = true }
 *
 * On success: sets the new password, optionally revokes every other
 * session, mints a fresh session in the response (auto-sign-in), and
 * returns { user_id, org_id, email }.
 *
 * On failure: 400 with code for the UI to map (token_invalid,
 * token_expired, password_policy, password_reuse).
 *
 * Accountant tier: the reset succeeds (the durable hash IS updated)
 * but the post-reset auto-sign-in is REFUSED — the response carries
 * { passkey_required: true } and no cookies. UI explains: "We've set
 * your password, but your firm requires a passkey to sign in."
 */
auth.post(
  "/password/reset/finish",
  rateLimit({ ip_per_minute: 10 }),
  async (c) => {
    let body: unknown;
    try {
      body = await c.req.json();
    } catch {
      return c.json({ error: "invalid json" }, 400);
    }
    const token =
      body &&
      typeof body === "object" &&
      typeof (body as { token?: unknown }).token === "string"
        ? (body as { token: string }).token
        : null;
    const newPassword =
      body &&
      typeof body === "object" &&
      typeof (body as { new_password?: unknown }).new_password === "string"
        ? (body as { new_password: string }).new_password
        : null;
    const revokeOthers =
      body &&
      typeof body === "object" &&
      (body as { revoke_other_sessions?: unknown }).revoke_other_sessions !==
        false;

    if (
      !token ||
      !newPassword ||
      typeof newPassword !== "string" ||
      newPassword.length > PASSWORD_BODY_MAX_LEN
    ) {
      return c.json({ error: "missing_fields" }, 400);
    }
    // Validate token SHAPE before hitting KV — saves a needless KV
    // metered read for garbage / fuzz input and protects against an
    // attacker probing the KV namespace with arbitrary strings.
    if (!RESET_TOKEN_RE.test(token)) {
      return c.json({ error: "token_invalid" }, 400);
    }

    const raw = await c.env.AUTH_KV.get(`pwreset:${token}`);
    if (!raw) return c.json({ error: "token_invalid" }, 400);
    let parsed: { email: string; user_id: string };
    try {
      parsed = JSON.parse(raw) as { email: string; user_id: string };
    } catch {
      return c.json({ error: "token_invalid" }, 400);
    }
    if (
      typeof parsed.email !== "string" ||
      typeof parsed.user_id !== "string"
    ) {
      return c.json({ error: "token_invalid" }, 400);
    }

    const policyResult = validatePassword(newPassword, { email: parsed.email });
    if (!policyResult.ok) {
      // Keep the token alive — a policy fail isn't an attack and the
      // user should be able to retry with a stronger password without
      // re-requesting an email.
      return c.json({ error: "password_policy", code: policyResult.code }, 400);
    }

    // Defense-in-depth: pull the user by EMAIL (the identity claim in
    // the token), then assert the row's id matches the token's user_id.
    // Without this, a token whose user_id was tampered between mint
    // and read (KV intrusion) could redirect the reset to a different
    // user. The token is HMAC-protected by virtue of being a 16-byte
    // random opaque KV key; this cross-check is the second factor.
    const userRow = c.env.DB
      ? await c.env.DB.prepare("SELECT id, org_id FROM users WHERE email = ?")
          .bind(parsed.email.toLowerCase().trim())
          .first<{ id: string; org_id: string }>()
      : null;
    if (!userRow || userRow.id !== parsed.user_id) {
      // Best-effort consume so a tampered token can't be retried.
      try {
        await c.env.AUTH_KV.delete(`pwreset:${token}`);
      } catch {
        /* best-effort */
      }
      return c.json({ error: "token_invalid" }, 400);
    }

    // Reuse check.
    const passwords = defaultPasswordsStore(c.env);
    const existing = await passwords.find(userRow.id);
    if (existing && (await verifyPassword(newPassword, existing.hash))) {
      return c.json({ error: "password_reuse" }, 400);
    }
    const history = await passwords.recentHistory(
      userRow.id,
      PASSWORD_HISTORY_DEPTH,
    );
    for (const h of history) {
      if (await verifyPassword(newPassword, h.hash)) {
        return c.json({ error: "password_reuse" }, 400);
      }
    }

    // Security audit C-2: do the WORK FIRST, then consume the token.
    // Previously the KV delete ran BEFORE hashPassword + passwords.set,
    // so any failure in those (Argon2id OOM, D1 brown-out) consumed
    // the token without setting the password — the user's reset link
    // was permanently dead and they had to request a new one. Compute
    // the hash + persist, THEN delete the token. The narrow window
    // where two concurrent requests could reuse the same token is
    // acceptable: both would set the same plaintext password (just
    // with different salts), the second's passwords.set overrides the
    // first's. Net effect: the user's chosen password is set; no
    // security loss.
    const hash = await hashPassword(newPassword);
    await passwords.set({
      user_id: userRow.id,
      hash,
      at: new Date().toISOString(),
    });
    await passwords.clearAttempts(userRow.id);
    // Consume the token — single-use. Errors here are non-fatal: the
    // password was already set; the token will expire on its own.
    try {
      await c.env.AUTH_KV.delete(`pwreset:${token}`);
    } catch {
      /* best-effort — token TTL provides the upper bound */
    }

    if (c.env.DB) {
      await recordAuditEvent(new D1AuditStore(c.env.DB), {
        org_id: userRow.org_id,
        actor_id: userRow.id,
        action: "password.reset.completed",
        target_kind: "user",
        target_ref: userRow.id,
      });
    }

    // Accountant-tier auto-sign-in refusal.
    try {
      const policy = await resolvePasskeyEnrolmentPolicy(c.env, userRow.org_id);
      if (policy.tier === "accountant") {
        const pks = await defaultPasskeysStore(c.env).listActivePasskeysForUser(
          userRow.id,
        );
        if (pks.length > 0) {
          // Accountant flow: revoke all existing sessions (the password
          // change is meaningful even though sign-in is passkey-gated),
          // do NOT mint a new session, return passkey_required.
          if (revokeOthers) {
            await revokeAllSessionsForUser(c.env, userRow.id);
          }
          return c.json({
            ok: true,
            passkey_required: true,
            user_id: userRow.id,
            org_id: userRow.org_id,
          });
        }
      }
    } catch {
      // Fail closed for accountant: refuse auto-sign-in if the policy
      // store hiccups. Owner tier proceeds.
    }

    // Security audit M-4 (second pass): mint the NEW session BEFORE
    // listing active sessions to revoke. If we listed first then
    // minted, a race against another device's /refresh could land a
    // jti inside the active list AFTER our snapshot but BEFORE our
    // mint, and that fresh jti would not be revoked. Mint first;
    // revoke everything EXCEPT the new jti.
    const jti = newJti();
    await mintAndPersistSession(
      c,
      { id: userRow.id, org_id: userRow.org_id },
      parsed.email,
      jti,
      "password",
    );

    if (revokeOthers) {
      await revokeAllSessionsForUser(c.env, userRow.id, { except: jti });
    }

    // Security audit M-6: record the auto-mint that happens after a
    // successful reset so the audit chain shows the SESSION created
    // alongside the password.reset.completed event. Without this, a
    // phished reset link would appear in the chain as "password
    // changed" but the attacker's session creation would be invisible
    // — a forensic gap when reconstructing a compromise.
    if (c.env.DB) {
      try {
        await recordAuditEvent(new D1AuditStore(c.env.DB), {
          org_id: userRow.org_id,
          actor_id: userRow.id,
          action: "session.created",
          target_kind: "user",
          target_ref: `${userRow.id}|origin=password_reset`,
        });
      } catch {
        /* best-effort */
      }
    }

    return c.json({
      ok: true,
      user_id: userRow.id,
      org_id: userRow.org_id,
      email: parsed.email,
    });
  },
);

/**
 * Helper: revoke every active session for a user, optionally except a
 * named jti. Used by /password/reset/finish so a reset that elects
 * "sign me out everywhere" terminates every prior session while
 * leaving the newly-minted post-reset session alive (M-4 audit).
 *
 * Best-effort: failures here must not change the response shape — the
 * password is already set; an unrevoked stale session will expire on
 * its own via the access TTL (15 min).
 */
async function revokeAllSessionsForUser(
  env: Env,
  user_id: string,
  opts: { except?: string } = {},
): Promise<void> {
  try {
    const sessions = defaultSessionsStore(env);
    const nowIso = new Date().toISOString();
    const list = await sessions.listActiveForUser(user_id, nowIso);
    await Promise.all(
      list
        .filter((row) => row.id !== opts.except)
        .map((row) =>
          sessions.revoke(row.id, "password_reset", nowIso).catch(() => false),
        ),
    );
  } catch {
    /* best-effort */
  }
}

/**
 * Helper: send a password-reset email. Mirrors sendMagicLinkEmail —
 * Resend in production, dev_link returned inline when no RESEND_API_KEY.
 *
 * SECURITY (audit L-8): dev_link is returned in the HTTP response
 * body ONLY when both:
 *   1. RESEND_API_KEY is unset (no real email send is possible), AND
 *   2. APP_ORIGIN looks like a dev/local host (localhost / 127.0.0.1
 *      / *.local / loopback IPv6).
 * In production both conditions are false, so the token NEVER appears
 * in the response. A staging deploy that accidentally lands without
 * RESEND_API_KEY still won't leak the token because APP_ORIGIN is a
 * routable hostname. This is stricter than the magic-link path's
 * dev_link convention; password reset's blast radius justifies it.
 */
function looksLikeDevOrigin(origin: string): boolean {
  const o = origin.toLowerCase();
  return (
    o.includes("localhost") ||
    o.includes("127.0.0.1") ||
    o.includes("[::1]") ||
    o.endsWith(".local") ||
    o.endsWith(".local/")
  );
}

async function sendPasswordResetEmail(
  env: Env,
  email: string,
  token: string,
): Promise<{ delivered: boolean; devLink?: string }> {
  const origin =
    env.WEB_APP_ORIGIN ?? env.APP_ORIGIN ?? env.API_PUBLIC_ORIGIN ?? "";
  const link = `${origin}/reset-password?token=${encodeURIComponent(token)}`;
  if (!env.RESEND_API_KEY) {
    return {
      delivered: false,
      ...(looksLikeDevOrigin(env.APP_ORIGIN ?? "") ? { devLink: link } : {}),
    };
  }
  const from = env.TXN_EMAIL_FROM ?? "Muntin Ledger <hello@muntin.digital>";
  const replyTo = env.EMAIL_REPLY_TO ?? "hello@muntin.digital";
  try {
    await fetch("https://api.resend.com/emails", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${env.RESEND_API_KEY}`,
      },
      body: JSON.stringify({
        from,
        to: email,
        reply_to: replyTo,
        subject: "Reset your Muntin Ledger password",
        text:
          `Open this link to set a new password.\n\n${link}\n\n` +
          `The link works for 15 minutes. If you did not ask to reset ` +
          `your password, you can ignore this email — your account is unchanged.`,
      }),
    });
    return { delivered: true };
  } catch {
    return { delivered: false };
  }
}

See also

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.

auth.ts · Verify · Muntin Ledger · Muntin