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 new file mode 100644 index 0000000..489d19f --- /dev/null +++ b/app/api/diag/passkey-census/route.js @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server"; +import { rateLimit } from "@/lib/rate-limit"; +import { list } from "@vercel/blob"; +import { readJsonDirect } from "@/lib/blob-utils"; +import { censusPasskeys, photosAtRisk } 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 = []; + 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) { + 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); + } 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); + } + + const census = censusPasskeys(found); + return NextResponse.json({ + dryRun: true, + writes: "none — enumeration and reads only", + 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/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/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 new file mode 100644 index 0000000..99f1371 --- /dev/null +++ b/lib/passkey-census.js @@ -0,0 +1,250 @@ +// @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, 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 = LEGACY_RP_ID; + +/** @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, + }; +} + +// ─── 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/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([]); + }); +}); 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 new file mode 100644 index 0000000..aefd29a --- /dev/null +++ b/tests/passkey-census.test.js @@ -0,0 +1,236 @@ +// 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, 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"); + +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"); + }); +}); + +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"); + }); +});