From ad871dd1b28bbe2dcfb9cab97bb3d753443a9bf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:55:33 +0000 Subject: [PATCH 1/3] Restore pinch-zoom and give the document a main landmark The viewport locked maximumScale/userScalable, blocking zoom for anyone who magnifies; BugReportSheet's textarea goes 14px -> 16px so iOS still never zooms on focus. .forge-page is now the main landmark, and the four routes that rendered their own
are divs. Both are tested. --- app/diag-bugs/page.jsx | 4 +- app/layout.jsx | 25 ++++++++--- app/locker-room/page.jsx | 6 +-- app/not-found.jsx | 4 +- components/BugReportSheet.jsx | 2 +- tests/a11y-contract.test.js | 80 +++++++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 tests/a11y-contract.test.js diff --git a/app/diag-bugs/page.jsx b/app/diag-bugs/page.jsx index 29995d5..0411778 100644 --- a/app/diag-bugs/page.jsx +++ b/app/diag-bugs/page.jsx @@ -51,7 +51,7 @@ export default function DiagBugs() { }; return ( -
+
Fill or kill
@@ -89,6 +89,6 @@ export default function DiagBugs() {
))} -
+ ); } diff --git a/app/layout.jsx b/app/layout.jsx index 6473399..1d46160 100644 --- a/app/layout.jsx +++ b/app/layout.jsx @@ -150,8 +150,18 @@ export const viewport = { colorScheme: "light dark", width: "device-width", initialScale: 1, - maximumScale: 1, - userScalable: false, + // NO maximumScale / userScalable lock. It arrived with this file's first + // commit as PWA boilerplate and never earned a reason of its own, while it + // cost pinch-zoom to anyone who magnifies to read (Lighthouse flags it). + // iOS has ignored the lock for user-initiated zoom since iOS 10, so it only + // ever bound Android. + // + // The one thing it plausibly still bought was suppressing iOS's + // zoom-on-focus, which fires on focusable inputs under 16px. That is now + // fixed at the cause instead: every focusable input in the app is >= 16px + // (BugReportSheet's textarea was the last one at 14px). Keep new inputs at + // 16px or larger and the lock stays unnecessary. + // // viewport-fit: cover STAYS, even though the immersive status-bar look it // was originally chosen for is gone (iOS 26.1 — see appleWebApp above). // The load-bearing reason was always the second one: without cover, every @@ -240,7 +250,12 @@ export default function RootLayout({ children }) { - {/* .forge-page (globals.css): document-height wrapper that paints + {/*
because this wrapper IS the document's dominant content: + every route renders through it and the layout has no nav or + footer landmark outside it. Routes must NOT render their own +
— it would nest, and a document gets one. + + .forge-page (globals.css): document-height wrapper that paints the ground substrate (bone/ash + the baked-in paper texture) across the FULL scrollable document. Texture lives in the ground's own background — substrate, never overlay — so it @@ -250,7 +265,7 @@ export default function RootLayout({ children }) { whenever a fixed/sticky element borders a viewport edge (WebKit bug 301756), which suppresses the translucent scroll-under treatment at both the status bar and URL bar. */} -
+
{/* The ONE transition boundary: route navigations and in-shell screen swaps both update this subtree inside a React transition, so both animate through the same class-mapped @@ -266,7 +281,7 @@ export default function RootLayout({ children }) { > {children} -
+
{/* Status-bar handling: iOS owns the bar (statusBarStyle: default, since 26.1 stopped honouring black-translucent). viewport-fit: diff --git a/app/locker-room/page.jsx b/app/locker-room/page.jsx index a40a5c7..6023ae5 100644 --- a/app/locker-room/page.jsx +++ b/app/locker-room/page.jsx @@ -348,7 +348,7 @@ export default function LockerRoom() { ); }; - if (!profile) return

No active profile — sign in first, then come back to the Locker Room.

; + if (!profile) return

No active profile — sign in first, then come back to the Locker Room.

; // ── Chart-first layout: ungated bodyweight on top, photos behind the toggle ── const photosVisible = shown && photos !== null; @@ -367,7 +367,7 @@ export default function LockerRoom() { const markerX = !photos?.length ? 0 : photos.length === 1 ? curveW / 2 : (pos / (photos.length - 1)) * curveW; return ( -
+
{picker} {/* Header anatomy mirrors the Performance Lab (#73c/d): safe-area-aware back-nav row with the photos toggle right-aligned, then eyebrow + @@ -479,6 +479,6 @@ export default function LockerRoom() { )}
)} -
+ ); } diff --git a/app/not-found.jsx b/app/not-found.jsx index 6714666..534a391 100644 --- a/app/not-found.jsx +++ b/app/not-found.jsx @@ -11,7 +11,7 @@ export const metadata = { // Server component by design — no client JS needed to get someone home. export default function NotFound() { return ( -
Take me home -
+ ); } diff --git a/components/BugReportSheet.jsx b/components/BugReportSheet.jsx index 2e86c64..a9dee76 100644 --- a/components/BugReportSheet.jsx +++ b/components/BugReportSheet.jsx @@ -65,7 +65,7 @@ export default function BugReportSheet({ profileName = null, onClose }) { maxLength={2000} rows={5} placeholder={COPY.placeholder} - style={{ width: "100%", background: T.well, border: "none", borderRadius: T.r, padding: "14px 16px", fontSize: 14, lineHeight: 1.5, color: T.ink, fontFamily: T.text, resize: "none", outline: "none", marginBottom: 8 }} + style={{ width: "100%", background: T.well, border: "none", borderRadius: T.r, padding: "14px 16px", fontSize: 16, lineHeight: 1.5, color: T.ink, fontFamily: T.text, resize: "none", outline: "none", marginBottom: 8 }} /> )} {state === "failed" && ( diff --git a/tests/a11y-contract.test.js b/tests/a11y-contract.test.js new file mode 100644 index 0000000..b445d03 --- /dev/null +++ b/tests/a11y-contract.test.js @@ -0,0 +1,80 @@ +// tests/a11y-contract.test.js +// ───────────────────────────────────────────────────────────────────────────── +// Two landmark/zoom invariants that used to live only in comments. +// +// 1. ONE
. app/layout.jsx marks .forge-page as the document's main +// landmark, so every route inherits one. A route rendering its own nests a +// second inside the first, which is a landmark error rather than a +// duplicate — /locker-room, /not-found and /diag-bugs each did before the +// layout took the job. +// +// 2. NO focusable input below 16px. app/layout.jsx deliberately ships no +// maximumScale/userScalable lock so people who magnify can pinch-zoom. The +// one thing that lock plausibly still bought was suppressing iOS's +// zoom-on-focus, which fires on inputs under 16px — so the floor is the +// thing keeping the lock unnecessary. BugReportSheet's textarea sat at 14px. +// ───────────────────────────────────────────────────────────────────────────── + +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve, join } from "node:path"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const files = []; +const walk = (d) => { + for (const f of readdirSync(resolve(root, d), { withFileTypes: true })) { + const rel = join(d, f.name); + if (f.isDirectory()) walk(rel); + else if (/\.jsx$/.test(f.name)) files.push(rel.replace(/\\/g, "/")); + } +}; +walk("components"); +walk("app"); + +const read = (rel) => readFileSync(resolve(root, rel), "utf8"); + +// Read one JSX tag from `<` to its matching `>`, ignoring the `>` inside arrow +// functions and nested braces (onChange={e=>...} is why a lazy /.*?>/ can't +// do this). +function tagAt(src, start) { + let depth = 0; + for (let i = start; i < src.length; i++) { + const c = src[i]; + if (c === "{") depth++; + else if (c === "}") depth--; + else if (c === ">" && depth === 0) return src.slice(start, i + 1); + } + return src.slice(start); +} + +describe("a11y contract", () => { + it("only the root layout declares
", () => { + const offenders = files.filter((rel) => rel !== "app/layout.jsx" && /]/.test(read(rel))); + expect( + offenders, + `
nests inside the layout's own — render a
: ${offenders.join(", ")}`, + ).toEqual([]); + }); + + it("no focusable input sets a font-size below 16px (iOS zoom-on-focus)", () => { + // Only explicit sub-16 values are flagged. An input that sets no fontSize + // inherits one, which this cannot see and should not guess at; the + // regression shape being caught is someone typing `fontSize: 14`. + const offenders = []; + for (const rel of files) { + const src = read(rel); + for (const m of src.matchAll(/<(input|textarea|select)\b/g)) { + const tag = tagAt(src, m.index); + // A file picker is never focused visually — both of ours are display:none. + if (/type="file"/.test(tag) || /display:\s*["']none["']/.test(tag)) continue; + const size = tag.match(/fontSize:\s*(\d+)/); + if (size && Number(size[1]) < 16) offenders.push(`${rel}: <${m[1]}> at ${size[1]}px`); + } + } + expect( + offenders, + `16px is the floor that keeps the viewport zoom lock unnecessary: ${offenders.join(" | ")}`, + ).toEqual([]); + }); +}); From 3dd2f38bcecd4061bbbd24041b21a65478a19231 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 13:55:48 +0000 Subject: [PATCH 2/3] Add a read-only passkey census Reports what is actually in the credential store: verifiable vs keyless legacy credentials, stray sibling blobs, and how many credentials are bound to the theforged.fit rpId. CRON_SECRET-gated, reads only, and tested to stay that way. --- app/api/diag/passkey-census/route.js | 88 ++++++++++++++ lib/passkey-census.js | 157 +++++++++++++++++++++++++ tests/passkey-census.test.js | 167 +++++++++++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 app/api/diag/passkey-census/route.js create mode 100644 lib/passkey-census.js create mode 100644 tests/passkey-census.test.js diff --git a/app/api/diag/passkey-census/route.js b/app/api/diag/passkey-census/route.js new file mode 100644 index 0000000..91fc7fe --- /dev/null +++ b/app/api/diag/passkey-census/route.js @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { rateLimit } from "@/lib/rate-limit"; +import { list } from "@vercel/blob"; +import { readJsonDirect } from "@/lib/blob-utils"; +import { censusPasskeys } from "@/lib/passkey-census"; + +// PASSKEY CENSUS — READ ONLY, by design and by protocol. +// GET /api/diag/passkey-census (Authorization: Bearer ) +// +// Wipe-protocol step 2 for the credential store: before any re-enrolment or +// cleanup work is designed, read the REAL store and report what is actually +// there. This route lists and reads. It imports no writer — no put, no del — +// and that absence is asserted by tests/passkey-census.test.js, because the +// 2026-07-09 incident was a read-shaped job that had grown teeth. +// +// Gated exactly as /api/diag/db-import is, and for the same reason: this is a +// WHOLE-NAMESPACE enumeration, and profile name IS the identity here, so an +// open census would hand over every user's key at once. Fails closed when +// CRON_SECRET is unset. +// +// The counting lives in lib/passkey-census.js (pure, tested). This file is +// only the gate and the I/O. + +const decode = (s) => { try { return decodeURIComponent(s); } catch { return s; } }; + +export async function GET(request) { + // Whole-store enumeration plus a read per profile — the most expensive + // request in the app. Throttle harder than the blob census. + const limited = rateLimit(request, "diag-passkey-census", 3); + if (limited) return limited; + + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + return NextResponse.json({ error: "CRON_SECRET not configured" }, { status: 500 }); + } + if (request.headers.get("authorization") !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Enumerate credential blobs only — prefix-scoped to forge/profiles/, then + // matched on the credentials filename the auth routes write. Nothing else + // in the namespace is touched or reported. + const found = []; + let cursor; + try { + do { + const page = await list({ prefix: "forge/profiles/", cursor, limit: 1000 }); + for (const b of page.blobs) { + const m = b.pathname.match(/^forge\/profiles\/([^/]+)\/credentials[^/]*\.json$/); + if (!m) continue; + found.push({ + profile: decode(m[1]), + pathname: b.pathname, + // The SDK hands back a Date; the census works in ISO strings so the + // report is JSON-stable and the pure function stays string-only. + uploadedAt: b.uploadedAt ? new Date(b.uploadedAt).toISOString() : "", + size: b.size || 0, + doc: null, + }); + } + cursor = page.cursor; + } while (cursor); + } catch (e) { + return NextResponse.json({ error: `credential census failed: ${e.message}` }, { status: 500 }); + } + + // Read ONLY the authoritative document per profile — the newest, which is + // the one readJsonByPrefix resolves for a real ceremony. Older siblings are + // counted as strays without being read: they are invisible to auth, so + // reading them would cost a request per stray to report a number that means + // nothing. + const newest = new Map(); + for (const f of found) { + const t = Date.parse(f.uploadedAt || "") || 0; + const cur = newest.get(f.profile); + if (!cur || t >= cur.t) newest.set(f.profile, { f, t }); + } + for (const { f } of newest.values()) { + f.doc = await readJsonDirect(f.pathname); + } + + return NextResponse.json({ + dryRun: true, + writes: "none — enumeration and reads only", + scanned: { prefix: "forge/profiles/", matched: "credentials*.json" }, + ...censusPasskeys(found), + }); +} diff --git a/lib/passkey-census.js b/lib/passkey-census.js new file mode 100644 index 0000000..80655a3 --- /dev/null +++ b/lib/passkey-census.js @@ -0,0 +1,157 @@ +// @ts-check +// lib/passkey-census.js +// ───────────────────────────────────────────────────────────────────────────── +// Wipe-protocol step 2 for the passkey store: enumerate what actually lives +// under forge/profiles/*/credentials TODAY, rather than reciting what the code +// should have written. Pure — the route hands it blobs it already read, so +// every branch is testable without a network or a store. +// +// It exists because "legacy passkey" names TWO different populations and they +// need separate counts: +// +// 1. KEYLESS legacy — written by the pre-verification code, carrying an id +// and a raw attestation but no usable publicKey. hasRealPasskey ignores +// them (lib/auth-server.js), so their owners read as unprotected and heal +// on re-registration. Inert, but they still occupy the document. +// +// 2. rpId legacy — EVERY credential, including ones minted this morning, is +// scoped to rpId "theforged.fit"; nothing in the stored shape says so +// because nothing ever wrote an rpId field. That is the load-bearing +// count: a credential's rpId is fixed at creation and cannot be migrated +// server-side, so this number is a re-enrolment backlog, not a data fix. +// +// NEVER emits credential ids, public keys or transports. The census answers +// "how many, of what kind, how old" — the identifying material is exactly what +// a census does not need. +// ───────────────────────────────────────────────────────────────────────────── + +import { FLIP_DATE } from "./origin.js"; + +// What an unlabelled credential IS. Every ceremony declares this rpId +// (lib/auth-server.js rpConfigFromRequest), so a stored credential carrying no +// rpId field is a theforged.fit credential — not an unknown one. +export const IMPLICIT_RP_ID = "theforged.fit"; + +/** @param {{ publicKey?: string }} c */ +const isVerifiable = (c) => !!c && typeof c.publicKey === "string" && c.publicKey.length > 0; + +/** @param {{ rpId?: string }} c */ +const rpIdOf = (c) => (typeof c?.rpId === "string" && c.rpId ? c.rpId : IMPLICIT_RP_ID); + +const bump = (/** @type {Record} */ m, /** @type {string} */ k) => { + m[k] = (m[k] || 0) + 1; +}; + +/** + * @typedef {object} CredentialBlob + * @property {string} profile decoded profile directory name + * @property {string} pathname full blob path + * @property {string} [uploadedAt] + * @property {number} [size] + * @property {any} doc parsed JSON, or null when unreadable + */ + +/** + * @param {CredentialBlob[]} blobs every blob under a credentials prefix + * @param {string} flipDate ISO date the domain moved + */ +export function censusPasskeys(blobs = [], flipDate = FLIP_DATE) { + /** @type {Record} */ + const profiles = {}; + + for (const b of blobs) { + const p = (profiles[b.profile] ||= { + blobs: 0, strays: 0, bytes: 0, unreadableBlobs: 0, + credentials: 0, verifiable: 0, keyless: 0, + rpIds: {}, preFlip: 0, postFlip: 0, undated: 0, + oldest: null, newest: null, + }); + p.blobs++; + p.bytes += b.size || 0; + } + + // One document per profile is authoritative: readJsonByPrefix picks the + // NEWEST by uploadedAt, so the census must read the same one the auth + // routes would. Older siblings are counted as strays and never tallied — + // tallying them would double-count credentials that no ceremony can see. + const byProfile = new Map(); + for (const b of blobs) { + const cur = byProfile.get(b.profile); + const t = Date.parse(b.uploadedAt || "") || 0; + if (!cur || t >= cur.t) byProfile.set(b.profile, { blob: b, t }); + } + + for (const [profile, { blob }] of byProfile) { + const p = profiles[profile]; + p.strays = p.blobs - 1; + p.authoritative = blob.pathname; + + const creds = Array.isArray(blob.doc?.credentials) ? blob.doc.credentials : null; + if (!creds) { + p.unreadableBlobs = 1; + continue; + } + for (const c of creds) { + p.credentials++; + if (isVerifiable(c)) p.verifiable++; + else p.keyless++; + bump(p.rpIds, rpIdOf(c)); + const created = typeof c?.createdAt === "string" ? c.createdAt.slice(0, 10) : null; + if (!created) p.undated++; + else { + if (flipDate && created < flipDate) p.preFlip++; + else p.postFlip++; + if (!p.oldest || created < p.oldest) p.oldest = created; + if (!p.newest || created > p.newest) p.newest = created; + } + } + // Mirrors hasRealPasskey: a profile is protected only by a credential a + // signature can actually be checked against. + p.protected = p.verifiable > 0; + } + + const all = Object.values(profiles); + const sum = (/** @type {(p: any) => number} */ f) => all.reduce((n, p) => n + (f(p) || 0), 0); + /** @type {Record} */ + const rpIds = {}; + for (const p of all) for (const [k, n] of Object.entries(p.rpIds)) rpIds[k] = (rpIds[k] || 0) + (/** @type {number} */ (n)); + + const credentials = sum((p) => p.credentials); + const native = Object.entries(rpIds) + .filter(([k]) => k !== IMPLICIT_RP_ID) + .reduce((n, [, v]) => n + v, 0); + + return { + totals: { + profilesWithCredentialBlobs: all.length, + credentialBlobs: sum((p) => p.blobs), + straySiblings: sum((p) => p.strays), + unreadableDocuments: sum((p) => p.unreadableBlobs), + bytes: sum((p) => p.bytes), + credentials, + verifiable: sum((p) => p.verifiable), + keylessLegacy: sum((p) => p.keyless), + profilesProtected: all.filter((p) => p.protected).length, + profilesWithNoVerifiableCredential: all.filter((p) => p.credentials > 0 && !p.protected).length, + mintedPreFlip: sum((p) => p.preFlip), + mintedPostFlip: sum((p) => p.postFlip), + undated: sum((p) => p.undated), + rpIds, + }, + // Stated as a finding rather than left for the reader to derive: this is + // the number the census exists to produce. + dependency: { + rpIdInUse: IMPLICIT_RP_ID, + credentialsBoundToLegacyDomain: rpIds[IMPLICIT_RP_ID] || 0, + credentialsNativeToHeatwayve: native, + note: + `Every ceremony declares rpId "${IMPLICIT_RP_ID}", so browsers must fetch ` + + `https://${IMPLICIT_RP_ID}/.well-known/webauthn to permit a login from heatwayve.app. ` + + `While that is true, ${IMPLICIT_RP_ID} is a hard authentication dependency: if it stops ` + + `resolving, no passkey can complete a ceremony. An rpId is fixed at credential ` + + `creation and cannot be rewritten server-side — clearing this requires each holder ` + + `to register a new passkey, not a migration script.`, + }, + profiles, + }; +} diff --git a/tests/passkey-census.test.js b/tests/passkey-census.test.js new file mode 100644 index 0000000..fa95d45 --- /dev/null +++ b/tests/passkey-census.test.js @@ -0,0 +1,167 @@ +// tests/passkey-census.test.js +// ───────────────────────────────────────────────────────────────────────────── +// The passkey census. Two things are pinned here, and the second matters more +// than the first. +// +// 1. The counting is right — in particular it tallies only the AUTHORITATIVE +// document per profile, the one a real ceremony resolves. A stray sibling +// left by a failed sweep must never inflate the count. +// +// 2. The route stays READ ONLY. The 2026-07-09 incident was a job that read +// like a caretaker and shipped with delete authority; it sat unarmed for +// weeks and then removed every user's passkey on its first real run. A +// census is the exact shape of thing that acquires a "while we're here" +// cleanup later. It does not get to. This test is the lock. +// +// 3. Nothing identifying leaves the process. A census needs counts, not +// credential ids or public keys. +// ───────────────────────────────────────────────────────────────────────────── + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { censusPasskeys, IMPLICIT_RP_ID } from "../lib/passkey-census.js"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const routeSrc = readFileSync(resolve(root, "app/api/diag/passkey-census/route.js"), "utf8"); + +const blob = (profile, name, uploadedAt, doc, size = 100) => ({ + profile, pathname: `forge/profiles/${profile}/${name}`, uploadedAt, size, doc, +}); + +describe("passkey census — the route can never delete", () => { + it("imports no writer from the blob SDK", () => { + const imports = routeSrc.match(/import\s*\{[^}]*\}\s*from\s*"@vercel\/blob"/s)?.[0] || ""; + expect(imports).toContain("list"); + for (const writer of ["put", "del", "copy"]) { + expect(imports).not.toMatch(new RegExp(`\\b${writer}\\b`)); + } + }); + + it("calls no destructive or mutating operation anywhere in the file", () => { + for (const banned of [/\bdel\s*\(/, /\bput\s*\(/, /\bdeleteByPrefix\b/, /\bwriteJson/, /\bDROP\b/]) { + expect(routeSrc).not.toMatch(banned); + } + }); + + it("exposes only GET — no method that implies a mutation", () => { + const methods = [...routeSrc.matchAll(/export async function ([A-Z]+)/g)].map((m) => m[1]); + expect(methods).toEqual(["GET"]); + }); + + it("fails closed when CRON_SECRET is unset, and checks the bearer", () => { + expect(routeSrc).toContain("CRON_SECRET not configured"); + expect(routeSrc).toContain('`Bearer ${cronSecret}`'); + }); + + it("scopes its listing to the credentials it owns, never the whole store", () => { + expect(routeSrc).toContain('prefix: "forge/profiles/"'); + // A trailing slash on the prefix and a filename match: the census cannot + // wander into a sibling namespace or a profile whose name merely shares a + // stem with another. + expect(routeSrc).toMatch(/\^forge\\\/profiles\\\/\(\[\^\/\]\+\)\\\/credentials/); + }); +}); + +describe("passkey census — counting", () => { + it("counts only the authoritative document, never a stray sibling", () => { + const r = censusPasskeys([ + blob("sam", "credentials-new.json", "2026-08-01T00:00:00Z", { + credentials: [{ id: "A", publicKey: "k", createdAt: "2026-08-01T00:00:00Z" }], + }), + // Older sibling from a sweep that failed — invisible to a ceremony. + blob("sam", "credentials-old.json", "2026-06-01T00:00:00Z", { + credentials: [{ id: "B", publicKey: "k" }, { id: "C", publicKey: "k" }], + }), + ]); + expect(r.totals.credentials).toBe(1); + expect(r.totals.credentialBlobs).toBe(2); + expect(r.totals.straySiblings).toBe(1); + }); + + it("separates keyless legacy credentials from verifiable ones", () => { + const r = censusPasskeys([ + blob("ada", "credentials.json", "2026-08-01T00:00:00Z", { + credentials: [ + { id: "A", publicKey: "k", createdAt: "2026-08-02T00:00:00Z" }, + { id: "B", createdAt: "2026-05-02T00:00:00Z" }, // pre-verification + { id: "C", publicKey: "", createdAt: "2026-05-03T00:00:00Z" }, // empty key is not a key + ], + }), + ]); + expect(r.totals.verifiable).toBe(1); + expect(r.totals.keylessLegacy).toBe(2); + // Mirrors hasRealPasskey: one verifiable credential is protection. + expect(r.totals.profilesProtected).toBe(1); + }); + + it("reports a profile whose only credentials are keyless as unprotected", () => { + const r = censusPasskeys([ + blob("kit", "credentials.json", "2026-08-01T00:00:00Z", { credentials: [{ id: "A" }] }), + ]); + expect(r.totals.profilesProtected).toBe(0); + expect(r.totals.profilesWithNoVerifiableCredential).toBe(1); + }); + + it("treats an unlabelled credential as bound to the legacy domain", () => { + // The honest reading: nothing ever wrote an rpId field, and every ceremony + // declares theforged.fit — so absent is not unknown. + const r = censusPasskeys([ + blob("sam", "credentials.json", "2026-08-01T00:00:00Z", { + credentials: [{ id: "A", publicKey: "k" }, { id: "B", publicKey: "k", rpId: "heatwayve.app" }], + }), + ]); + expect(r.totals.rpIds[IMPLICIT_RP_ID]).toBe(1); + expect(r.dependency.credentialsBoundToLegacyDomain).toBe(1); + expect(r.dependency.credentialsNativeToHeatwayve).toBe(1); + }); + + it("splits minting either side of the flip", () => { + const r = censusPasskeys([ + blob("sam", "credentials.json", "2026-08-01T00:00:00Z", { + credentials: [ + { id: "A", publicKey: "k", createdAt: "2026-07-01T00:00:00Z" }, + { id: "B", publicKey: "k", createdAt: "2026-08-01T00:00:00Z" }, + { id: "C", publicKey: "k" }, + ], + }), + ], "2026-07-26"); + expect(r.totals.mintedPreFlip).toBe(1); + expect(r.totals.mintedPostFlip).toBe(1); + expect(r.totals.undated).toBe(1); + }); + + it("survives an unreadable document without losing the rest of the store", () => { + const r = censusPasskeys([ + blob("ada", "credentials.json", "2026-08-01T00:00:00Z", null), + blob("sam", "credentials.json", "2026-08-01T00:00:00Z", { credentials: [{ id: "A", publicKey: "k" }] }), + ]); + expect(r.totals.unreadableDocuments).toBe(1); + expect(r.totals.credentials).toBe(1); + expect(r.totals.profilesWithCredentialBlobs).toBe(2); + }); + + it("is empty-safe", () => { + const r = censusPasskeys([]); + expect(r.totals.credentials).toBe(0); + expect(r.totals.profilesWithCredentialBlobs).toBe(0); + }); + + it("emits no credential id, public key or transport anywhere in the report", () => { + const r = censusPasskeys([ + blob("sam", "credentials.json", "2026-08-01T00:00:00Z", { + credentials: [{ + id: "CREDENTIAL-ID-SECRET", + publicKey: "PUBLIC-KEY-SECRET", + transports: ["internal"], + createdAt: "2026-08-01T00:00:00Z", + }], + }), + ]); + const dumped = JSON.stringify(r); + expect(dumped).not.toContain("CREDENTIAL-ID-SECRET"); + expect(dumped).not.toContain("PUBLIC-KEY-SECRET"); + expect(dumped).not.toContain("internal"); + }); +}); From ecf1fc2384b9e200b294b680110a4ba67207099e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 14:25:50 +0000 Subject: [PATCH 3/3] Mint heatwayve-native passkeys and record each credential's rpId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration from a heatwayve origin now mints under rpId heatwayve.app; the legacy origin still mints legacy, since no reverse Related Origin Requests document exists. Both verify routes accept the rpId set and store the one the library matched, and login backfills it onto older credentials from the verified assertion. Login plans a single-rpId ceremony and offers only that pool's credentials, preferring native. Sunset is 2026-11-16. The census also reports which profiles' photos a sunset wipe would target. It reports only — no delete path exists. --- app/api/auth/login-options/route.js | 31 ++-- app/api/auth/login-verify/route.js | 41 ++++- app/api/auth/register-options/route.js | 11 +- app/api/auth/register-verify/route.js | 14 +- app/api/diag/passkey-census/route.js | 39 +++-- lib/auth-server.js | 68 +++++++- lib/origin.js | 44 +++++ lib/passkey-census.js | 97 ++++++++++- tests/auth-server.test.js | 36 ++-- tests/passkey-census.test.js | 71 +++++++- tests/passkey-migration.test.js | 231 +++++++++++++++++++++++++ 11 files changed, 631 insertions(+), 52 deletions(-) create mode 100644 tests/passkey-migration.test.js diff --git a/app/api/auth/login-options/route.js b/app/api/auth/login-options/route.js index 93d6dfc..5ad05ef 100644 --- a/app/api/auth/login-options/route.js +++ b/app/api/auth/login-options/route.js @@ -3,7 +3,7 @@ import { rateLimit } from "@/lib/rate-limit"; import { put } from "@vercel/blob"; import crypto from "crypto"; import { readJsonByPrefix } from "@/lib/blob-utils"; -import { hasChallengeSecret, issueChallenge } from "@/lib/auth-server"; +import { hasChallengeSecret, issueChallenge, rpConfigFromRequest, planLoginCeremony } from "@/lib/auth-server"; // Generate authentication options for WebAuthn // POST /api/auth/login-options @@ -25,10 +25,21 @@ export async function POST(request) { // Find credentials for this profile const credData = await readJsonByPrefix(credentialsPrefix(profile)); - - if (!credData?.credentials?.length) { + + // Plan the ceremony BEFORE issuing a challenge. A ceremony is single-rpId, + // so this picks one pool — native whenever the profile has one — and + // offers only credentials from it. Offering the other pool's credentials + // would spend a Face ID prompt on something the authenticator cannot use. + // + // Null means nothing usable is left: no verifiable credential, or the only + // ones are bound to an rpId that no longer completes a ceremony. Both read + // as "no passkey" to the client, which re-offers setup — the same path a + // keyless legacy credential has always taken. + const config = rpConfigFromRequest(request); + const plan = planLoginCeremony(credData, config); + if (!plan) { return NextResponse.json( - { error: "No passkey registered for this profile" }, + { error: "No passkey registered for this profile", needsRegister: true }, { status: 404 } ); } @@ -55,18 +66,16 @@ export async function POST(request) { }); } - // RP ID must be consistent between registration and authentication - const host = request.headers.get("host") || ""; - const rpId = host.includes("localhost") ? "localhost" : "theforged.fit"; - return NextResponse.json({ challenge, - rpId, + // Both from the plan, so the declared rpId and the offered credentials + // can never disagree. + rpId: plan.rpId, timeout: 60000, - allowCredentials: credData.credentials.map(cred => ({ + allowCredentials: plan.credentials.map(cred => ({ id: cred.id, // Use credential id, not rawId type: "public-key", - transports: ["internal", "hybrid"], + transports: cred.transports?.length ? cred.transports : ["internal", "hybrid"], })), userVerification: "required", }); diff --git a/app/api/auth/login-verify/route.js b/app/api/auth/login-verify/route.js index ccc5135..2dc8855 100644 --- a/app/api/auth/login-verify/route.js +++ b/app/api/auth/login-verify/route.js @@ -5,6 +5,7 @@ import crypto from "crypto"; import { verifyAuthenticationResponse } from "@simplewebauthn/server"; import { readJsonDirect, readJsonByPrefix, deleteByPrefix, writeJsonReplacingPrefix } from "@/lib/blob-utils"; import { rpConfigFromRequest, hasChallengeSecret, verifyChallenge, mintAuthToken, isAdminProfile } from "@/lib/auth-server"; +import { LEGACY_RP_ID, passkeyNudgeUrgent, daysUntilPasskeySunset } from "@/lib/origin"; // Verify WebAuthn authentication and mint a short-lived auth token. // POST /api/auth/login-verify @@ -72,14 +73,16 @@ export async function POST(request) { } // Really verify the assertion signature. - const { rpId, expectedOrigin } = rpConfigFromRequest(request); + const { acceptedRpIds, expectedOrigin } = rpConfigFromRequest(request); let verification; try { verification = await verifyAuthenticationResponse({ response: { ...credential, clientExtensionResults: credential.clientExtensionResults || {} }, expectedChallenge, expectedOrigin, - expectedRPID: rpId, + // Both rpIds while the window is open. Which one this credential is + // actually bound to is the library's answer, not our assumption. + expectedRPID: acceptedRpIds, requireUserVerification: true, credential: { id: matchingCred.id, @@ -100,11 +103,26 @@ export async function POST(request) { // accepts; a hardware authenticator that ever regresses its counter would // have been rejected above. const newCounter = verification.authenticationInfo.newCounter; - if (typeof newCounter === "number" && newCounter !== matchingCred.counter) { + // rpId BACKFILL. Credentials written before per-credential rpId storage + // carry no rpId field, and a successful assertion is the one moment we + // learn the answer authoritatively — the library reports the rpId it + // matched. Stamping it here is what lets login-options later offer the + // right single-rpId pool without inferring anything. It rides the counter + // write rather than adding a second one. + const verifiedRpId = verification.authenticationInfo.rpID || null; + const counterChanged = typeof newCounter === "number" && newCounter !== matchingCred.counter; + const rpIdChanged = !!verifiedRpId && matchingCred.rpId !== verifiedRpId; + if (counterChanged || rpIdChanged) { try { const updated = { credentials: credData.credentials.map((c) => - c.id === matchingCred.id ? { ...c, counter: newCounter } : c, + c.id === matchingCred.id + ? { + ...c, + ...(counterChanged ? { counter: newCounter } : null), + ...(verifiedRpId ? { rpId: verifiedRpId } : null), + } + : c, ), }; // Write-first, sweep-after — see audit #6 / writeJsonReplacingPrefix. @@ -142,11 +160,26 @@ export async function POST(request) { // hw_photos: any active day rotates it, so a device in use never re-auths. const syncToken = await mintAuthToken({ profile, ttlMs: 30 * 86400000, scope: "sync" }); + // Upgrade signal. A login that verified against the LEGACY rpId is a + // credential that stops working at the sunset, so the client is told once, + // at the only moment it is certain — the client decides how loudly to say + // it (quiet on the profile page, insistent in the closing stretch). + const onLegacyCredential = verifiedRpId === LEGACY_RP_ID; + const res = NextResponse.json({ ok: true, verified: true, profile: normalise(profile), authToken, expiresIn: 3600, // Single-admin recognition: a UI hint only — every admin surface // re-verifies the token's profile server-side. admin: isAdminProfile(profile), + ...(onLegacyCredential + ? { + passkeyUpgrade: { + needed: true, + urgent: passkeyNudgeUrgent(), + daysLeft: daysUntilPasskeySunset(), + }, + } + : null), }); res.cookies.set("hw_photos", photoToken, { httpOnly: true, secure: true, sameSite: "strict", path: "/api/photos", maxAge: 7 * 86400, diff --git a/app/api/auth/register-options/route.js b/app/api/auth/register-options/route.js index 7e7fa6f..7edecbf 100644 --- a/app/api/auth/register-options/route.js +++ b/app/api/auth/register-options/route.js @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { rateLimit } from "@/lib/rate-limit"; import { list } from "@vercel/blob"; import crypto from "crypto"; -import { hasChallengeSecret, issueChallenge } from "@/lib/auth-server"; +import { hasChallengeSecret, issueChallenge, rpConfigFromRequest } from "@/lib/auth-server"; // Generate registration options for WebAuthn // POST /api/auth/register-options @@ -50,10 +50,11 @@ export async function POST(request) { }); } - // RP ID must be consistent between registration and authentication - // Use the actual domain in production, localhost in dev - const host = request.headers.get("host") || ""; - const rpId = host.includes("localhost") ? "localhost" : "theforged.fit"; + // RP ID must be consistent between registration and authentication, and + // is now decided in one place (lib/auth-server.js) so options and verify + // can never drift. From a heatwayve origin this mints a NATIVE credential; + // the legacy rpId is only used by a ceremony genuinely on the old domain. + const { rpId } = rpConfigFromRequest(request); return NextResponse.json({ challenge, diff --git a/app/api/auth/register-verify/route.js b/app/api/auth/register-verify/route.js index 9fd241f..eb2f8f8 100644 --- a/app/api/auth/register-verify/route.js +++ b/app/api/auth/register-verify/route.js @@ -78,14 +78,18 @@ export async function POST(request) { } // Really verify the attestation and extract the public key. - const { rpId, expectedOrigin } = rpConfigFromRequest(request); + const { acceptedRpIds, expectedOrigin } = rpConfigFromRequest(request); let verification; try { verification = await verifyRegistrationResponse({ response: { ...credential, clientExtensionResults: credential.clientExtensionResults || {} }, expectedChallenge, expectedOrigin, - expectedRPID: rpId, + // Both rpIds during the migration window. A credential minted from a + // heatwayve origin is native; one minted on the old domain is legacy. + // The library reports which matched, so it is recorded rather than + // assumed. + expectedRPID: acceptedRpIds, requireUserVerification: true, }); } catch (e) { @@ -103,6 +107,12 @@ export async function POST(request) { counter: vc.counter, transports: vc.transports || credential.response?.transports || [], createdAt: new Date().toISOString(), + // The rpId this credential is bound to, as VERIFIED — not as requested. + // It is immutable for the life of the credential and decides which + // ceremony can ever use it, so it comes from the library's match rather + // than from what we asked for. Falls back to the requested rpId only if + // a future library version stops reporting it. + rpId: verification.registrationInfo.rpID || rpConfigFromRequest(request).rpId, }; // Keep other REAL credentials (minus any id collision), DROP keyless legacy diff --git a/app/api/diag/passkey-census/route.js b/app/api/diag/passkey-census/route.js index 91fc7fe..489d19f 100644 --- a/app/api/diag/passkey-census/route.js +++ b/app/api/diag/passkey-census/route.js @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { rateLimit } from "@/lib/rate-limit"; import { list } from "@vercel/blob"; import { readJsonDirect } from "@/lib/blob-utils"; -import { censusPasskeys } from "@/lib/passkey-census"; +import { censusPasskeys, photosAtRisk } from "@/lib/passkey-census"; // PASSKEY CENSUS — READ ONLY, by design and by protocol. // GET /api/diag/passkey-census (Authorization: Bearer ) @@ -41,22 +41,30 @@ export async function GET(request) { // matched on the credentials filename the auth routes write. Nothing else // in the namespace is touched or reported. const found = []; + const photos = []; let cursor; try { do { const page = await list({ prefix: "forge/profiles/", cursor, limit: 1000 }); for (const b of page.blobs) { const m = b.pathname.match(/^forge\/profiles\/([^/]+)\/credentials[^/]*\.json$/); - if (!m) continue; - found.push({ - profile: decode(m[1]), - pathname: b.pathname, - // The SDK hands back a Date; the census works in ISO strings so the - // report is JSON-stable and the pure function stays string-only. - uploadedAt: b.uploadedAt ? new Date(b.uploadedAt).toISOString() : "", - size: b.size || 0, - doc: null, - }); + if (m) { + found.push({ + profile: decode(m[1]), + pathname: b.pathname, + // The SDK hands back a Date; the census works in ISO strings so the + // report is JSON-stable and the pure function stays string-only. + uploadedAt: b.uploadedAt ? new Date(b.uploadedAt).toISOString() : "", + size: b.size || 0, + doc: null, + }); + continue; + } + // Progress photos, for the sunset kill list. Enumerated here because + // this pass is already walking the namespace — no extra listing, and + // no read: a photo's bytes are never opened, only counted. + const ph = b.pathname.match(/^forge\/profiles\/([^/]+)\/photos\/[^/]+$/); + if (ph) photos.push({ profile: decode(ph[1]), pathname: b.pathname, size: b.size || 0 }); } cursor = page.cursor; } while (cursor); @@ -79,10 +87,15 @@ export async function GET(request) { f.doc = await readJsonDirect(f.pathname); } + const census = censusPasskeys(found); return NextResponse.json({ dryRun: true, writes: "none — enumeration and reads only", - scanned: { prefix: "forge/profiles/", matched: "credentials*.json" }, - ...censusPasskeys(found), + scanned: { prefix: "forge/profiles/", matched: ["credentials*.json", "photos/*"] }, + ...census, + // What a sunset photo wipe WOULD remove. Reported, never executed: no + // delete path exists yet, and per protocol it does not get written until + // these numbers have been read off the real store. + photoExposure: photosAtRisk(census, photos), }); } diff --git a/lib/auth-server.js b/lib/auth-server.js index c7c9343..128f70d 100644 --- a/lib/auth-server.js +++ b/lib/auth-server.js @@ -22,6 +22,7 @@ import crypto from "crypto"; import { put } from "@vercel/blob"; import { readJsonDirect } from "./blob-utils.js"; +import { NATIVE_RP_ID, LEGACY_RP_ID, acceptedRpIds, legacyRpRetired } from "./origin.js"; import { hasDb, dbInsertToken, dbReadToken } from "./db.js"; const normalise = (name) => String(name || "").trim().toLowerCase(); @@ -114,15 +115,74 @@ const ALLOWED_ORIGIN_HOSTS = new Set([ "www.heatwayve.app", ]); -export function rpConfigFromRequest(request) { +export function rpConfigFromRequest(request, now = Date.now()) { const host = (request.headers.get("host") || "").toLowerCase(); if (host.includes("localhost")) { - return { rpId: "localhost", expectedOrigin: `http://${host}` }; + return { rpId: "localhost", expectedOrigin: `http://${host}`, acceptedRpIds: ["localhost"] }; } const origin = ALLOWED_ORIGIN_HOSTS.has(host) ? `https://${host}` - : "https://theforged.fit"; // unknown host: fail toward the legacy origin - return { rpId: "theforged.fit", expectedOrigin: origin }; + : `https://${NATIVE_RP_ID}`; // unknown host: fail toward the live origin + // WHICH rpId A NEW CREDENTIAL IS MINTED UNDER. + // + // An rpId must be a registrable suffix of the ceremony origin, or be + // permitted by a Related Origin Requests document served at the rpId's own + // origin. We serve one at theforged.fit listing the heatwayve origins, which + // is what lets a heatwayve ceremony mint (and use) a theforged.fit + // credential. There is NO document in the other direction, so a ceremony + // that is genuinely on theforged.fit can only mint theforged.fit — minting + // native from there would be rejected by the browser. + const onLegacyOrigin = host === LEGACY_RP_ID || host === `www.${LEGACY_RP_ID}`; + const rpId = onLegacyOrigin && !legacyRpRetired(now) ? LEGACY_RP_ID : NATIVE_RP_ID; + // WHICH rpIds VERIFICATION MAY ACCEPT. Only this dimension is widened: the + // credential's rpId is unknown until the library matches it, whereas the + // origin is known exactly, so expectedOrigin stays pinned to one string. + return { rpId, expectedOrigin: origin, acceptedRpIds: acceptedRpIds(now) }; +} + +/** The rpId a stored credential belongs to. Absent means the field predates + * this work, and every ceremony then declared the legacy rpId — so absent is + * a legacy credential, not an unknown one. */ +export function credentialRpId(credential) { + const v = credential?.rpId; + return typeof v === "string" && v ? v : LEGACY_RP_ID; +} + +/** + * Plan a LOGIN ceremony. WebAuthn ceremonies are single-rpId, so offering a + * credential from the other pool guarantees a prompt the authenticator cannot + * satisfy. This picks ONE rpId and returns only the credentials that match it. + * + * Native is preferred whenever the profile holds one, so a user who has + * already upgraded never touches the legacy path again. Keyless credentials + * are never offered: no signature can be checked against them, so a ceremony + * using one fails at verification after costing the user a Face ID prompt. + * + * @param {{ credentials?: any[] } | null} credData + * @param {{ rpId: string, acceptedRpIds: string[] }} config + * @returns {{ rpId: string, credentials: any[] } | null} null when nothing usable remains + */ +export function planLoginCeremony(credData, config) { + const verifiable = (credData?.credentials || []).filter( + (c) => c && typeof c.publicKey === "string" && c.publicKey.length > 0, + ); + if (!verifiable.length) return null; + + // localhost dev: one rpId, no migration semantics. + if (config.rpId === "localhost") { + return { rpId: "localhost", credentials: verifiable }; + } + + const usable = config.acceptedRpIds.filter((id) => id !== "localhost"); + // Native first — the order in acceptedRpIds is the preference order. + for (const id of usable) { + const matching = verifiable.filter((c) => credentialRpId(c) === id); + // A ceremony on the legacy origin cannot declare the native rpId. + if (matching.length && (id === config.rpId || id === LEGACY_RP_ID || config.rpId === NATIVE_RP_ID)) { + return { rpId: id, credentials: matching }; + } + } + return null; } /** diff --git a/lib/origin.js b/lib/origin.js index 1d8a595..b02d17b 100644 --- a/lib/origin.js +++ b/lib/origin.js @@ -19,6 +19,50 @@ export const HEATWAYVE_HOSTS = new Set(["heatwayve.app", "www.heatwayve.app"]); export const FLIP_DATE = "2026-07-26"; // ARMED flip day (was null pre-flip) export const MIGRATION_WINDOW_DAYS = 60; +// ─── Passkey rpId migration (boss ruling, 2026-08-18) ──────────────────────── +// Every credential minted before this work is scoped to rpId "theforged.fit", +// including ones created from heatwayve.app (legal via Related Origin +// Requests). An rpId is fixed at credential creation and cannot be rewritten +// server-side, so clearing the dependency is a RE-ENROLMENT, not a migration: +// each holder registers a fresh passkey under the native rpId. +// +// theforged.fit is not being renewed. After the sunset its credentials cannot +// complete a ceremony at all — the browser can no longer fetch the ROR +// document at the rpId origin — so they stop being protection whether or not +// anything is deleted. That is why the sunset needs no sweeper: it is a date +// and a predicate, and the stored records simply stop counting. +export const NATIVE_RP_ID = "heatwayve.app"; +export const LEGACY_RP_ID = "theforged.fit"; + +// 90 days from the ruling. Passkeys minted from here on are native; holders of +// a legacy credential are prompted on login, quietly at first and then +// insistently for the final PASSKEY_NUDGE_DAYS. +export const PASSKEY_SUNSET = "2026-11-16"; +export const PASSKEY_NUDGE_DAYS = 30; + +/** True once the legacy rpId can no longer complete a ceremony. */ +export function legacyRpRetired(now = Date.now(), sunset = PASSKEY_SUNSET) { + if (!sunset) return false; + return now >= parseLocalDate(sunset).getTime(); +} + +/** rpIds a ceremony may verify against right now. Ordered: native first. */ +export function acceptedRpIds(now = Date.now(), sunset = PASSKEY_SUNSET) { + return legacyRpRetired(now, sunset) ? [NATIVE_RP_ID] : [NATIVE_RP_ID, LEGACY_RP_ID]; +} + +/** True in the closing stretch, when the upgrade prompt stops being quiet. */ +export function passkeyNudgeUrgent(now = Date.now(), sunset = PASSKEY_SUNSET) { + if (!sunset || legacyRpRetired(now, sunset)) return false; + return now >= parseLocalDate(sunset).getTime() - PASSKEY_NUDGE_DAYS * 86400000; +} + +/** Whole days left before legacy credentials stop working (0 once retired). */ +export function daysUntilPasskeySunset(now = Date.now(), sunset = PASSKEY_SUNSET) { + if (!sunset) return Infinity; + return Math.max(0, Math.ceil((parseLocalDate(sunset).getTime() - now) / 86400000)); +} + /** True only between the flip and flip+window. Null flip date = never. */ export function migrationWindowOpen(now = Date.now(), flipDate = FLIP_DATE) { if (!flipDate) return false; diff --git a/lib/passkey-census.js b/lib/passkey-census.js index 80655a3..99f1371 100644 --- a/lib/passkey-census.js +++ b/lib/passkey-census.js @@ -25,12 +25,12 @@ // a census does not need. // ───────────────────────────────────────────────────────────────────────────── -import { FLIP_DATE } from "./origin.js"; +import { FLIP_DATE, LEGACY_RP_ID, NATIVE_RP_ID } from "./origin.js"; // What an unlabelled credential IS. Every ceremony declares this rpId // (lib/auth-server.js rpConfigFromRequest), so a stored credential carrying no // rpId field is a theforged.fit credential — not an unknown one. -export const IMPLICIT_RP_ID = "theforged.fit"; +export const IMPLICIT_RP_ID = LEGACY_RP_ID; /** @param {{ publicKey?: string }} c */ const isVerifiable = (c) => !!c && typeof c.publicKey === "string" && c.publicKey.length > 0; @@ -155,3 +155,96 @@ export function censusPasskeys(blobs = [], flipDate = FLIP_DATE) { profiles, }; } + +// ─── Photo exposure ────────────────────────────────────────────────────────── +// Wipe-protocol step 1: report what a wipe WOULD remove, and remove nothing. +// +// The hazard, stated plainly (boss ruling, 2026-08-18): when the legacy rpId +// retires, a profile whose only passkey was legacy can no longer be proved by +// anyone, so the next person to register the name takes it. Training history +// can be squatted on. Progress photos cannot — handing a stranger someone +// else's body is the one outcome that has to be impossible. +// +// This produces the kill list and nothing else. It has no delete authority, is +// not reachable from a scheduler, and the route that calls it imports no +// writer. Whatever executes this later must be a separate, explicitly enabled +// path that the boss arms after reading the numbers below. +// +// It reports TWO buckets, deliberately not merged: +// · atSunset — legacy-only profiles that hold a verifiable passkey now +// and lose it on the sunset date. +// · alreadyOpen — profiles with NO verifiable credential at all. These are +// claimable TODAY, not at the sunset; their photos are a +// pre-existing exposure and a different decision. + +const PATHS_PER_PROFILE = 20; + +/** + * @param {ReturnType} census + * @param {Array<{ profile: string, pathname: string, size?: number }>} photoBlobs + */ +export function photosAtRisk(census, photoBlobs = []) { + /** @type {Record} */ + const byProfile = {}; + for (const b of photoBlobs) { + const e = (byProfile[b.profile] ||= { photos: 0, bytes: 0, paths: [] }); + e.photos++; + e.bytes += b.size || 0; + e.paths.push(b.pathname); + } + + const bucket = (/** @type {string[]} */ names) => { + const rows = names + .filter((n) => byProfile[n]) + .map((n) => { + const e = byProfile[n]; + return { + profile: n, + photos: e.photos, + bytes: e.bytes, + // The prefix a wipe would scope itself to. Trailing slash: it cannot + // reach a sibling profile whose name merely starts the same way. + prefix: `forge/profiles/${encodeURIComponent(n)}/photos/`, + paths: e.paths.slice(0, PATHS_PER_PROFILE).sort(), + // Never a silent cap — a truncated list must say so, or the reader + // takes a sample for the whole. + pathsOmitted: Math.max(0, e.paths.length - PATHS_PER_PROFILE), + }; + }) + .sort((a, b) => b.photos - a.photos); + return { + profiles: rows.length, + photos: rows.reduce((n, r) => n + r.photos, 0), + bytes: rows.reduce((n, r) => n + r.bytes, 0), + rows, + }; + }; + + const entries = Object.entries(census.profiles || {}); + // Loses its lock at the sunset: holds a verifiable credential, none native. + const atSunsetNames = entries + .filter(([, p]) => p.verifiable > 0 && !(p.rpIds?.[NATIVE_RP_ID] > 0)) + .map(([n]) => n); + // Already unprotected: a credential document exists but nothing in it can be + // verified. hasRealPasskey already reports these profiles as having no + // passkey, so the name is claimable now. + const alreadyOpenNames = entries + .filter(([, p]) => p.verifiable === 0) + .map(([n]) => n); + // Photos under a profile with no credential document at all. + const known = new Set(entries.map(([n]) => n)); + const noCredentials = Object.keys(byProfile).filter((n) => !known.has(n)); + + return { + dryRun: true, + deletes: "none — this reports a proposed kill list and nothing else", + scope: "forge/profiles//photos/ — prefix-scoped, per profile", + totals: { + profilesWithPhotos: Object.keys(byProfile).length, + photos: Object.values(byProfile).reduce((n, e) => n + e.photos, 0), + bytes: Object.values(byProfile).reduce((n, e) => n + e.bytes, 0), + }, + atSunset: bucket(atSunsetNames), + alreadyOpen: bucket([...alreadyOpenNames, ...noCredentials]), + }; +} diff --git a/tests/auth-server.test.js b/tests/auth-server.test.js index 129216c..3772a39 100644 --- a/tests/auth-server.test.js +++ b/tests/auth-server.test.js @@ -13,20 +13,28 @@ import { rpConfigFromRequest, isTokenValid, hasRealPasskey, issueChallenge, veri const reqWithHost = (host) => ({ headers: { get: (k) => (k === "host" ? host : null) } }); describe("rpConfigFromRequest", () => { - it("prod host → theforged.fit rpId + https origin", () => { + // These once pinned "every credential is theforged.fit". That contract was + // retired by the 2026-08-18 ruling — new credentials are minted native and + // the old domain is not being renewed — so the expectations move with it. + // The SECURITY properties they were guarding do not move: localhost stays + // isolated, preview hosts get no rpId of their own, and an unrecognised host + // is handed an origin it cannot match. + it("the legacy origin still mints legacy while the window is open", () => { + // A ceremony genuinely on theforged.fit cannot declare the native rpId: + // no reverse Related Origin Requests document is served at heatwayve.app. expect(rpConfigFromRequest(reqWithHost("theforged.fit"))) - .toEqual({ rpId: "theforged.fit", expectedOrigin: "https://theforged.fit" }); + .toMatchObject({ rpId: "theforged.fit", expectedOrigin: "https://theforged.fit" }); }); it("localhost keeps its port and uses http", () => { expect(rpConfigFromRequest(reqWithHost("localhost:3123"))) - .toEqual({ rpId: "localhost", expectedOrigin: "http://localhost:3123" }); + .toMatchObject({ rpId: "localhost", expectedOrigin: "http://localhost:3123" }); }); it("an unknown/preview host resolves to the prod RP (passkeys scoped to the real domain)", () => { // Preview *.vercel.app hosts intentionally don't get their own rpId. expect(rpConfigFromRequest(reqWithHost("project-forge-git-x.vercel.app")).rpId) - .toBe("theforged.fit"); + .toBe("heatwayve.app"); }); }); @@ -118,16 +126,24 @@ describe("stateless challenges (signed, no blob round-trip)", () => { }); }); -describe("Heatwayve migration — two origins, one rpId (challenge 1)", () => { +describe("Heatwayve migration — two origins, and now two rpIds", () => { const req = (host) => ({ headers: { get: (k) => (k === "host" ? host : null) } }); - it("heatwayve.app is an allowed ORIGIN but rpId stays theforged.fit", () => { + it("a heatwayve origin now mints a NATIVE credential", () => { + // Was "allowed origin, but rpId stays theforged.fit" — the single-rpId + // arrangement that the 90-day window exists to unwind. expect(rpConfigFromRequest(req("heatwayve.app"))) - .toEqual({ rpId: "theforged.fit", expectedOrigin: "https://heatwayve.app" }); + .toMatchObject({ rpId: "heatwayve.app", expectedOrigin: "https://heatwayve.app" }); expect(rpConfigFromRequest(req("www.heatwayve.app")).expectedOrigin).toBe("https://www.heatwayve.app"); }); - it("allow-list is exact-match — lookalike hosts fail toward the legacy origin", () => { - expect(rpConfigFromRequest(req("evil-heatwayve.app")).expectedOrigin).toBe("https://theforged.fit"); - expect(rpConfigFromRequest(req("heatwayve.app.evil.com")).expectedOrigin).toBe("https://theforged.fit"); + it("allow-list is exact-match — lookalike hosts get an origin they cannot match", () => { + // The property under test is fail-closed, not the particular domain: a + // host we do not recognise is handed an expectedOrigin that can never + // equal its clientDataJSON.origin, so verification rejects it. + for (const host of ["evil-heatwayve.app", "heatwayve.app.evil.com", "theforged.fit.evil.com"]) { + const { expectedOrigin } = rpConfigFromRequest(req(host)); + expect(expectedOrigin).toBe("https://heatwayve.app"); + expect(expectedOrigin).not.toBe(`https://${host}`); + } }); it("the ROR well-known lists both domains and stays a static literal", async () => { const { readFileSync } = await import("node:fs"); diff --git a/tests/passkey-census.test.js b/tests/passkey-census.test.js index fa95d45..aefd29a 100644 --- a/tests/passkey-census.test.js +++ b/tests/passkey-census.test.js @@ -21,7 +21,8 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; -import { censusPasskeys, IMPLICIT_RP_ID } from "../lib/passkey-census.js"; +import { censusPasskeys, photosAtRisk, IMPLICIT_RP_ID } from "../lib/passkey-census.js"; +import { NATIVE_RP_ID } from "../lib/origin.js"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const routeSrc = readFileSync(resolve(root, "app/api/diag/passkey-census/route.js"), "utf8"); @@ -165,3 +166,71 @@ describe("passkey census — counting", () => { expect(dumped).not.toContain("internal"); }); }); + +describe("photo exposure — the kill list, and nothing more", () => { + const photo = (profile, date, size = 1000) => ({ + profile, pathname: `forge/profiles/${profile}/photos/${date}.jpg`, size, + }); + const withCreds = (profile, creds) => + blob(profile, "credentials.json", "2026-08-01T00:00:00Z", { credentials: creds }); + + it("lists a legacy-only profile's photos as at risk at the sunset", () => { + const census = censusPasskeys([withCreds("sam", [{ id: "a", publicKey: "k" }])]); + const r = photosAtRisk(census, [photo("sam", "2026-08-01"), photo("sam", "2026-08-02")]); + expect(r.atSunset.profiles).toBe(1); + expect(r.atSunset.photos).toBe(2); + expect(r.atSunset.rows[0].prefix).toBe("forge/profiles/sam/photos/"); + }); + + it("spares a profile that already holds a native passkey", () => { + const census = censusPasskeys([ + withCreds("ada", [{ id: "a", publicKey: "k" }, { id: "b", publicKey: "k", rpId: NATIVE_RP_ID }]), + ]); + const r = photosAtRisk(census, [photo("ada", "2026-08-01")]); + expect(r.atSunset.profiles).toBe(0); + expect(r.totals.photos).toBe(1); + }); + + it("keeps already-claimable profiles in a SEPARATE bucket", () => { + // A profile with only keyless credentials is exposed today, not at the + // sunset. Merging the two would misdate the risk. + const census = censusPasskeys([withCreds("kit", [{ id: "a" }])]); + const r = photosAtRisk(census, [photo("kit", "2026-08-01")]); + expect(r.atSunset.profiles).toBe(0); + expect(r.alreadyOpen.profiles).toBe(1); + }); + + it("catches photos under a profile with no credential document at all", () => { + const r = photosAtRisk(censusPasskeys([]), [photo("ghost", "2026-08-01")]); + expect(r.alreadyOpen.profiles).toBe(1); + expect(r.atSunset.profiles).toBe(0); + }); + + it("scopes every proposed prefix with a trailing slash", () => { + // "sam" must never reach "sammy". The trailing slash is the whole defence. + const census = censusPasskeys([ + withCreds("sam", [{ id: "a", publicKey: "k" }]), + withCreds("sammy", [{ id: "b", publicKey: "k" }]), + ]); + const r = photosAtRisk(census, [photo("sam", "2026-08-01"), photo("sammy", "2026-08-01")]); + for (const row of r.atSunset.rows) expect(row.prefix).toMatch(/\/photos\/$/); + const sam = r.atSunset.rows.find((x) => x.profile === "sam"); + expect(r.atSunset.rows.find((x) => x.profile === "sammy").paths) + .not.toContain(sam.paths[0]); + }); + + it("never truncates a path list silently", () => { + const many = Array.from({ length: 25 }, (_, i) => photo("sam", `2026-08-${String(i + 1).padStart(2, "0")}`)); + const census = censusPasskeys([withCreds("sam", [{ id: "a", publicKey: "k" }])]); + const row = photosAtRisk(census, many).atSunset.rows[0]; + expect(row.photos).toBe(25); + expect(row.paths).toHaveLength(20); + expect(row.pathsOmitted).toBe(5); + }); + + it("announces itself as a dry run that deletes nothing", () => { + const r = photosAtRisk(censusPasskeys([]), []); + expect(r.dryRun).toBe(true); + expect(r.deletes).toMatch(/none/i); + }); +}); diff --git a/tests/passkey-migration.test.js b/tests/passkey-migration.test.js new file mode 100644 index 0000000..ba572fc --- /dev/null +++ b/tests/passkey-migration.test.js @@ -0,0 +1,231 @@ +// tests/passkey-migration.test.js +// ───────────────────────────────────────────────────────────────────────────── +// The theforged.fit → heatwayve.app passkey migration (boss ruling, 2026-08-18: +// a 90-day window, then the old domain is not renewed). +// +// The invariant that matters most is the one WebAuthn enforces and we cannot +// see fail in a unit test: A CEREMONY IS SINGLE-rpId. Offer a credential bound +// to a different rpId than the one declared and the authenticator cannot +// satisfy the prompt — the user spends a Face ID and gets an error. So every +// test below that touches planLoginCeremony checks BOTH halves of the answer +// agree, not just that it returned something. +// +// The second invariant: an rpId is never inferred. It is read back from what +// the library cryptographically matched, because guessing it is precisely the +// class of bold assumption that has cost us before. +// ───────────────────────────────────────────────────────────────────────────── + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { + rpConfigFromRequest, planLoginCeremony, credentialRpId, +} from "../lib/auth-server.js"; +import { + NATIVE_RP_ID, LEGACY_RP_ID, PASSKEY_SUNSET, + acceptedRpIds, legacyRpRetired, passkeyNudgeUrgent, daysUntilPasskeySunset, +} from "../lib/origin.js"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const src = (p) => readFileSync(resolve(root, p), "utf8"); + +const req = (host) => /** @type {any} */ ({ headers: { get: (k) => (k === "host" ? host : null) } }); +const at = (d) => new Date(`${d}T12:00:00`).getTime(); +const cred = (id, rpId, extra = {}) => ({ id, publicKey: "k", ...(rpId ? { rpId } : null), ...extra }); + +describe("migration timeline", () => { + it("accepts both rpIds during the window and only the native one after", () => { + expect(acceptedRpIds(at("2026-08-18"))).toEqual([NATIVE_RP_ID, LEGACY_RP_ID]); + expect(acceptedRpIds(at("2026-11-15"))).toEqual([NATIVE_RP_ID, LEGACY_RP_ID]); + expect(acceptedRpIds(at("2026-11-16"))).toEqual([NATIVE_RP_ID]); + }); + + it("retires on the sunset date, not before", () => { + expect(legacyRpRetired(at("2026-11-15"))).toBe(false); + expect(legacyRpRetired(at("2026-11-16"))).toBe(true); + }); + + it("turns the nudge urgent for the final 30 days only", () => { + expect(passkeyNudgeUrgent(at("2026-10-16"))).toBe(false); + expect(passkeyNudgeUrgent(at("2026-10-18"))).toBe(true); + expect(passkeyNudgeUrgent(at("2026-11-15"))).toBe(true); + // Past the sunset there is nothing left to nudge about. + expect(passkeyNudgeUrgent(at("2026-11-17"))).toBe(false); + }); + + it("counts down and never goes negative", () => { + expect(daysUntilPasskeySunset(at("2026-08-18"))).toBe(90); + expect(daysUntilPasskeySunset(at("2026-12-25"))).toBe(0); + }); + + it("the window is 90 days from the ruling", () => { + expect(PASSKEY_SUNSET).toBe("2026-11-16"); + expect(daysUntilPasskeySunset(at("2026-08-18"))).toBe(90); + }); +}); + +describe("rp config — which rpId a NEW credential is minted under", () => { + it("mints native from a heatwayve origin", () => { + for (const host of ["heatwayve.app", "www.heatwayve.app"]) { + const c = rpConfigFromRequest(req(host), at("2026-08-18")); + expect(c.rpId).toBe(NATIVE_RP_ID); + expect(c.expectedOrigin).toBe(`https://${host}`); + } + }); + + it("mints legacy from the legacy origin — the browser would reject native there", () => { + // No reverse Related Origin Requests document is served at heatwayve.app, + // so a ceremony genuinely on theforged.fit cannot claim the native rpId. + const c = rpConfigFromRequest(req(LEGACY_RP_ID), at("2026-08-18")); + expect(c.rpId).toBe(LEGACY_RP_ID); + }); + + it("stops minting legacy once the domain is retired", () => { + expect(rpConfigFromRequest(req(LEGACY_RP_ID), at("2026-12-01")).rpId).toBe(NATIVE_RP_ID); + }); + + it("keeps localhost isolated from migration semantics", () => { + const c = rpConfigFromRequest(req("localhost:3000"), at("2026-08-18")); + expect(c).toMatchObject({ rpId: "localhost", expectedOrigin: "http://localhost:3000" }); + expect(c.acceptedRpIds).toEqual(["localhost"]); + }); + + it("pins expectedOrigin to one exact string even though the library allows a list", () => { + // Only the rpId dimension is widened: the credential's rpId is unknown + // until verification matches it, but the origin is known exactly. + expect(typeof rpConfigFromRequest(req("heatwayve.app")).expectedOrigin).toBe("string"); + }); + + it("fails an unknown host toward a live origin it cannot match", () => { + const c = rpConfigFromRequest(req("evil.example"), at("2026-08-18")); + expect(c.expectedOrigin).toBe(`https://${NATIVE_RP_ID}`); + }); +}); + +describe("credentialRpId — absent means legacy, not unknown", () => { + it("reads a field-less credential as legacy", () => { + expect(credentialRpId({ id: "a", publicKey: "k" })).toBe(LEGACY_RP_ID); + expect(credentialRpId({ id: "a", publicKey: "k", rpId: "" })).toBe(LEGACY_RP_ID); + }); + it("respects a stored rpId", () => { + expect(credentialRpId({ rpId: NATIVE_RP_ID })).toBe(NATIVE_RP_ID); + }); +}); + +describe("planLoginCeremony — one rpId, and only its own credentials", () => { + const cfgNative = (now = at("2026-08-18")) => rpConfigFromRequest(req("heatwayve.app"), now); + + // The load-bearing assertion, applied to every plan this suite produces. + const assertCoherent = (plan) => { + expect(plan).not.toBeNull(); + const ids = new Set(plan.credentials.map((c) => credentialRpId(c))); + expect([...ids]).toEqual([plan.rpId]); + }; + + it("prefers the native pool when the profile holds one", () => { + const plan = planLoginCeremony( + { credentials: [cred("legacy-1"), cred("native-1", NATIVE_RP_ID)] }, + cfgNative(), + ); + assertCoherent(plan); + expect(plan.rpId).toBe(NATIVE_RP_ID); + expect(plan.credentials.map((c) => c.id)).toEqual(["native-1"]); + }); + + it("never mixes pools, however many credentials there are", () => { + const plan = planLoginCeremony( + { + credentials: [ + cred("l1"), cred("l2"), cred("l3"), + cred("n1", NATIVE_RP_ID), cred("n2", NATIVE_RP_ID), + ], + }, + cfgNative(), + ); + assertCoherent(plan); + expect(plan.credentials).toHaveLength(2); + }); + + it("falls back to the legacy pool while the window is open", () => { + const plan = planLoginCeremony({ credentials: [cred("l1"), cred("l2")] }, cfgNative()); + assertCoherent(plan); + expect(plan.rpId).toBe(LEGACY_RP_ID); + }); + + it("returns null for a legacy-only profile once the domain is retired", () => { + // Not an error state — the client reads it as "no passkey" and re-offers + // setup, which is how the holder mints a native one. + const now = at("2026-12-01"); + expect(planLoginCeremony({ credentials: [cred("l1")] }, cfgNative(now))).toBeNull(); + }); + + it("still logs in a profile that upgraded, after the sunset", () => { + const now = at("2026-12-01"); + const plan = planLoginCeremony( + { credentials: [cred("l1"), cred("n1", NATIVE_RP_ID)] }, + cfgNative(now), + ); + assertCoherent(plan); + expect(plan.rpId).toBe(NATIVE_RP_ID); + }); + + it("never offers a keyless credential", () => { + // No signature can be checked against one, so a ceremony using it fails + // AFTER costing the user a prompt. + const plan = planLoginCeremony( + { credentials: [{ id: "keyless" }, cred("n1", NATIVE_RP_ID)] }, + cfgNative(), + ); + assertCoherent(plan); + expect(plan.credentials.map((c) => c.id)).toEqual(["n1"]); + }); + + it("returns null when every credential is keyless", () => { + expect(planLoginCeremony({ credentials: [{ id: "a" }, { id: "b" }] }, cfgNative())).toBeNull(); + }); + + it("is empty- and null-safe", () => { + expect(planLoginCeremony(null, cfgNative())).toBeNull(); + expect(planLoginCeremony({ credentials: [] }, cfgNative())).toBeNull(); + }); +}); + +describe("the routes read the rpId back rather than assuming it", () => { + it("both verify routes accept the rpId SET, not a single guess", () => { + for (const p of ["app/api/auth/register-verify/route.js", "app/api/auth/login-verify/route.js"]) { + expect(src(p)).toContain("expectedRPID: acceptedRpIds"); + } + }); + + it("register-verify stores the rpId the library matched", () => { + expect(src("app/api/auth/register-verify/route.js")) + .toContain("verification.registrationInfo.rpID"); + }); + + it("login-verify backfills from the verified assertion, not from config", () => { + const s = src("app/api/auth/login-verify/route.js"); + expect(s).toContain("verification.authenticationInfo.rpID"); + // The backfill rides the existing counter write — one write, not two. + expect(s.match(/writeJsonReplacingPrefix\(/g) || []).toHaveLength(1); + }); + + it("no auth route hardcodes the legacy domain any more", () => { + for (const p of [ + "app/api/auth/register-options/route.js", + "app/api/auth/login-options/route.js", + "app/api/auth/register-verify/route.js", + "app/api/auth/login-verify/route.js", + ]) { + expect(src(p)).not.toMatch(/"theforged\.fit"/); + } + }); + + it("login-options declares the planned rpId and offers only its credentials", () => { + const s = src("app/api/auth/login-options/route.js"); + expect(s).toContain("rpId: plan.rpId"); + expect(s).toContain("plan.credentials.map"); + // The old shape offered every stored credential regardless of rpId. + expect(s).not.toContain("credData.credentials.map"); + }); +});