Commit Graph
69 Commits
Author SHA1 Message Date
jwinterm 6f0fca532d feat(registration): add gate mode (all|any) for alternative registration methods
The invite + payment gates were strictly conjunctive: a new wallet had to satisfy
EVERY enabled gate. Add a REGISTRATION_GATE_MODE knob so an operator can offer the
enabled gates as ALTERNATIVES instead (e.g. "an invite code OR pay to register").
PoW stays orthogonal and always applies on top, regardless of mode.

- config: GateMode { All (default) | Any } on RegistrationConfig, from
  REGISTRATION_GATE_MODE (default "all", back-compatible).
- capabilities: advertise registration_mode ("all"|"any") so the wallet can route
  onboarding (multiple gates + "any" => "pick a method"; "all" => satisfy each).
- enforcement: enforce_method_gates composes invite+payment per mode. Extracted a
  pure plan_gates() decision so the fund-critical dispatch is unit-tested. In
  "any" with both gates on, the client presents exactly ONE credential and only
  that gate runs (and only it consumes its single-use token); presenting BOTH is
  rejected so a one-of choice can never burn two tokens (an invite AND a paid
  invoice). Single-gate configs behave identically in both modes.

6 KATs on plan_gates (open / conjunction / single-gate / dispatch / reject-both /
require-one). openapi.json regenerated. 211 lib tests pass.
2026-07-04 22:34:58 -04:00
jwinterm ef6d280412 feat(feed): operator-configurable public feed advertised via /capabilities
A read-only web feed (feed.<domain>) is a backend-operator knob: FEED_ENABLED
plus curation switches (show owner / show premium members / a featured npub
allowlist / extra relays) surface in /capabilities.feed so the feed page adapts
per instance. Reads from the relay, so validate() fail-closes FEED_ENABLED
without RELAY_ENABLED. Off by default; all public display config, no secrets.
2026-07-03 16:58:22 -04:00
jwinterm a166ef56d7 chore: gitignore the target-deb container-build output 2026-07-02 22:05:17 -04:00
jwinterm 394abd5ab7 fix(config): exempt loopback from the PAYMENT_PROVIDER_URL https-in-prod guard
A payment processor reached over a loopback hop (an SSH tunnel or a local
reverse proxy) is safe to call over plaintext in production — the request never
leaves the host. Relax the https requirement for 127.0.0.0/8, ::1, and
localhost in both the pay-to-register and premium payment validation paths.
2026-07-02 22:04:58 -04:00
jwinterm d96f457b69 docs(examples): operator cookbook — six deployment recipes + premium-relay spec
Add ready-to-run .env + README recipes (personal, public-free, friends,
paid-access, paid-relay, community) and the premium-relay design overview,
with audit fixes to the config template and spec:

- Drop the inert INSTANCE_ID knob (config never read it) and document the real
  per-node id, which is derived from ADMIN_PUBLIC_URL.
- Warn that a placeholder PUBLIC_API_URL is rejected at boot in production
  (Nostr identity is on by default); soften the recipe "boots today" status.
- Remove dead template vars: BTC/LTC Core-RPC settings and the non-existent
  PUBLIC_EXPOSE_FEATURES (replaced with the real per-field landing switches).
- Mark ALTCHA_HMAC_KEY conditional in PoW-off recipes; blank placeholder
  payment creds so a recipe doesn't ship a key that fails validation.
- Fix premium-relay spec drift: the migration creates no premium_until index,
  disabled endpoints return 400/200 (not 404), and neutralize operator voice.
2026-07-02 19:47:46 -04:00
jwinterm dec9994e06 fix(config): fail closed on POW_REQUIRED without FEATURE_POW; harden premium key check
- Reject boot when POW_REQUIRED (or the per-pubkey PoW list) is set while
  FEATURE_POW is off: the gate would silently never apply and /capabilities
  would report pow_required:false — a signup endpoint the operator believes
  is protected but isn't.
- Validate the premium PAYMENT_API_KEY through the same require_secret path
  the pay-to-register flow uses, so a placeholder key cannot reach production.
- Advertise the premium-post write policy in MessagingCapability and
  regenerate openapi.json to match.
- Give the premium integration fixtures unique per-run ids so the suite is
  re-runnable against a persistent local test database.
2026-07-02 19:47:38 -04:00
jwinterm 9c75bc6bdd docs(examples): operator cookbook + premium-relay spec
examples/: an operator "cookbook" turning the backend's composable knobs
(registration gates, relay write policy, restore policy, feature flags) into
ready-to-run recipes per persona — personal, public-free (what the reference
instance runs), friends, paid-access, community, and paid-relay (the flagship:
free wallet + paid premium Nostr posting). Plus LEGAL_AND_OPS.md, a
not-legal-advice considerations checklist that points operators at their own
counsel and at the project's own published docs as a worked example. Recipes use
placeholders only.

docs/design/premium-relay.md: the premium-relay feature spec (reconciled to the
shipped design — dedicated premium_invoices table, transactional activate).
2026-07-02 17:57:13 -04:00
jwinterm eb98e4d625 feat(premium): recurring tiered relay-posting subscription (premium-post)
A paid premium tier gating GENERAL Nostr posting to the operator's relay, while
wallet-functional events (encrypted DMs, tips, swap coordination) stay free for
registered users. Registration itself stays free (PoW-gated). Off by default.

- Admission: a new `premium-post` write policy gates on kind x membership —
  premium author -> any kind; registered + WALLET_KINDS (1059 gift-wrap, 10050
  DM-relay-list) -> free; registered + general -> premium required; external ->
  gift-wrap DM inbox only. nauthz resolves is_premium_npub (only under premium-post),
  fail-closed.
- Payment: reuses the PaymentProvider seam (PULL model, non-custodial — only reads
  invoice status). POST /premium/invoice {plan} mints a tier invoice bound to the
  JWT user_id; POST /premium/activate verifies Settled + consumes single-use AND
  extends premium_until in ONE transaction (a mid-flight failure keeps the paid
  invoice redeemable); GET /premium/status reads it. Per-user pending-invoice cap.
- Tiered plans (PREMIUM_PLANS id:days:amount) = a built-in discount feature;
  advertised via /capabilities (premium_relay + premium{currency,plans,relay_url}).
- Config fail-closes: PREMIUM_ENABLED needs relay on + premium-post + processor
  creds + >=1 positive-decimal plan + unique ids + days<=3660, and the 0-conf /
  expiry processor floors apply on the premium path too (its common case is
  require_payment=false).
- Tests: 15 policy unit (incl. 5 premium-post) + config fail-closed matrix + plan
  parser + L1 integration (activate single-use/stacking/cross-user, is_premium by
  expiry, feature gate). OpenAPI + migration updated.

Adversarially reviewed (correctness / security-payment / operational-config); the
0-conf-on-premium-path double-spend footgun, non-transactional activate, and
unbounded plan days were caught and fixed before commit.
2026-07-02 17:56:44 -04:00
jwinterm b4935b15e8 feat(admin): migrate-legacy — import v0.2.x user identity into v0.3
`smirk-admin migrate-legacy --source-url <pg> [--commit] [--limit N]` copies
user identity from a legacy v0.2.x backend database into this v0.3 DB, so users
keep their handle (and NIP-05 name@domain) when they move to a v0.3 client.

- Identity-only, joined on the derivation-independent seed_fingerprint; also
  carries pubkey_hash (v3 wallets reclaim their row with no rotation step),
  username, nostr_pubkey, wallet_birthday and chain restore heights. Socials/
  tips/keys/wallets are intentionally not carried (no v0.3 home, or re-registered
  by the client on first unlock).
- pubkey_hash + seed_fingerprint are peppered at rest, so the importer builds its
  target with the real SEED_FINGERPRINT_PEPPER (run with the v0.3 .env).
- Idempotent (skips users already present by either peppered identity column),
  dry-run unless --commit, best-effort per row: a transient error or a concurrent
  native onboarding is reconciled, never aborting the pass. Reserved/taken/
  malformed usernames and already-linked npubs are dropped; the user still lands.
- Exports validate_username so the importer applies the identical format +
  reserved-name rules the HTTP handler does.
- docs/operations/LEGACY_MIGRATION.md + scripts/test-legacy-migration.sh, a
  self-contained E2E that seeds alice/bob/carol plus edge cases and asserts
  import, peppering, collision handling, and idempotency.

Reviewed adversarially (correctness / security / operational) before commit.
2026-07-02 13:49:45 -04:00
jwinterm 635cb64601 fix(relay): harden admission (fail-safe, loopback-only, bounded, size cap) [review]
Adversarial review of the relay admission surface:

- FAIL-SAFE on admission death: nostr-rs-relay fails OPEN if its gRPC admission
  server is unreachable, so a dead admission = an unrestricted relay. The backend
  now EXITS if the admission service stops (supervisor restarts a clean pair);
  documented in RELAY.md. Pin the relay image to a digest (compose note).
- Registration-oracle protection: validate() now refuses a non-loopback
  RELAY_ADMISSION_BIND unless RELAY_ADMISSION_ALLOW_PUBLIC=true (+ test).
- No N+1 on crafted events: recipient p-tags are capped (20, deduped) and resolved
  in ONE batched query (db.any_registered_npub) instead of one-per-tag.
- Defence-in-depth size cap: admission denies events over max_event_bytes before
  any DB work (the relay enforces its own limit too).
- Docs: RELAY.md Security section (fail-open, loopback oracle, default PoW=0 spam
  footgun, client sender-verification); config.toml relay port 8088 (not 8080);
  Docker Hub image; .env.example RELAY_ADMISSION_ALLOW_PUBLIC.

Note: the client-side sender-impersonation blocker is fixed separately in the
monorepo (verifying gift-wrap unwrap).
2026-07-02 08:35:37 -04:00
jwinterm 17bb7bcc20 docs(relay): packaging (deploy/relay) + operator guide + .env.example
Completes the operator side of the relay integration:

- deploy/relay/: reference docker-compose + config.toml for a locked-down
  nostr-rs-relay wired to the backend's gRPC admission (host networking so the
  loopback admission socket stays private). Clearly marked a template to pin/
  verify against the operator's nostr-rs-relay version.
- docs/operations/RELAY.md: what it is (inbox relay, not the only relay), the
  admission architecture, the three write policies + NIP-13 PoW, bundled vs
  external, verification, and the seam's modularity. Linked from OPERATOR_SETUP.
- .env.example: the RELAY_* block (all off/placeholder).

Backend relay integration (config → seam → policy → gRPC admission →
capabilities → NIP-05 → packaging/docs) is now complete. Client DM stack next.
2026-07-02 07:06:36 -04:00
jwinterm f1872c781c feat(relay): advertise the relay via /capabilities + NIP-05 relay hints
So clients can discover + connect to the relay (and self-adapt when it's off):

- /capabilities: features.nostr_relay + a messaging{ relay_url, write_policy,
  inbound_pow_bits, supported_nips } object, with the same config-presence
  downgrade as chains/nostr_identity (advertised only when enabled AND a URL is
  configured — never advertise a relay clients can't reach). OpenAPI regenerated.
- nip05.rs: /.well-known/nostr.json relay hints are now config-driven — this
  instance's own relay (the user's DM inbox) first when enabled, then the public
  interop relays (damus/nos.lol) so cross-wallet delivery still works. Replaces
  the hardcoded-only default.
2026-07-02 07:00:02 -04:00
jwinterm 63d2c75194 feat(relay): gRPC event-admission (nauthz) service enforcing the write policy
nostr-rs-relay's [grpc] event_admission_server calls Authorization.EventAdmit
once per inbound event; we answer PERMIT/DENY so the write policy is enforced at
ingest (dynamic, per-event — a static pubkey whitelist can't do inbox/PoW).

- proto/nauthz.proto (matches nostr-rs-relay's contract) compiled by build.rs;
  protoc is vendored (protoc-bin-vendored) so a source build needs no system
  protobuf-compiler. tonic/prost added (reuse the hyper/tower/h2 stack axum
  already pulls in); server-only codegen.
- infra/relay/nauthz: AdmissionService resolves author + recipient (p-tag)
  registration via the DB, runs the pure policy engine, returns the decision;
  served on a loopback socket (spawned in main only when relay enabled + policy
  != open), mirroring the admin plane. Fail-closed on a malformed request.
- db: is_registered_npub(hex) — a no-PII membership check safe to call per event.
- tests/relay_admission.rs: L1 handler tests (is_registered_npub membership +
  outbox/inbox/deny-non-giftwrap/deny-unregistered-recipient through the real
  event_admit handler against live Postgres).
2026-07-02 06:46:59 -04:00
jwinterm a8f1a9e50c feat(relay): messaging-plane config + provider seam + pure admission policy engine
First slice of the optional Nostr relay (messaging plane), behind a swappable
seam mirroring the PaymentProvider pattern so nostr-rs-relay can be replaced by
another relay (strfry/external) or another messaging protocol later.

- config: MessagingConfig { relay: RelayConfig } — RELAY_ENABLED (off by default),
  mode (bundled|external), advertised ws(s):// URL, write_policy
  (inbox-outbox default | author-allowlist | open), inbound_pow_bits (NIP-13),
  admission_bind, resource caps. validate() fail-closes (URL required + ws(s)://
  + wss in prod, known policy, PoW <= 40, admission bind for non-open). +7 tests.
- infra/relay: RelayProvider trait + from_config (feature-gated, fail-closed) +
  the default nostr-rs-relay/external adapter; AppState.relay wired
  (lib/main/tests).
- infra/relay/policy: PURE admission engine (WritePolicy, decide, NIP-13
  leading_zero_bits): open permits all; author-allowlist gates on a registered
  npub; inbox-outbox permits a registered author's outbox OR a kind-1059 gift-wrap
  addressed to a registered user (inbox), the external-inbound branch optionally
  behind a PoW gate. Registration lookups are resolved by the caller and passed as
  booleans so the decision is pure + fully unit-tested (10 tests).

The gRPC event-admission service that calls this engine, capabilities/NIP-05
advertising, packaging, and the client DM stack land in the following slices.
2026-07-02 06:29:38 -04:00
jwinterm 1181b5938a fix(auth): canonicalize verified Nostr pubkey + harden the link/login grade
Adversarial review (Identity Phase 1 flow) findings:

- Canonicalize the pubkey returned by verify_nip98 / verify_signed_action to
  lowercase hex. BIP-340 hex-decode is case-insensitive, so an UPPER-case-hex
  pubkey is a fully self-consistent, verifying event — but stored/looked-up
  verbatim it would let ONE key bind to two accounts via case variance (bypassing
  the UNIQUE index + 409) and yield non-canonical NIP-05 output. Now every
  downstream store/compare/lookup is on the canonical form. +KAT.
- nostr_link records a login event on success: a session-authed link (including a
  rotation that REPLACES an existing npub) is a security-relevant binding change
  and must not be silent.
- Tighten NIP98_LOGIN_MAX_AGE_SECS 60 -> 30s: a captured login token is a bearer
  credential within the window; match it to the state-change grade.
2026-07-02 05:43:20 -04:00
jwinterm 8d4f78a8a3 feat(auth): wire the Nostr link nonce — link-challenge endpoint + atomic consume
The link flow was stubbed: consume_link_nonce always returned false and no nonce
issuer existed, so nostr_login worked only for already-linked npubs and nothing
could link one. Complete it:

- GET /auth/nostr/link-challenge (authenticated) issues a single-use nonce via
  the existing challenge store, bound to the caller (subject) + the nostr_link
  purpose, 5-minute TTL.
- nostr_link now consumes it atomically (DELETE … RETURNING, so a replay loses
  the race), cross-checks the nonce was issued to THIS user, then verifies the
  signed-action proof (unchanged) and stores the npub.
- Route registered + OpenAPI regenerated (no drift).

tests/nostr_auth.rs: pins the empty-body descriptor hash as a cross-impl KAT (the
wallet binds the identical value), and covers a full challenge→link round-trip
plus replay and wrong-user-nonce rejection.
2026-07-02 04:36:40 -04:00
jwinterm d3dd3fc113 fix(admin): create-admin-wallet fully bootstraps a fresh instance (latch + compose)
create-admin-wallet registered a pending admin key but never latched setup, so on
a fresh instance it left the bootstrap half-open and a later `setup` collided with
it (setup's guard only checked for an ACTIVE admin; the cli key was pending).

- New atomic Database::create_admin_key_bootstrapping: under the admin-keys
  advisory lock, insert the pending generated key AND latch `locked` (unless
  already validly locked), append audit, commit — one transaction. Fails CLOSED on
  a tampered latch (defers to reset-setup) instead of silently healing it.
- bootstrap_admin (setup) now refuses on ANY live admin key (active OR pending),
  not just active, so setup composes with create-admin-wallet. The adopt-existing-
  deployment path (users but no admin keys) is unaffected: no live key -> proceeds.
- reset_setup now takes the same advisory lock (all latch writers serialize) and
  documents the live-key recovery path.
- CLI prints bootstrapped-vs-added and a terminating lost-secret recovery hint.
- Docs (README + OPERATOR_SETUP section 5): both mutually-exclusive bootstrap paths.

Adversarially reviewed (atomicity / lockout / regression — all ship). L1 test
extended: fresh-latch, no-relatch-when-locked, pending semantics, composition
guard, and tamper-refuse (fails closed + rolls back the key insert).
2026-07-02 03:19:32 -04:00
jwinterm a919ea7b52 docs(operator): OPERATOR_SETUP guide + fix .env.example peppers + README bootstrap
- .env.example: add the required at-rest identity secrets (SEED_FINGERPRINT_PEPPER,
  REFRESH_TOKEN_PEPPER, IP_SALT). They were missing, so `cp .env.example .env` on a
  fresh setup boot-failed closed on the first required-secret check.
- README: correct the first-admin bootstrap to `smirk-admin setup --pubkey`.
  create-admin-wallet registers a pending key but does not latch, so on a fresh
  instance it leaves the bootstrap uninitialized.
- docs/operations/OPERATOR_SETUP.md: full deployment guide — database, secrets,
  per-chain backends, run + verify, admin bootstrap, registration policy, and
  smoke-testing an instance (SMOKE_API_STYLE=namespaced).

Surfaced by a clean-room operator setup against a fresh instance.
2026-06-30 23:19:05 -04:00
jwinterm 54ae9fadac feat(registration): pay-to-register via pluggable payment provider (gate #3)
Optional REGISTRATION_REQUIRE_PAYMENT gate: a new wallet must settle an
invoice on an external, non-custodial payment processor before it can
register. Pull model — the grant reads the processor's authenticated API as
the source of truth, so there is no inbound webhook to secure and a settled
invoice cannot be forged over HTTP. Returning wallets and self-hosting bypass
it, exactly like the invite and PoW gates.

- infra/payment: a pluggable PaymentProvider seam (neutral Invoice /
  InvoiceStatus / InvoiceRequest) with a BTCPay-compatible first adapter that
  also serves this project's xmrcheckout/wowcheckout apps + the BTCPay Monero
  plugin, so "operator picks any coin" costs one adapter. A future ETH/native
  adapter (BTCPay serves neither) implements the same trait. Only `Settled`
  grants (unknown status fails safe); the API key rides in a redacting Secret
  and hostile responses are size-capped.
- payment_invoices: invoice<->pubkey_hash binding + atomic single-use consume
  (UPDATE ... WHERE consumed_at IS NULL AND pubkey_hash=$ RETURNING), so one
  invoice grants at most one registration and never a different identity.
- POST /auth/payment-invoice mints + binds; enforce_payment (bind -> processor
  `Settled` -> atomic consume) runs beside enforce_invite; payment_invoice_id
  on the register request.
- Fail-closed config: https-in-prod, positive-decimal price, >= 1 confirmation
  (bars a 0-conf settled-then-double-spent free registration), bounded expiry.
  /capabilities advertises payment_required + price; OpenAPI + .env.example.

Tests: adapter status/speedPolicy mapping + config validation (unit), and L1
binding + atomic single-use (concurrent-consume-one-wins, mis-bind rejected).
Reviewed adversarially across payment-integrity, leak/oracle, and config/DoS
lenses; the confirmed findings (0-conf, expiry bound, Settled-means-paid-in-full
adapter contract, rotation-branch bypass guard) are addressed here.
2026-06-30 21:30:12 -04:00
jwinterm 56238dcac8 feat(restore): default create-only + PoW-priced restore depth (gate #2)
Default restore policy is now create-only — which betrays no self-sovereignty,
since that lives at the ECOSYSTEM level (run your own backend). On top of the
create-only|bounded|unlimited depth gate, restore depth can be PRICED in hashcash
PoW so an importer pays compute proportional to the chain-scan cost they impose
on the operator (deeper restore => more work).

- core::restore_pow: a self-contained hashcash bound to (asset, address,
  start_height) — a solved nonce is valid only for that exact restore, so no
  challenge state, no replay, no depth-downgrade. Difficulty = leading zero bits.
- RestoreConfig: pow_free_days / pow_days_per_bit (0 = off) / pow_max_bits curve;
  required_restore_pow_bits + enforce_restore_pow (literal errors, no oracle).
- Enforced at xmr/wow register + grin scan via a new restore_pow_nonce; the curve
  is advertised via /capabilities so the wallet computes + solves it.
- Default WALLET_RESTORE_POLICY flipped bounded -> create-only.
- Unit tests (curve scaling/cap, off-when-zero, valid/invalid nonce, hashcash
  roundtrip + param-binding); .env.example; OpenAPI drift clean.
2026-06-30 18:35:20 -04:00
jwinterm 610819bb5d feat(registration): composable gate framework + invite codes (gate #1)
First of the operator-configurable registration gates (PoW-priced restore and
pay-to-register follow). A NEW wallet must present a valid invite code when
REGISTRATION_REQUIRE_INVITE is on; returning wallets and self-hosting bypass it,
and the active gates are advertised via /capabilities so the wallet shapes
onboarding.

- migration + db: invite_codes table storing only sha256(code) (128-bit codes,
  no pepper needed). Redemption is an atomic single-statement claim
  (UPDATE ... WHERE used_at IS NULL RETURNING) — single-use even under concurrent
  submission (L1 test fires 8 concurrent claims; exactly one wins).
- core::invite: generate (base58, 128-bit) + sha256 hash.
- enforce_invite in extension_register, mirroring enforce_pow (returning bypass;
  ONE literal error for not-found/used/expired — never an oracle).
- RegistrationConfig.require_invite + RegistrationCapability in /capabilities.
- smirk-admin mint-invite --count/--label (prints raw codes once; stores hashes).
- L1 tests (single-use + concurrency race), .env.example, OpenAPI (drift clean).
2026-06-30 17:14:53 -04:00
jwinterm 1e08119c5e fix(lws): import_account uses add_account + /rescan for the modern LWS
The 1.1-alpha LWS dropped the start_height param from add_account (every account
starts scanning at the chain TIP) and moved restore to a separate /rescan admin
command. The backend's import_account still passed start_height to add_account —
a no-op there, so imported/restored wallets never scanned their history (balance
read as 0).

Rework import_account: register only if the account is new (add_account errors
on a duplicate), then lower its scan height to the wallet birthday with a
backwards /rescan when that strictly lowers it (honoring rescan's backwards-only
invariant). Drop the now-unused start_height from add_account / AddAccountParams.

Verified live against a freshly-built wownero-lws 1.1-alpha: registering with a
start_height drives the rescan and the historical outputs are found.
2026-06-29 20:33:07 -04:00
jwinterm 99e1727204 feat(lws): balance as a verification passthrough (spent_outputs + mempool pending)
POST /wallet/lws/balance returned a server-summarized {unlocked, locked} where
unlocked = total_received - locked OVER-COUNTED: a non-custodial backend cannot
verify spends without the spend key, so it never subtracted them. Reshape to the
wallet's verification-passthrough contract — aggregate get_address_info +
get_address_txs (parallel) and return total_received, locked_balance,
pending_balance (0-conf summed directly from mempool rows: webhook-free, cleaner
than the private backend's DB table), and spent_outputs[] for client-side
key-image verification. The wallet nets its own spends (privacy-correct).
spent_outputs fixes the over-count immediately; pending_balance activates once
the parity LWS mempool feature is deployed. Adversarially reviewed (3 lenses,
0 confirmed findings).
2026-06-29 16:13:46 -04:00
jwinterm 224ef36ad3 feat(restore): operator-configurable wallet restore policy
How far back a wallet may ask THIS instance to scan on import is an operator
decision — the backfill cost lands on its LWS/node. Add WALLET_RESTORE_POLICY
(create-only | bounded | unlimited, default bounded) + WALLET_MAX_RESTORE_DEPTH_DAYS,
advertise it via /capabilities so the wallet adapts its import UX, and enforce
it at the scan path (xmr/wow register, grin scan): a too-deep start_height is a
ValidationError naming the bound, never the caller's value. Create needs no
check (no tip lookup). Self-sovereignty stays intact at the ecosystem level —
a deeper restore means self-hosting or a looser operator. Fail-closed config
validation; a unit test per policy; OpenAPI drift clean.
2026-06-28 22:15:42 -04:00
jwinterm ff89ca6584 feat(wallet): unified GET /wallet/heights (tip per chain)
The wallet's ChainProvider seam fetches all chain tips in one call
(getBlockchainHeights) and selects per asset; the backend exposed per-chain
height endpoints but no aggregate, so the client's call 404'd. Add a
best-effort /wallet/heights returning {btc,ltc,xmr,wow,grin} concurrently — a
disabled or unreachable chain is null, never failing the others. JWT-gated,
OpenAPI-annotated (drift clean).
2026-06-28 22:01:14 -04:00
jwinterm 56c8dd463a feat(openapi): document bearer auth on the 28 token-gated routes
The spec carried no security scheme, so generated clients/docs could not tell
which endpoints require a session token — e.g. /wallet/utxo/balance read as
public while its handler enforces auth via extract_user_id_from_token. Add a
`bearer_auth` HTTP/JWT scheme through a Modify addon and annotate every route
that resolves a session; auth-bootstrap, public username/key lookups, and
system routes stay open. Regenerated openapi.json (drift clean).
2026-06-28 21:42:32 -04:00
jwinterm 29efbf4494 chore: point repository at Such-Software/smirk-backend-core (publish org) 2026-06-28 08:45:31 -04:00
jwinterm a7c144b11f docs: MIT LICENSE (Such Software LLC) + README for v0.3.0
- LICENSE: MIT, © Such Software LLC (published under the company umbrella).
- Cargo.toml: license = "MIT".
- README: rewritten for the feature-complete release — non-custodial guarantee,
  chains, public API + operator surface, running it, security posture, testing.
2026-06-28 08:38:57 -04:00
jwinterm efb54911de docs: scrub private-doc references from source comments (pre-publish)
Source comments pointed at gitignored docs/private/*.md design docs — harmless
(no secret) but dangling references in a public tree. Inlined the rationale and
dropped the pointers. Comment-only.
2026-06-28 08:33:24 -04:00
jwinterm 8120d93fb8 fix(erasure): harden per adversarial review (Subsystem 5)
6 confirmed findings (review wqrd2gka3), 3 root-cause fixes:

- HIGH: execute_erasure had no status recheck and the sweeper's SKIP LOCKED row
  lock was released before execution (separate connection), so (a) a request
  CANCELLED after the sweep's SELECT was still executed, and (b) a fleet could
  double-execute one erasure (duplicate hash-chained audit row, double count).
  Fixed by making execution atomically CLAIM the row as its first statement —
  `UPDATE ... SET status='completed' WHERE id=$1 AND status='confirmed'
  RETURNING user_id` — and skipping (returns false) when 0 rows match. Only the
  first executor of a still-confirmed request proceeds; a cancelled/already-done
  row is a no-op; on any later failure the whole tx (claim included) rolls back.
- account-existence oracle: the absent-user request path returned a FRESH random
  erasure_id + advancing scheduled_for each call, while a real account returned a
  STABLE get-or-create id — a re-call distinguisher. The request response is now
  constant-shape: a STABLE id (real, or a deterministic integrity-secret-keyed
  synthetic for the absent path), a fixed "pending" status, and NO scheduled_for
  (it's meaningful only at confirm, where grace starts, and is returned there).
- config: ERASURE_GRACE_PERIOD_HOURS is bounded (<= 10 years) in validate(), so
  the u64->i64 cast can't wrap negative (defeating grace) and Duration::hours
  can't panic.

Dismissed after verification: ERASURE_ENABLED without FEATURE_NOSTR_IDENTITY
(validate already requires PUBLIC_API_URL; the nostr_identity coupling is a
nicety). Tests extended: cancelled-request-not-executed (HIGH regression),
double-execute no-op. 140 lib + 140 integration green, no drift, scans clean.
2026-06-28 07:59:52 -04:00
jwinterm b03b9cb90d feat(erasure): self-service account erasure + export [operator §5]
The last operator subsystem: a user who no longer trusts the backend can reach in
and delete their data. Action-bound and two-phase.

- migration 20260626000003: erasure_requests (user_id ON DELETE SET NULL so the
  completed tombstone survives the user delete; one-live-request-per-user partial
  unique index; due index).
- Proof model (§5.2): every action is a fresh NIP-98 signed action (purpose
  erasure_request/confirm/cancel/export) bound to a single-use challenge nonce +
  canonical descriptor + (confirm/cancel) the explicit erasure_id. A session
  token is NEVER sufficient. Erasure targets only the users.id the proof's key
  resolves to (via linked nostr_pubkey); leaked identifiers are never selectors.
  Nonce consumed AFTER signature checks (forged sig can't burn a victim's nonce).
- Public routes (proof-gated): POST /account/erasure/challenge, /account/erasure
  (request; ABSENT-user path returns identical shape — no account-existence
  oracle), /account/erasure/:id/confirm (revokes live sessions), DELETE
  /account/erasure/:id (cancel during grace), POST /account/export (see-before-
  delete; omits view keys; audited).
- Execution (§5.7): a background sweeper claims confirmed-past-grace rows
  (FOR UPDATE SKIP LOCKED) and runs ONE fail-closed tx: purge login_events
  (the origin/asset/time quasi-identifier; default) or anonymize; scrub audit
  PII; DELETE users (schema cascades wallets/sessions/keys/owned slatepacks,
  SET NULL on counterparty/audit/login links); tombstone; hash-chained
  account_erasure_completed audit.
- config: ERASURE_GRACE_PERIOD_HOURS (72h default); validate() requires the
  integrity secret + PUBLIC_API_URL when erasure is enabled.
- 1 L1 test: export -> request -> confirm -> execute -> account gone, plus the
  absent-user constant-shape path.

Scope (v1, documented): live primary. No at-rest view key exists to crypto-shred
(stateless-forward), so backup residue is bounded by retention-window docs, not
crypto-shred (non-v1 per decisions ledger). Deferred: LWS de-registration queue +
read-back probe (operators run their own LWS), audit cross-reference scrub on
OTHER users' rows, restore_attempt salted-rotated fingerprint, retention sweepers,
NIP-17 DM warning, in-flight-value blockers (no swaps/tips in the clean core),
hard per-day export rate cap, /admin/erasure/stuck observability. Routes are off
the public OpenAPI for now (stable contract; annotate later).
2026-06-27 20:43:10 -04:00
jwinterm e618574ee7 fix(operator): harden S3/S2/S4 per adversarial review
9 confirmed findings (review wbdf3c0sd), all fixed:

S4 (capabilities):
- REGRESSION: /capabilities reported grin_relay:false for a working relay (S4
  wrongly coupled it to Grin chain serviceability). The relay is a non-custodial
  mailbox — wallet broadcasts locally — so it is decoupled again. Removed the now-
  dead grin_relay downgrade note.

S3 (bootstrap latch):
- A DELETE of the latch row escaped Locked without Tampered detection on a
  CLI-bootstrapped-but-no-users instance (adoption keyed off `users` only). Boot
  now adopts on has_any_users() OR has_any_admin_keys(), so a deleted latch
  re-latches Locked instead of silently dropping to uninitialized.
- bootstrap_admin's single-bootstrap guard was a check-then-insert race (two
  concurrent `setup` could seed two admins). It now takes the admin-keys mutation
  advisory lock (shared with add/revoke/rotate) at the top of its tx.

S2 (admin posture + landing):
- Hard bind guard matched loopback by string prefix ("localhost".starts_with) —
  "localhost.evil.com" slipped past. Now resolves via to_socket_addrs and requires
  EVERY resolved addr to be loopback.
- host_allowed's IPv6 loopback check was an unanchored prefix ("[::1]" matched
  "[::1].evil"); now anchored to exactly "[::1]" / "[::1]:port". +regression tests.
- Anti-clickjacking headers (X-Frame-Options/CSP) are now applied on the 403
  Host-reject path too (computed on both branches, headers added once).
- GET /api/v1/server-info now returns a BARE 404 when landing is off (the
  {error,code} envelope itself hinted the route exists).
- Public landing `chains` now uses the SAME serviceability predicate as
  /capabilities (no over-advertising an unserviceable chain).
- Removed the dead/misleading PUBLIC_EXPOSE_FEATURES toggle (never read; the
  feature tuple is intentionally never emitted anonymously).

Dismissed after verification: latch chains-by-flag (dup), title length bound
(operator-owned + escaped), prices:true with empty PRICES_ASSETS (documented).
140 lib + 140 integration green, no openapi drift, scans clean.
2026-06-27 19:44:08 -04:00
jwinterm 2d0077b2b9 feat(capabilities): honest secret-presence downgrade + admin features view [operator §4]
Make /capabilities fail-closed and add the admin features surface.

- Secret-presence downgrade (§4.2): a chain whose flag is on but whose source is
  unconfigured (btc/ltc no electrum URL, xmr/wow no LWS admin key, grin no owner
  secret) now reports enabled:false — so /capabilities never advertises a chain
  that would 404, and a missing-secret downgrade is indistinguishable from a
  deliberately-off chain. grin_relay requires serviceable Grin; nostr_identity
  requires PUBLIC_API_URL. effective_capabilities() is the single explicit mapper.
- contract_version stamp on the public document (clients soft-notice a higher
  value, ignore unknown additive keys).
- GET /admin/features (admin_guard): the resolved effective state PLUS admin-only
  downgrade reasons (the public surface hides the reason, per §4.3).
- Golden test: the public capabilities JSON, built from a fully-secret-seeded
  config, contains no secret value (asserts no "integration-test-" substring).

openapi regenerated (contract_version). Admin features view stays off the public
OpenAPI.

Deferred (documented): runtime mutation — PUT /admin/features + a DB feature-
override table + resolve() precedence + Arc hot-swap + CLI `features set`. Flags
are env/config-driven for v1; runtime tuning (and its fleet propagation, itself a
deferred LB decision) is a later addition.
2026-06-27 15:35:56 -04:00
jwinterm 51a3371be1 feat(admin,landing): admin-plane hardening + public landing [operator §2]
Admin posture (§2.1/§2.2):
- Hard fail-closed bind guard in main: a non-loopback ADMIN_BIND refuses startup
  unless ADMIN_ALLOW_PUBLIC_BIND=true (was a warning).
- admin_plane_guard middleware on the admin router: Host allowlist
  (localhost/127.0.0.1/[::1]/configured TOR_ADMIN_ONION; a missing Host is allowed
  since rebinding must supply one and the bind is the primary control) to defeat
  DNS-rebinding against the loopback socket, plus X-Frame-Options: DENY +
  CSP frame-ancestors 'none' (anti-clickjacking). Still no CORS on the admin plane.

Public landing (§2.3), default-OFF, one fingerprint surface:
- GET /api/v1/server-info — built from a hand-written ServerInfo allowlist (never
  by serializing config/state). Default projection is minimal { software.name,
  status }; version (coarse major.minor), chains, price_feed, and uptime (bool
  `up` only) are each individually opt-in. The enabled-feature tuple is NEVER
  emitted anonymously (connected clients use /capabilities).
- GET / — minimal HTML (operator title HTML-escaped); bare 404 when off (does not
  hint an admin plane exists). server-info also 404s when off.
- config: ADMIN_ALLOW_PUBLIC_BIND, TOR_ADMIN_ONION; PUBLIC_LANDING_TITLE +
  per-field PUBLIC_EXPOSE_VERSION/CHAINS/PRICE_FEED toggles.

Tests: host_allowlist + html_escape unit tests; landing off->on minimal-projection
(asserts only software+status, no version/chains/features); admin-plane Host-403 +
anti-clickjacking headers. Landing/admin stay off the public OpenAPI.

Deferred (documented): §2.5 NIP-05 consent-gating (changes existing
/.well-known/nostr.json behavior — needs the standing v0.2.4-compat verification
first), §2.3 k-anon public stats, response-header/TLS-fingerprint stripping (proxy
/ops-doc concern).
2026-06-27 15:32:11 -04:00
jwinterm 67faf8f762 feat(admin): first-run bootstrap latch + CLI setup [operator §3]
No trust-on-first-use: bootstrap mode is an explicit, MAC-protected latch
(server_config singleton), NOT inferred from "no admin keys". The latch MAC
(ADMIN_KEY_INTEGRITY_SECRET over setup_state|bootstrap_completed_at) means a
DB-write attacker who restores a pre-bootstrap backup or flips the state to
re-open TOFU produces a mismatch the boot path detects and fails closed on.

- migration 20260626000002: server_config (id=1 singleton; setup_state +
  bootstrap_completed_at + integrity_mac).
- db::server_config: read_setup_state -> {Fresh,Uninitialized,Locked,Tampered}
  (MAC-verified); has_any_users; init_server_config (adoption: a live upgrade
  latches `locked` so it never exposes a setup window; a truly empty DB begins
  uninitialized); bootstrap_admin (insert the FIRST active admin AND latch
  locked + hash-chained audit, ONE tx; refuses if an active admin already
  exists); reset_setup (shell recovery; recomputes MAC, preserves admin keys).
- main boot (admin enabled): adopt/await-bootstrap, and FAIL CLOSED on a tampered
  latch.
- smirk-admin: `setup --pubkey` (the supported headless bootstrap) + `reset-setup
  --i-understand`.
- 1 L1 test: Fresh -> locked(valid) -> wrong-secret Tampered -> raw-flip Tampered
  -> reset Uninitialized -> bootstrap refused while an active admin exists.

Deferred (documented): the HTTP /setup/* wizard. Per the spec the CLI / `docker
exec` is the SUPPORTED bootstrap (pure-PaaS without shell is explicitly
unsupported), so the loopback TTY wizard is a later convenience, not load-bearing.
2026-06-27 14:11:28 -04:00
jwinterm 12e4a54c63 feat(admin): smirk-admin break-glass CLI [operator §1.6]
A binary that talks to Postgres directly, bypassing the HTTP admin plane and all
network rate limits — the recovery path when the network plane is unreachable or
a solo admin key is compromised. Authority = shell + DB creds +
ADMIN_KEY_INTEGRITY_SECRET. Every mutation writes a hash-chained audit row
(actor_kind=cli).

Commands:
- list-keys
- add-key --pubkey <64hex> [--label]
- revoke-key --id <uuid>            — MAY revoke the last key (keep_min_live=false;
  the network DELETE refuses that, the CLI is the authority)
- replace-key --old <uuid> --pubkey <64hex> [--revoke-all] — atomic rotate; with
  --revoke-all, the global break-glass that revokes every other live key
- create-admin-wallet --out <path> — generate a schnorr keypair from OS entropy,
  write the secret hex to a NEW 0600 file (create_new, never clobber/stdout/log),
  register only the pubkey; seed + secret buffers zeroized (zeroize crate)
- doctor — DB reachability, live-key count, audit-chain verification

Preflight refuses to run if a present .env is group/other-readable. No new dep
beyond zeroize; bin auto-discovered as `smirk-admin`. Smoke-tested end to end
(wallet file is 0600; bad pubkey rejected; doctor detects a wrong-secret chain).

Deferred (documented): remac-keys (integrity-secret rotation must also re-chain
the audit log) and setup/reset-setup (first-run bootstrap = Subsystem 3).
2026-06-26 11:04:05 -04:00
jwinterm 5df12d3df8 fix(admin): harden allowlist per adversarial review (Subsystems 0-1)
From a 5-lens adversarial review of the admin surface (6 confirmed findings,
collapsing to 4 fixes; 2 dismissed after verification):

- HIGH: last-key-revoke TOCTOU (lockout). The count(live)<=1 floor was checked in
  the handler, then revoke ran separately — two concurrent revokes could both pass
  and empty the allowlist. Moved the floor INTO revoke_admin_key_full under a
  per-tx advisory lock (admin_keys_mutate_lock); returns RevokeKeyOutcome
  {Revoked,NotFound,WouldEmptyAllowlist}.
- LOW: ADMIN_MAX_KEYS cap TOCTOU. Same check-then-mutate race on add. Cap is now
  counted INSIDE create_admin_key_audited under the same lock; returns
  AddKeyOutcome {Created,CapReached}. rotate takes the lock too, so all
  add/revoke/rotate serialize.
- LOW: integrity MAC didn't cover activation_deadline — a DB-write attacker could
  null/extend the deadline to revive an expired pending key without breaking the
  MAC. Added activation_deadline to AdminKeyMacInput + the canonical input +
  every compute/verify site; new KAT binds it.
- LOW: get_active_admin_key (and create_*) logged the full pubkey via #[instrument]
  (params weren't skipped), defeating the prefix-only / no-social-graph design.
  Skipped pubkey/input from those spans.

Dismissed (both skeptics not-a-bug): audit-chain JSONB `details` round-trip — the
handlers always pass details:None and serde's sorted-key default makes the
string case round-trip (the L1 chain test proves it). Documented v1 deferrals
(login timing-oracle hardening, per-pubkey backoff, hard non-loopback bind guard)
remain tracked, not regressions.

All gates green: 138 lib + 138 integration, no openapi drift, secret-scan, leak.
2026-06-26 07:14:03 -04:00
jwinterm 62104ca85e feat(admin): allowlist key CRUD + kill-switches [operator §1.5]
Admin-plane key management, all admin_guard-protected and fail-closed audited:

- POST /admin/keys: add a pending key (validated 64-char lowercase x-only hex;
  created_by_kind=admin; activation_deadline = now + ADMIN_PENDING_KEY_TTL_DAYS).
  Capped at ADMIN_MAX_KEYS live keys (anti allowlist-stuffing).
- GET /admin/keys: list active/pending/revoked (sanitized view + status).
- DELETE /admin/keys/🆔 soft-revoke + cascade-revoke its sessions + audit in ONE
  tx. Refuses to revoke the LAST live key over the network (CLI-only), so an
  operator can't lock everyone out.
- POST /admin/keys/:id/rotate: atomic revoke-old(+sessions)+add-new(pending)+audit
  in one tx — permitted on the last key (add+revoke, not bare revoke), the in-band
  recovery for a compromised solo key.

db: create_admin_key refactored onto a shared insert_admin_key(&mut PgConnection)
helper; create_admin_key_audited / revoke_admin_key_full / rotate_admin_key run
their state change + session cascade + hash-chained audit in a single transaction.
1 L1 test extended to cover add/list/rotate/revoke + malformed-pubkey rejection.

NOTE: the live-key cap and last-key revoke guard are count-based on a table the
shared test DB pollutes across suites, so their boundaries aren't integration-
asserted (covered by review); the happy path raises the cap.
2026-06-26 06:46:07 -04:00
jwinterm 4489c1c540 feat(admin): admin auth flow + guard + loopback plane [operator §1.1-1.4, §2.1]
"Sign in with Smirk" for operators. Auth is a NIP-98 signed action over a
single-use nonce (AUTHN) + the MAC'd allowlist (AUTHZ), composed in admin_guard
so a protected route physically cannot run without both. All rejections collapse
to one opaque 401 (no enumeration oracle).

- src/api/admin.rs: admin_guard (verify access token -> live session by jti ->
  live MAC-verified, activated allowlist entry) + AdminContext; handlers:
  - challenge: mint a 120s admin_login nonce + return the {url, instance_id} the
    wallet must bind.
  - verify: verify_signed_action (purpose/nonce/descriptor/instance) THEN consume
    the nonce (forged sig can't burn a victim's nonce; replay loses the race) ->
    authorize (pending key allowed for first login, which activates it) -> mint +
    persist session AND the login audit row in ONE tx (fail-closed) -> bump
    last_used.
  - refresh: re-run FULL authorization before minting (closes the "8h refresh is
    the real blast radius" gap) + constant-time refresh-hash bind + rotate jti so
    the old access token dies.
  - logout: revoke the session; audit. me: guarded whoami.
- admin_router (lib): served on a SEPARATE loopback socket in main (confidentiality
  by socket, not middleware); no CORS layer (cross-origin browser calls blocked);
  warns if bound non-loopback (hard bind-guard lands with admin posture). Routes
  are intentionally absent from the public OpenAPI.
- config: ADMIN_PUBLIC_URL (the signed-action u-tag base; validated when enabled).
  AppState.admin_sessions (Some only when enabled). AdminSessionManager.mint_access
  (refresh) + db helpers (create_admin_session_audited, touch_admin_key_last_used).
- test harness: admin_router() + request_on(router); 1 L1 test exercising the full
  flow end-to-end incl. user-token rejection, replay rejection, jti rotation, and
  post-logout revocation.

NOTE (for the upcoming S1 review): admin-login timing-oracle hardening (fixed-cost
allowlist probe + jittered 401 floor, spec §1.3) is deferred — the loopback/onion
exposure + single-use nonce + signature gate are the v1 boundary. Per-pubkey
backoff (§0.5) also pending.
2026-06-26 06:36:55 -04:00
jwinterm 49df1d1ae5 feat(admin): tamper-evident privileged-action audit chain [operator §0.4]
A separate, low-volume audit trail for admin/setup/erasure actions, hash-chained
so a DB-write attacker cannot silently rewrite history. Kept distinct from the
high-volume user audit_logs (which keeps its enum action) — the chain applies
only where the privacy-maximalist mandate requires it.

- migration 20260626000001: admin_audit_logs (free-text action, actor_kind,
  actor_pubkey_prefix [prefix only, no social graph], target, details, real-peer
  ip, prev_hash, row_hash; monotonic seq).
- db::admin_audit: append_admin_audit(&mut PgConnection) so the audit row commits
  in the SAME transaction as the state change it records (fail-closed, not a
  best-effort side effect); record_admin_audit (own tx) for standalone audits;
  verify_admin_audit_chain (boot/doctor walk — detects edit/delete/reorder/broken
  linkage); list_admin_audit (panel). row_hash = HMAC(integrity_secret,
  prev_hash || canonical(row)); appends serialize on a tx advisory lock so
  seq/prev_hash cannot race.
- AdminAuditLog/NewAdminAudit models; 1 L1 test (link + verify + tamper-detect +
  restore).
2026-06-26 06:06:54 -04:00
jwinterm 041860bc94 feat(admin): admin session JWTs + admin_sessions queries [operator §0.3]
Admin tokens are cryptographically distinct from user tokens: a dedicated
ADMIN_JWT_SECRET, a distinct audience (smirk-admin) plus an explicit scope, and
the centralized strict Validation (HS256 pinned, zero leeway, aud required) —
Validation::default() is never used on this path. Access TTL 15 min (carries a
jti recorded in the session row, so a soft-revoked admin's still-valid token is
rejected at the next call), refresh TTL 8 h (binds a session id).

- core::admin_session: AdminSessionManager (mint/verify access+refresh) with the
  four required regressions + scope check: user token fails admin decode;
  wrong/absent aud fails; HS512 (alg confusion) fails; expired fails at zero
  leeway; non-admin scope fails.
- core::session::strict_validation made pub(crate) and shared (no drift).
- db::admin_sessions: create, find-active-by-jti (guard), find-active-by-id +
  rotate-jti (refresh), revoke (logout), revoke-all-for-key (key cascade), and a
  dead-session sweep. Lookups return only live (unrevoked, unexpired) rows.
- NewAdminSession model; 2 L1 tests (lookup/rotate/logout, key->session cascade).
2026-06-25 18:46:50 -04:00
jwinterm a03dfc6253 feat(admin): allowlist + integrity-MAC data model [operator §0.3/§1.2]
The admin authorization data model. The server stores only public keys; an admin
is authorized by presence in admin_keys, and each row carries an HMAC
(ADMIN_KEY_INTEGRITY_SECRET, separate from DATABASE_URL) over
id|pubkey|scope|created_at|activated_at|revoked_at. Because the MAC covers the
revocation/activation state, a DB-write attacker who clears revoked_at (un-revoke)
or revives a key produces a mismatch the guard rejects — the MAC proves a pubkey
is *currently* authorized, not merely once.

- migration 20260625000002: admin_keys (partial unique index = one active row per
  pubkey; soft revoke keeps history) + admin_sessions (separate table/secret from
  user sessions; cascade-on-key-delete).
- core::crypto::admin_mac: compute/verify over a JSON-canonical field set,
  timestamps as microsecond epochs; constant-time compare; KATs bind
  revoked/activated state.
- db::admin_keys: create / get_active (re-verifies MAC, fail-closed + logs tamper)
  / list / count_live / activate / revoke. Every covered-field write recomputes
  the MAC centrally; timestamps truncated to microseconds so the signed value
  round-trips; revoke/activate are FOR UPDATE transactions.
- 5 L1 tests incl. the load-bearing raw-SQL un-revoke -> MAC mismatch -> rejected.
2026-06-25 18:38:57 -04:00
jwinterm 4f9d00f1d2 feat(db): unified single-use challenge store [operator §0.2]
The shared server-nonce store every signed action consumes (admin login,
first-run setup, self-service erasure). DB-backed so consumption is atomic and
correct under concurrent requests and a future load-balanced fleet:

  DELETE FROM challenges WHERE nonce=$1 AND purpose=$2 AND expires_at > NOW()
                         RETURNING subject

Winning the race returns one row; zero rows = replay/expired/wrong-purpose =>
reject (no TOCTOU). issue_challenge mints a 256-bit nonce bound to a purpose and
optional subject; consume_challenge is the atomic single-use step that pairs with
verify_signed_action (already built, §0.1). Periodic expiry sweep bounds the
table; the table holds no secret and is documented as backup-excluded
(ephemeral, links pubkey->time).

migration 20260625000001; ConsumedChallenge exported; 3 L1 tests (consume-once /
replay-rejected, wrong-purpose rejected, expired rejected).
2026-06-25 17:33:59 -04:00
jwinterm 771a69b85e fix(prices,config): harden per-feed control + fail-closed gaps (adversarial review)
From a multi-lens adversarial review of the capabilities + prices work:

- PRICES_ASSETS="" now means NONE, not all (real bug): from_env used env_opt,
  which collapses unset and present-but-empty into None, so the documented
  "empty = none" silently served all five feeds. Read via env::var so unset =>
  all, present (even empty) => the listed subset or none.
- PRICES_CURRENCY is now validated against a curated SUPPORTED_PRICE_CURRENCIES
  allowlist. Previously a typo (e.g. "usdd") booted, advertised prices:true, and
  served an empty feed forever (the provider returns empty quotes for an unknown
  currency) — a fail-open path inconsistent with the adjacent provider/asset
  checks.
- PRICES_PROVIDER is lowercased at read, matching the asset/currency
  normalization (mixed-case no longer trips the allowlist).
- validate() now requires an Electrum URL for an enabled BTC/LTC chain (hard
  error in prod, warn in dev), symmetric with the xmr/wow/grin secret checks.
  Keeps /capabilities honest: a chain it can't serve won't boot advertising
  itself as available.

Tests: config regressions (currency, utxo-electrum); a PriceClient feed-subset
selection unit test; the /prices integration test now populates the snapshot and
asserts the served quotes + RFC-3339 timestamp (the prior PRICES_ASSETS env-var
was inert under the harness); capabilities asserts network presence/null across
all five chains.

Dismissed after verification: coingecko json() was already switched to a
streaming read_capped; fleet refresh-jitter (no API key, single-node,
self-healing on 429); prices capability staying a bare bool (/prices carries the
currency + per-asset map).
2026-06-25 17:25:52 -04:00
jwinterm d8b6058d40 feat: fiat price feed with per-feed operator control (GET /api/v1/prices)
Cached crypto→fiat price feed, refreshed by a background task and served from an
in-memory snapshot — no upstream call on the request path. Public and
feature-flagged: when FEATURE_PRICES is off, /prices is 404 and no upstream is
ever contacted.

Per-feed control, as a public server needs: PRICES_ASSETS narrows which feeds an
instance quotes (unset = all supported; an explicit list serves a subset; empty
serves none), validated fail-closed against SUPPORTED_PRICE_ASSETS. Provider
(PRICES_PROVIDER, coingecko) and currency (PRICES_CURRENCY, usd) are configurable;
the refresh interval is clamped to a provider-friendly minimum.

- infra/prices: PriceClient (feed whitelist → provider coin ids), bounded by the
  HTTP client timeout; non-finite/negative/missing quotes are skipped, not faked.
  A fetch failure keeps the last good snapshot. Drift guard: a test asserts the
  provider mapping covers exactly SUPPORTED_PRICE_ASSETS.
- main: background refresh task only when enabled and at least one feed is set.
- AppState.prices snapshot; handler 404s when disabled.
- openapi (prices tag); unit tests + L1 test over both feature-flag states.
2026-06-25 16:41:40 -04:00
jwinterm 1e2bdf3f16 feat: GET /api/v1/capabilities (client feature discovery)
Public, unauthenticated description of what this deployment offers, so the
wallet can adapt to the instance it's pointed at — grey out disabled chains and
features, and pick the right network. This is the client-facing half of the
open-core/feature-flag design: every capability is a config switch, surfaced
here. No secrets — only on/off flags and public network names.

Response: { version, chains: { btc/ltc/xmr/wow/grin: { enabled, network } },
features: { grin_relay, prices, nostr_identity, tips } }. UTXO chains carry
their configured network; CryptoNote/Grin report null. On the normal rate-limit
tier. Registered in openapi (system tag); 1 L1 test (public, well-shaped).
2026-06-25 16:27:13 -04:00
jwinterm c558e9d7bd feat: per-IP rate limiting (tower_governor tiers) + ConnectInfo wiring
Add tiered per-IP rate limiting to the router, completing the assembly:
- STRICT tier (~2 req/s, burst 20) on the unauthenticated, abuse-prone
  surface: /api/v1/auth/* and /api/v1/website/*. This closes the
  website-challenge growth vector (the prior rate-limit LOW): an unauthenticated
  flood of challenge requests is now throttled per IP.
- NORMAL tier (~10 req/s, burst 60) on the authenticated wallet/identity API.
- PeerIpKeyExtractor keys on the peer IP; `main` now serves with
  into_make_service_with_connect_info::<SocketAddr> so the IP is available, and
  the test harness injects a loopback ConnectInfo (each test builds a fresh
  router with fresh limiter state).

Over-limit returns 429. The limiter sits inside the global layers (CORS/timeout/
body/compression/trace) and in front of the handlers.

L1 test proves it throttles: a 40-request burst to a strict-tier route returns
429 (the governor runs before the handler, so even unauthenticated requests
count). All 33 integration tests + 114 lib tests pass through the fully-layered
router; openapi unchanged.
2026-06-23 19:02:03 -04:00
jwinterm 707613c92b feat: router middleware (CORS, body limit, timeout, compression, tracing)
Layer global middleware onto build_router (applies to every route, shared by
main + the test harness):
- CORS: configurable allowed origins via CORS_ALLOWED_ORIGINS; empty -> allow
  any origin, which is safe here because auth is a Bearer token (no cookies, no
  ambient credentials). Credentials are never allowed.
- Body limit: 1 MiB global backstop (handlers already validate tighter per
  field); /wallet/lws/submit_tx overrides to ~2 MiB+ for raw Monero tx hex.
- Timeout: 60s request backstop (above the chain clients' ~30s timeouts, so
  their errors surface first; bounds any slow handler). Uses the http-aware
  tower_http TimeoutLayer (-> 408), not tower::timeout (fallible, rejected by
  axum's Infallible Router).
- gzip compression + request tracing (outermost).

Adds cors_allowed_origins config + the tower-http "timeout" feature. Per-IP
rate limiting is deliberately separate (needs ConnectInfo) — next commit.

All gates green; integration tests pass through the layered router (CORS/timeout/
compression/body-limit are oneshot-safe). openapi unchanged.
2026-06-23 18:25:10 -04:00
jwinterm 586f681287 feat: Grin slatepack relay (non-custodial async mailbox, feature-flagged)
Final wallet-handler slice. An async store-and-forward mailbox for interactive
Grin transfers between two registered Smirk users, over the grin_slatepacks db.
Reworked clean from the legacy relay — NOT a port.

Endpoints (under /api/v1/wallet/grin/relay, JWT-gated):
- POST create   {recipient_user_id, slate_id, slatepack, amount_nanogrin}
- GET  pending  -> the caller's inbox
- POST get      {slate_id} -> poll a relay's state (sender/recipient)
- POST respond  {slate_id, response_slatepack} (recipient)
- POST finalize {slate_id, tx_hash} (sender; RECORDS the txid only)
- POST cancel   {slate_id} (sender or recipient)

Non-custodial: stores only opaque (encrypted) slatepacks + lifecycle status;
never holds keys, finalizes, or broadcasts. The wallet broadcasts locally (via
/wallet/grin/broadcast) and reports the txid here — diverging from the legacy
server-side post_tx (see docs/private/OWN_BACKEND_CONVERGENCE.md).

Controls + hardening (incl. fixes from the adversarial review workflow):
- Per-action authz: caller must be the slate's sender/recipient; a non-party
  gets 404 (no existence oracle).
- slate_id is UNIQUE (+ validated as a UUID) and mutations key on the
  authorized row's PRIMARY KEY, not slate_id — so a duplicate slate_id can't
  brick a relay (fetch_optional-on-2-rows 500) or let a slate_id-keyed UPDATE
  touch another row. create maps the unique violation to 409.
- State transitions are ATOMIC: the status + expiry preconditions live in the
  UPDATE WHERE clause (not a Rust read-check-write), so respond-twice and
  cancel-races-finalize cannot occur; a non-qualifying transition -> 409.
- TTL enforced at the action boundary (respond/finalize require expires_at >
  now) + a periodic expire_old_slatepacks sweep in main.rs (relay-enabled only).
- Recipient must be a registered user; slatepack sizes capped (64 KiB).
- Behind FEATURE_GRIN_RELAY (default on).

6 paths + schemas + grin_relay tag in openapi (drift-clean). 4 L1 integration
tests against live Postgres: full lifecycle, unregistered-recipient 400, auth
gate, non-party/non-recipient 404s.
2026-06-23 17:39:13 -04:00
jwinterm e62f1bec8e feat: Grin wallet handlers (view-only, non-custodial)
Third wallet-handler slice. Authenticated, view-only proxies over the
GrinClient — the backend holds no Grin spend key; the wallet exports its
rewind_hash (a public-derived view credential) per scan, the backend forwards
it to grin-wallet's scan_rewind_hash and stores no secret. Spending happens in
the wallet; the backend only broadcasts the finalized tx.

Endpoints (under /api/v1/wallet/grin):
- POST scan {rewind_hash, start_height?} -> {outputs, total_balance,
  last_pmmr_index}  (incremental resume index)
- GET  height -> {height}
- POST broadcast {tx} -> {ok}  (node push_transaction)

- JWT-gated. rewind_hash is a view credential: GrinScanRequest omits Debug and
  is skipped from every span (never logged), and is validated as 64 hex before
  any grin call (applying the wallet-handler review's secret-DTO lesson).
- Feature-gated: grin disabled -> 400.
- Shared validate_hex64 in wallet/mod.rs (view_key + rewind_hash); 3 paths +
  schemas + grin tag registered; spec regenerated, drift-clean.

4 L1 integration tests (auth gate on scan/height/broadcast; malformed
rewind_hash 400).

Also: dropped Debug from the xmr_wow view_key-bearing request DTOs
(ViewRequest/RegisterRequest) per the review — defense-in-depth against a
cleartext view_key dump.
2026-06-23 16:02:44 -04:00