Fixes every confirmed backend finding from the v0.3.0 pre-ship review.
Identity (the two that corrupt accounts):
- nostr_register creates a row with pubkey_hash NULL, so that wallet's later
/auth/extension call matched nothing, fell through the rotation path (which no
client ever satisfies, since nothing sends rotation_signature) and minted a
SECOND account. A fingerprint match on a NULL-pubkey_hash row now adopts the
submitted pubkey_hash onto that row, but only when the row's on-file BTC key
equals the submitted one, so a bare fingerprint (which check_restore discloses,
and is therefore not a secret) still cannot claim an account.
- The fingerprint-ownership check no longer short-circuits on returning_by_pubkey.
It was skipped for returning users, so a fingerprint owned by another row got
backfilled, violated the UNIQUE index, and 500'd that account on every
subsequent login.
- The rotation proof is verified over a distinct, instance- and target-bound
message. It shared the login message, so a captured login signature was a
replayable rotation proof against another instance.
Abuse control:
- The rate limiters key on the real client IP, honouring X-Forwarded-For only
from a trusted proxy. Behind the TLS terminator every request looked like
127.0.0.1, so the whole internet shared one bucket and ~3 req/s 429'd every
wallet unlock globally.
- A PoW solution is single-use instead of replayable for the challenge TTL.
- Delegated /verify is opt-in and rate-limited; it was an unauthenticated
amplifier into Electrum, LWS and a full-genesis grin scan.
- A grin scan with no start_height gets the same depth policy and PoW price as
one that passes a height, instead of an unbounded free scan.
Retention and logging:
- The retention sweep is implemented AND scheduled. RETENTION_* was advertised
and inert, which made the privacy policy's retention promise untrue. Zero
means keep forever, and each statement is batched so a backlog drains over
ticks rather than holding row locks.
- login_events.ip_hash is no longer written, and legacy values are scrubbed:
nothing ever read it, so it was retention without a purpose.
- Tracing spans log user_id only, not the raw npub and username.
- secret-scan.sh matches the coverage its comment claims.
Also fixes the test suite, which did not compile on this commit: NewSession
gained an id in c64cd9b and two call sites were never updated. mint_session now
binds one session_id for both the token and the row, since a different id there
would silently recreate the bug c64cd9b fixed.
- shared build_router() (lib) so main + tests exercise identical wiring
- tests/common: spins the real router + AppState against TEST_DATABASE_URL;
self-skips when unset (cargo test stays green without a DB). Deterministic,
non-secret test JWT/peppers; ephemeral generated identities only — no funded
or sensitive wallet seeds (those stay in a separate gitignored L3 harness).
- tests/db_queries: validates migrations + db layer against real Postgres
(peppered identity lookups, UNIQUE->409, session create/lookup/revoke,
key upsert/list/delete, restore-attempt counts).
- tests/auth_flow: HTTP flows through the real router (health, auth gate,
refresh rotation + old-token revocation, username set+lookup, key register+
fetch, NIP-05 directory).
- CI: new integration job with an ephemeral postgres:16 service.
- 82 tests total (69 unit + 13 integration), all green against live PG.
Complements the monorepo's client-side suite (it tests the wire format the
client sends; the client tests the wallet side); mirrors its secret-handling
(env-driven, gitignored). Mirrors the L0-L5 tiering from docs/engineering.