Change evidence report

Magic-link-only customer authentication: reviewer evidence pack

PR 1903 removes customer password authentication and password settings across the API and public widgets. Customer signup and sign-in now establish sessions through emailed, tenant-bound, single-use magic links. The change also makes link-request responses uniform, prevents inbound links from replacing valid stored sessions, preserves in-progress gift-card state through the email round trip, and drops the obsolete password and mailbox-proof columns in migration 0190.

Range
6049e37fc36fc560a1202894d6bb255439cbe658…a7d3107de6757a18218546225d34282e659cf4fc
Source state
fix/1885-magic-link-only-auth at code commit a7d3107de, pushed before this evidence source was added
Generated

Reviewer brief

Outcome and scope

Customer sign-in is email-only and the migration chain converges

The built account widget renders no password control and reaches a conditional check-inbox state after an email request. Focused API and database suites prove uniform request responses, token issuance and consumption, single-use enforcement, revocation, tenant scoping and email-change races. A fresh database migrated through 0190 and matched its generated snapshot. Independent correctness and security follow-up reviews found no remaining merge blocker.

What this report proves

The screenshot is a real browser rendering of the production widget bundle built from code commit a7d3107de, with the API response intercepted to avoid sending email. It proves the rendered email-only interaction and conditional copy, not live provider delivery. The database-backed API tests and fresh migration run establish the server and schema claims. This report is reviewer evidence, not approval or a substitute for CI.

Claim map

Evidence coverage

Each material changed behavior or state maps to direct evidence or an explicit gap.

Behavior or claimEvidenceStatus
Customer sign-in and account settings contain no password pathBuilt-widget screenshot, contract table, source diff and focused widget testsCovered
Known and unknown magic-link requests return the same body and do not wait for provider acceptanceFocused API excerpt and a provider-pending regression testCovered
An inbound magic link cannot silently replace a valid stored customer or impersonation sessionHydration-order excerpt and a browser-state regression testCovered
Magic links remain tenant-bound, expire after 30 minutes and can be consumed only onceDatabase-backed capture flow covering request, mailbox follow, successful verify and replay rejectionCovered
Migration 0190 removes both obsolete credential columns without forking migration 0189Migration excerpt, fresh-chain convergence check and independent snapshot-lineage reviewCovered
A signed-out cross-device browser confirms identity before adopting an unbound linkNot implemented in this change. The documented bearer-link tradeoff is tracked in issue #1902Gap

Observed change

Review evidence

Evidence is grouped by the reviewer question each item addresses.

Account widget

Screenshot

Email request reaches a non-enumerating confirmation state

Production widget bundle rendered at 606 by 757 CSS pixels. The request used unknown@example.com against an intercepted successful response, matching the response contract for both known and unknown addresses.

The panel says a link arrives only if an account exists, exposes a 60-second resend countdown, and contains zero password inputs.After
Acme Sauna member login panel showing Check your email, conditional account-exists copy for unknown@example.com, a disabled resend countdown and Back to sign in

Authentication contract

Table

Customer credential surface before and after

Clean cutover with no compatibility aliases or deprecated password routes.

ContractBasePR 1903
Sign-in methodsPassword or emailed magic linkEmailed magic link only
Customer API routesPOST /widget/auth/login and POST /widget/auth/password presentBoth routes removed
Account settingsSet, change and remove password cardNo password setting
Session responsehasPassword and mailboxProven status fieldsCredential status fields removed
Databasecustomers.password_hash and magic_link_tokens.self_requestedBoth columns dropped by 0190

API request privacy

Code

Delivery state no longer reveals whether an address matched

Global delivery configuration is checked before the customer lookup. A known account prepares its token without waiting for provider acceptance, then returns the same okBody as an unknown account.

Uniform response path at code commit a7d3107de.After
packages/api/src/routes/widget-auth.ts:680-738
if (!emailService) {
  return c.json(sendFailedBody, 503);
}

const customerRows = await db
  .select()
  .from(customers)
  .where(/* brand-scoped normalized email */);

const okBody = {
  ok: true,
  resend: resendState(Math.ceil(RESEND_COOLDOWN_MS / 1000), gate.remaining),
};
const customer = customerRows[0];
if (customer) {
  try {
    await sendCustomerMagicLink({
      db, emailService, brand, customer, redirectUrl, ip, trackingBaseUrl,
      waitForDelivery: false,
    });
  } catch (err) {
    console.error("[widget-auth] failed to prepare magic link:", err);
  }
}
return c.json(okBody);

Widget session safety

Code

Existing valid sessions take precedence over inbound links

Hydration validates impersonation and customer storage candidates first. Only when no candidate survives does it redeem the mlt fragment.

Session-fixation guard at code commit a7d3107de.After
packages/widgets/src/lib/auth.tsx:768-857
for (const candidate of candidates) {
  const r = await api.rawRequest("/api/widget/auth/me", {
    auth: false,
    headers: { Authorization: `Bearer ${candidate.token}` },
  }).catch(() => null);

  if (!r) return;
  if (r.ok) {
    const data = await r.json();
    setToken(candidate.token);
    setCustomer(data.customer);
    setImpersonatedBy(data.impersonatedBy ?? null);
    return;
  }
  // Authoritatively rejected candidates are removed from their own store.
}

if (mlt) {
  const customer = await verifyMagicLink(mlt);
  if (customer) return;
}

Database migration

Code

0190 drops obsolete customer credential columns

The migration was regenerated after origin/main's 0189 booking migration. Its snapshot derives from 0189 and omits both customer-only credential fields.

Complete migration SQL.After
packages/api/drizzle/0190_customer_magic_link_only_auth.sql:1-2
ALTER TABLE "customers" DROP COLUMN "password_hash";--> statement-breakpoint
ALTER TABLE "magic_link_tokens" DROP COLUMN "self_requested";

Confidence

Validation

Pass

Fresh migration chain and schema convergence

A new local Postgres database migrated from 0000 through 0190. The post-migrate assertion reported 96 tables and 56 enums matching 0190_snapshot.json.

Pass

Focused API authentication and route coverage

95 tests passed across widget auth, public widget origin, customer sign-in links, safe customer projections, friend booking and audit route inventory.

Pass

Database-backed magic-link boundaries

42 tests passed across request, captured mailbox follow, successful verification, replay rejection, session revocation and concurrent email-change issuance.

Pass

Widget authentication behavior

84 tests passed across API auth, WidgetAuthProvider hydration, SignInPanel and GiftCardPurchase. BookingCalendar added 66 passing authentication-flow tests.

Pass

Tenant inventory simulation

18 tenant-isolation scenarios passed after recreating the simulation database from the proposed schema.

Pass

Affected-package typechecks and lint

API, widgets, simulation and docs typechecks exited 0. Biome checked all changed supported files with no findings, and git diff --check passed.

Pass

Independent correctness and security review

The correctness reviewer verified 0190 lineage after rebase and found no merge fallout. The security reviewer confirmed the request status oracle and valid-session overwrite findings are fixed, with the signed-out cross-device tradeoff recorded below.

Warn

Exact-head CI

Pending on the draft pull request. Local focused proof passed before evidence authoring.

Limits

Known issues, risks, and gaps

Destructive credential removal and the intentional cross-device bearer-link boundary

Password credentials are removed without compatibility Migration 0190 permanently drops customer password hashes. The API routes, response fields and widget controls are removed in the same release. This is intentional because no customers are using the pre-launch application.

Signed-out cross-device redemption remains bearer-link authentication A valid stored customer or impersonation session can no longer be replaced by an inbound link. A browser with no valid stored session still auto-redeems an unused same-brand link to preserve documented cross-device sign-in. Customers are warned not to share links. Explicit identity confirmation for unbound links is tracked in #1902.

Provider rejection is not shown as an account-specific error The request endpoint cannot expose provider acceptance without revealing whether the address matched. Its UI therefore says a link will arrive only if an account exists, while asynchronous provider failures are logged. Global delivery misconfiguration still returns the same 503 for known and unknown addresses.

The visual evidence is head-only The report captures the proposed email-only state but does not include a matched base screenshot of the removed password form. The base-to-head source diff and deleted password test suites establish the removal boundary.