Making seed v0.12's first recovery mechanism — recovery codes — manageable: a person can now regenerate their codes and see how many remain, the old set genuinely revoked. Passkeys and recovery codes move under one /me/security roof.
Recovery codes are the one account-recovery mechanism that already worked end to end — but only just barely. A person got ten codes, shown once at signup, and there was no way to get a fresh set or even to see how many were left. Use them up, or lose them, and that recovery route was simply gone.
This change makes recovery codes manageable. An authenticated person can now regenerate their codes — the old set is revoked and a fresh set is shown once — and see a count of how many unused codes remain. They cannot view their existing codes: codes are stored hashed and can never be shown again, by design. So the surface offers two things — a count, and a regenerate — never a view.
Alongside this, the passkeys routes from the previous change and these new recovery-code routes are consolidated under one /me/security/* route group, mirroring the single Operator Layer security screen that will call both. The passkeys routes move from /me/passkeys/* to /me/security/passkeys/*; their behaviour is unchanged.
As before, this is engine-only — real and proven through the test suite, not yet reachable by a person until the security screen lands.
Under the consolidated /me/security route group, for the signed-in person only:
POST /me/security/recovery-codes/regenerate — revokes the person's current recovery codes and issues a fresh set, returned exactly once.GET /me/security/recovery-codes — reports { unused_count, total } — how many codes are still usable, and the size of the current set (so the surface can show "N of M remaining"). Never returns the codes themselves.POST /me/security/passkeys/begin, POST /me/security/passkeys/complete, GET /me/security/passkeys — the passkeys routes from the prior change, moved here unchanged.All require an authenticated, second-factor-verified session; an unauthenticated request is rejected with 401.
Regenerate does not delete the old codes. It soft-invalidates them by stamping a new column, invalidated_at, and keeps the rows.
The column is deliberately distinct from the existing used_at, because "redeemed at sign-in" and "rotated out by a regenerate" are different facts, and the data should say which happened. Overloading used_at to mean both would lose that distinction. Keeping them separate preserves an honest, auditable history of a security-sensitive action — the same corrections-preserved discipline the rest of Loomworks holds to. A code is valid only when both used_at and invalidated_at are NULL.
Row accumulation (old invalidated rows stay) is acceptable; a cleanup pass is deferrable.
This is the load-bearing change, and it is easy to overlook. Soft-invalidating the old codes only means anything if redemption respects it. So the redemption function — verify_and_consume_recovery_code, the function the sign-in recovery path (POST /auth/login/recovery) calls to consume a code — was changed to filter invalidated_at IS NULL (alongside its existing used_at IS NULL).
Without this filter, regenerate would not revoke anything — the old codes would keep redeeming, and the whole feature would be defeated. With it, a rotated-out code can no longer be redeemed, because redemption happens only here. The test suite proves this directly: after a regenerate, an old code fails the real redemption function and a new code succeeds.
The same filter is applied to the count, so "unused" means genuinely redeemable (not used, not rotated out).
Regenerate runs in one transaction: it invalidates the person's currently-valid codes and inserts the fresh set together. A mid-rotation failure rolls back to the prior set — there is no window in which the person has zero valid codes, and no window in which both sets are live. Either the rotation completes (old revoked, new issued) or nothing changes.
Recovery codes are stored as bcrypt hashes; the plaintext is unrecoverable after the one-time display. There is therefore no view endpoint and no way to return stored codes — it is structurally impossible, not an omission. The surface offers a count (how many remain) and a regenerate (get a fresh set, shown once), and nothing else.
The passkeys routes (from the prior change) and these recovery-code routes now live in one router, me_security, under the /me/security/* prefix. This mirrors the single Operator Layer security-settings screen that will call both — passkeys and recovery codes are two halves of "how I get into my own account," and they belong in one place. The consolidation was a pure move for the passkeys routes: same authentication, the same 403 person-binding guard on passkey completion, the same responses — only the URLs changed.
| Was (passkeys CR) | Now |
|---|---|
POST /me/passkeys/begin | POST /me/security/passkeys/begin |
POST /me/passkeys/complete | POST /me/security/passkeys/complete |
GET /me/passkeys | GET /me/security/passkeys |
The /me/passkeys Endpoints CR documented the old paths; this CR supersedes those path references. The change was made now precisely because there is no consumer yet — these endpoints are engine-only and not user-reachable until the OL security screen lands — so moving them was free, and would only have grown costlier once the screen was built against the URLs. Behaviour is unchanged; only the URLs moved.
0082 adds a nullable invalidated_at timestamp to recovery_codes. Forward-only, no backfill — existing codes have a NULL invalidated_at (never rotated). Reversible (the downgrade drops the column).
Proven through the test harness, not yet a UI:
me_security tests: 9 passed. The surrounding recovery-code + WebAuthn registration/authentication regression: 48 passed alongside them. The full suite collects 2,937 with no import breakage. The live engine serves all five /me/security/* routes.
This is engine-only. The Operator Layer security-settings screen — one screen where a person adds a backup passkey, sees their registered passkeys, sees their recovery-code count, and regenerates their codes — is the next change, built once over both engine halves. Until it lands, these endpoints are real and provable but not reachable by a person.
The Operator Layer security-settings surface — the next change, over both engine halves.
Operator-mediated recovery (seed mechanism 3) — still deferred; its decision is pending at the documentation step.
Delete / revoke of passkeys and the last-credential guard — still deferred to where the recovery arc settles what "last credential" means.
Carried, unchanged from the passkeys CR: get_webauthn_config is defined twice in deps.py (lines 337 and 468); Depends(get_webauthn_config) resolves to the last (line 468). This work continues to use it knowingly; the duplicate remains a pre-existing oddity for its own cleanup decision.