Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions app/api/auth/login-options/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
);
}
Expand All @@ -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",
});
Expand Down
41 changes: 37 additions & 4 deletions app/api/auth/login-verify/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 6 additions & 5 deletions app/api/auth/register-options/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions app/api/auth/register-verify/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions app/api/diag/passkey-census/route.js
Original file line number Diff line number Diff line change
@@ -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 <CRON_SECRET>)
//
// 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),
});
}
4 changes: 2 additions & 2 deletions app/diag-bugs/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function DiagBugs() {
};

return (
<main style={{ maxWidth: 560, margin: "0 auto", padding: "52px 20px 40px", color: C.text, fontFamily: "system-ui, sans-serif" }}>
<div style={{ maxWidth: 560, margin: "0 auto", padding: "52px 20px 40px", color: C.text, fontFamily: "system-ui, sans-serif" }}>
<div style={{ fontSize: 10, fontWeight: 600, color: C.faint, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 6 }}>
Fill or kill
</div>
Expand Down Expand Up @@ -89,6 +89,6 @@ export default function DiagBugs() {
</div>
</div>
))}
</main>
</div>
);
}
25 changes: 20 additions & 5 deletions app/layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -240,7 +250,12 @@ export default function RootLayout({ children }) {
<body>
<ServiceWorkerRegistrar />
<ErrorBoundary>
{/* .forge-page (globals.css): document-height wrapper that paints
{/* <main> 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
<main> — 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
Expand All @@ -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. */}
<div className="forge-page">
<main className="forge-page">
{/* 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
Expand All @@ -266,7 +281,7 @@ export default function RootLayout({ children }) {
>
{children}
</ViewTransition>
</div>
</main>
</ErrorBoundary>
{/* Status-bar handling: iOS owns the bar (statusBarStyle: default,
since 26.1 stopped honouring black-translucent). viewport-fit:
Expand Down
6 changes: 3 additions & 3 deletions app/locker-room/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ export default function LockerRoom() {
);
};

if (!profile) return <main style={page}><p style={{ color: T.ink3 }}>No active profile — sign in first, then come back to the Locker Room.</p></main>;
if (!profile) return <div style={page}><p style={{ color: T.ink3 }}>No active profile — sign in first, then come back to the Locker Room.</p></div>;

// ── Chart-first layout: ungated bodyweight on top, photos behind the toggle ──
const photosVisible = shown && photos !== null;
Expand All @@ -367,7 +367,7 @@ export default function LockerRoom() {
const markerX = !photos?.length ? 0 : photos.length === 1 ? curveW / 2 : (pos / (photos.length - 1)) * curveW;

return (
<main style={page}>
<div style={page}>
{picker}
{/* Header anatomy mirrors the Performance Lab (#73c/d): safe-area-aware
back-nav row with the photos toggle right-aligned, then eyebrow +
Expand Down Expand Up @@ -479,6 +479,6 @@ export default function LockerRoom() {
)}
</div>
</>)}
</main>
</div>
);
}
Loading