DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/loomworks-ol-security-settings-surface-cr-v0_1.html
LoomworksChange Requestv0.12026-06-12Built on a branch — awaiting merge

Operator Layer Security-Settings Surface

The screen that makes both built recovery halves reachable by a person — add a backup passkey, regenerate recovery codes behind a save-it-first guard — closing the recovery arc's build.

Version   0.1
Date   2026-06-12
Status   Built on a branch, pushed, awaiting merge. Six steps on feat-security-settings-surface (f9cbe5c → c17539a). NOT merged to main — the merge is a separate approval after this CR is read.
Render-type   Change request. Operator-facing — HTML primary, Markdown source alongside.
Source   Seed v0.12 (Account recovery); the OL security-surface scoping note; the two engine CRs this surfaces (me-passkeys-endpoints, me-security-recovery-codes); the Step-0 OL inspection (2026-06-12).
Repos touched   loomworks (Operator Layer) only — no engine change; the five /me/security/* routes already exist

Plain-language summary

The two engine halves of account recovery were built and reachable by the API, but no person could use them — there was no screen. This change builds that screen. A signed-in person can now open Security from their user menu and, in one place: see the passkeys they have registered, add a backup passkey, see how many recovery codes they have left, and regenerate their codes — the fresh set shown once, behind a save-it-first guard.

With this, the recovery arc's build is complete: every recovery mechanism that was built is now reachable by a person — rotatable recovery codes (with a guarded one-time display) and an enrollable backup passkey. The acute lockout exposure — lose your authenticator and your codes and you're locked out — is closed by two independent mechanisms a person can actually reach and manage.

What was built — the screen and the routes it makes reachable

A new page at /settings/security, a child of the existing Settings page, with two sections. It calls the five /me/security/* engine routes that already existed but had no surface:

Passkeys

Recovery codes

Existing recovery codes are never shown — they are bcrypt-hashed in the engine and cannot be retrieved. The surface offers a count and a regenerate, never a view.

The guarded one-time display, and why it exists

Regenerating recovery codes is destructive and irreversible: the engine revokes the old set the instant it issues the new one. The fresh codes appear once, in that response. So if a person closes the page, navigates away, or the render is interrupted before they save the new set, they are worse off than before they regenerated — the old set is dead and the new set is lost, and that recovery route is gone.

The protection is a shared component, OneTimeSecretsDisplay: it renders the fresh codes with Copy and Download affordances and an explicit "I have saved these codes" acknowledgement, and it does not let the screen move on until the person gives that acknowledgement. The Step-0 inspection found no such guard anywhere in the app.

A subtle but deliberate property: the regenerate flow does not refresh the displayed count until the person confirms. The old set is already revoked, but the surface waits — it never implies the rotation is safely complete before the codes have been captured. Only after the acknowledgement does the count re-read (now the fresh set's size).

The guard is not a forbidden disabled control

The seed forbids disabled and grayed-out controls — only show what is available. The acknowledgement gate is not that, and the distinction is worth stating so it isn't mistaken for a seed violation.

A forbidden disabled control is a grayed-out option the person cannot use. The gate is the opposite: the dismiss action is surfaced only once the person acknowledges they have saved the codes — it is the person taking an action, not a control held inert against them. That is conditional availability — the seed's "only show what is available" applied to a secret that must be captured before it is gone — and the machine-surfaces / human-confirms shape the seed holds to everywhere else. Consistent with this, the Regenerate button itself is always enabled (including at "0 unused codes", which is exactly when it matters most), disabled only for the moment its own request is in flight — the house's one admitted use of disabled.

The signup retrofit — a side-benefit gap-closure

Signup also shows recovery codes once. The Step-0 inspection found the signup flow had the same exposure — codes shown inline, no copy, no download, no confirmation, one click straight to the dashboard. Milder than regenerate (a fumble at signup doesn't lock you out — you still have your passkey and can regenerate later), but the same gap with the same fix.

So the guarded component is built once and used in both places. The signup "done" stage was retrofitted onto OneTimeSecretsDisplay: the welcome content is untouched, the recovery-codes block and the standalone dashboard button are replaced by the guarded component, and dashboard navigation is now gated behind the acknowledgement rather than firing on an immediate click. One component, both call sites — leaving signup on its own ungated version once the guarded one existed would have meant two one-time-code displays in the codebase, one protected and one not, for no reason.

This retrofit also added the first test coverage of the signup "done" stage. That stage had none — the prior signup tests stopped at the TOTP-setup transition, so the recovery-code display (and its old inline structure) was never exercised. The retrofit added a test that drives the full flow to "done" and asserts the codes render via the guarded component and that dashboard navigation does not fire until the person acknowledges and confirms.

The user-menu Settings entry — closing a reachability gap

The Settings page was reachable only by typing the URL — the user menu held only Sign Out, with no Settings link anywhere in the navigation. A security screen with no way to reach it is built-but-unreachable, the same shape as an engine endpoint with no surface. So this change adds a Settings entry to the user menu (above Sign Out), which makes the security screen — and the existing Settings page — reachable.

That the existing Settings page had been unreachable-by-navigation until now is a pre-existing gap this change happens to close, not one it created — recorded so the menu addition reads as fixing a standing gap, not introducing a new surface.

What was reused, and what is new

Reused unchanged:

New, built here:

Verification

Proven through the frontend test harness (vitest), not yet exercised by hand:

Full suite: 467 passed / 62 files, green at every step.

Pre-existing items — recorded, not fixed (not this feature's to fix)

Two pre-existing conditions are recorded so a future reader doesn't mistake them for something this feature introduced:

npm run lint is red on main independent of this change — 13 errors / 8 warnings on a clean tree, 10 of the errors being the react-hooks set-state-in-effect rule firing on the house's standard void load(signal) async-mount-fetch pattern across 10+ existing sites (SpendVisibilitySection, GrantList, …), with no eslint-disable anywhere. This work added 2 same-class instances — its two section mount-fetches — by following the established house convention (the handoff directed leaning on the existing three-branch fetch pattern; diverging for two files alone would make them the only /me/* screens fetching differently from every other, imposing a local correctness the house doesn't hold). The pre-existing lint state is its own separate item.

3 tsc errors in TagChipArea.test.tsx (an unrelated ProjectTag.project_id type) pre-exist on main and are untouched here; this work's own files typecheck clean.

Both are the same kind of pre-existing-and-recorded item as the engine CRs' get_webauthn_config duplicate — flagged so they stay walkable, not silently carried, and not conflated with this change.

What this closes, and what follows

This closes the recovery arc's build. Every built recovery mechanism is now reachable by a person: recovery codes (rotatable, with a guarded one-time display) and a backup passkey (enrollable). The lockout exposure is closed by two independent, manageable mechanisms.

What follows is the documentation step: carrying the seed's positive recovery commitment into the Companion functional specification (which states recovery only negatively today), written against the built reality — and the operator-mediated-recovery decision (seed mechanism 3): build it now as the third mechanism, or mark it committed-and-deferred, taken there with the two reachable mechanisms in full view.

Out of scope

Delete / revoke of passkeys and the last-credential guard — still deferred to where the recovery arc settles what "last credential" means.

Operator-mediated recovery (seed mechanism 3) — still deferred; its decision is taken at the documentation step that follows.

Engine changes — none. The five routes exist; this is Operator Layer only.

The get_webauthn_config duplicate in the engine — a pre-existing engine-side oddity carried in the engine CRs; not this surface's concern.

DUNIN7 — Done In Seven LLC — Miami, Florida
Loomworks — Operator Layer Security-Settings Surface — Change Request — v0.1 — 2026-06-12