Commit Graph
94 Commits
Author SHA1 Message Date
jwinterm 22d362b62f feat(config): GET/PUT /admin/config endpoints on the settings layer (Stage 3a)
The operator-editable config surface, on the effective-config layer:
- GET /admin/config: per-field effective value + the DB overlay (source=db) + per-section
  version + runtime_class map + restart_pending (persisted-but-unapplied change).
- PUT /admin/config: a sparse patch is field-merged onto the persisted overlay, validated
  with the SAME Config::validate boot uses (400 on invalid, nothing persists), then each
  changed section is persisted audited + version-guarded (409 on a stale expected_version).
  Runtime-safe edits HOT-SWAP the effective config atomically (arc-swap, live next request);
  any restart-required field is persisted + flagged restart_pending (operator restarts to
  apply). Records the editing admin key as updated_by.
- config_overlay: editable_overlay() (effective values as an overlay) + EDITABLE_FIELDS.
- operator_settings: optimistic-concurrency (expected_version) + settings_versions().

Secrets remain unreachable (overlays carry no secret fields). cargo all-targets clean,
lib 256/0. Next (3b): restart_apply_mode (manual|auto self-restart) + graceful shutdown.
2026-07-18 14:00:43 -04:00
jwinterm 4c83e53839 feat(config): effective config via ArcSwap + DB overlay at boot (Stage 2)
Wire the settings overlay into the live config path:
- AppState.config: Config -> Arc<ArcSwap<Config>> (hot-swappable effective config) +
  config_base: Arc<Config> (immutable env-only). New AppState::cfg() accessor; every
  config read goes through it (state.cfg().field), so a runtime-safe operator edit is
  seen on the next request. One Config type, no parallel path.
- Boot (main.rs): load_settings_overlay -> Config::apply_overlay(validate) -> build
  every client/worker/router from the EFFECTIVE config. A persisted overlay that no
  longer validates is ignored (boot env-only + loud log), never bricks. Env-only when
  ADMIN_KEY_INTEGRITY_SECRET is unset.
- Migrated ~59 state.config.X reads to state.cfg().X across api/*; bound guards where a
  reference outlives the statement; integrity_secret returns owned String.
- Test harness builds AppState with ArcSwap + config_base (arc-swap dev-dep).

Behavior-preserving: with no operator_settings rows, effective == env config.
cargo check --all-targets clean; lib tests 256/0.
2026-07-18 06:00:16 -04:00
jwinterm 00968a9c05 feat(config): operator settings overlay foundation (storage + validated merge)
Stage 1 of the operator-editable config layer (additive; no behavior change yet):
- migration operator_settings: one MAC(ADMIN_KEY_INTEGRITY_SECRET) JSONB row per
  section, FK to admin_keys, audited on write. Missing section = env-only.
- OperatorSettingRow model.
- SettingsOverlay + section overlays (landing/retention/restore) with secrets
  EXCLUDED BY CONSTRUCTION; Config::apply_overlay merges onto the env-derived Config
  then re-runs the SAME Config::validate boot uses (a bad edit yields the identical
  error, nothing persists, the instance cannot be bricked).
- runtime_class() single source of truth (RuntimeSafe hot-swap vs RestartRequired).
- operator_settings storage: canonical-JSON MAC (stable across JSONB key reorder),
  load_settings_overlay (fail-closed per section -> env fallback), put_setting_audited
  (upsert + admin_audit append in one tx).
- sqlx json feature.

cargo check clean; 4 new unit tests pass. Next stages: ArcSwap AppState + ~59
read-site migration, GET/PUT /admin/config (+ restart_apply_mode), remaining sections.
2026-07-18 04:33:20 -04:00
jwinterm ede7bec4a6 fix(electrum): tighter connect timeout + primary circuit breaker
The BTC/LTC balance path dials a configured primary (our Fulcrum) then randomized
public fallbacks. When the primary was unreachable, every request paid the full
10s CONNECT_TIMEOUT before falling back, so a down/overwhelmed primary meant a
~10s user-facing stall on every balance read.

- CONNECT_TIMEOUT 10s -> 4s (primary is a warm mesh node; fallbacks answer in ~1s)
- Add a per-primary circuit breaker: after 2 consecutive primary failures, skip
  the primary for a 30s cooldown and go straight to fallbacks, so a down primary
  costs a stall at most once per cooldown instead of on every request. Success
  clears it immediately. Only skips the primary when a fallback exists.

Protects the small such-nodes-one Fulcrum box: if it is overwhelmed or drops, the
backend fails over to public electrum in ~1s instead of stalling users. 22/22
electrum unit tests pass (incl. a new breaker state-machine test).
2026-07-15 05:39:50 -04:00
jwinterm 00be0d2088 docs(README): document the social-tips subsystem and grin voucher model 2026-07-11 12:21:03 -04:00
jwinterm c5c8dd5bf1 tips: grin config gate, reorg sweep_txid preservation, LWS import retry
- config: grin is a supported tip chain (voucher model verified against the node),
  so FEATURE_TIPS with only grin enabled no longer fails closed at boot.
- social_tips: revert_sweep_on_reorg now preserves sweep_txid instead of nulling
  it. Address-scan chains (btc/ltc/xmr/wow) overwrite it via confirm_sweep_onchain
  on the next cycle, so preserving the orphaned value is harmless; grin needs the
  kernel excess to re-date a re-mined sweep via get_kernel, so nulling it stranded
  reorged grin tips in 'claiming'. Test updated to assert preservation.
- lws import_account: retry the birthday backfill rescan (3 attempts, backoff) so
  a transient failure no longer strands a new account at the chain tip. The
  already-registered Some(_) no-op is unchanged, so this cannot reintroduce the
  balance-reset bug.
2026-07-11 12:21:03 -04:00
jwinterm 533f945290 feat(tips): serve /tips/social/received + /claimable as empty (public-only)
Public-only instances have no targeted-recipient inbox (public tips are claimed
via share URL, never delivered to a user), so the client's inbox poll of
/received + /claimable 404'd. Serve them 200-empty instead so the poll succeeds
quietly. Matches the client contract ({tips:[]}).
2026-07-10 22:35:44 -04:00
jwinterm 1d6d67faa5 feat(tips): grin public tips (voucher model via grin node get_outputs + get_kernel)
Grin as a new asset arm in the tips machinery (no grin-lws change, no custodial
grin_transactions/grin_outputs port):
- GrinClient::get_outputs([commit]) (funding: present+height=confirmed) +
  get_kernel(excess) (sweep spend-height); reached via state.chains.grin.
- create-gating (supported_tip_asset grin, validate 66-hex commitment,
  confirmations_for_asset grin=10), grin_commitment column (migration).
- confirmation worker grin arm: get_outputs present -> confs; amount AUTO-VERIFY
  with warn (voucher hides amount; not a theft vector; matches legacy).
- sweep reconciler grin arm: get_outputs-absent + get_kernel height at depth 10 ->
  settle; absence-without-kernel -> no settle (recoverable, never missettle);
  reorg revert only on commitment reappearing; probe error never settles/reverts.
- 5 grin tests + pure-helper unit tests; 251 lib + 24 tips green.

Amount-verification note: the voucher's bulletproof rewind nonce is random +
untransmitted, so it can't be rewound today; a clean non-custodial fast-follow is
to have the client send the per-voucher rewind nonce and verify via rangeproof
rewind (nonce reveals amount only, not spend authority).
2026-07-10 18:40:56 -04:00
jwinterm 5d8d5e193f feat(tips): stage 5 — GC workers + fail-closed flag + capabilities + openapi
- GC: cancel_old_drafts (draft >7d -> cancelled), cancel_stuck_pending_confirmation
  (>7d), log_stuck_claiming (read-only, >15min). Draft GC hourly + lifecycle GC
  5min, spawned behind FEATURE_TIPS. Funded-but-unattached safety net is the
  7-day window + clawback incl 'cancelled'.
- config validate(): tips enabled fails closed unless a supported chain
  (btc/ltc/xmr/wow) is on AND tip_share_base is set.
- capabilities: advertise tips=true only when actually serveable.
- regenerated openapi.json (8 /tips/social/* endpoints).

Public-tips port COMPLETE (stages 1-5). Full suite green.
2026-07-10 15:51:20 -04:00
jwinterm b7e3510553 feat(tips): stage 4 — claim / clawback / confirm-sweep + sweep reconciler (money-out)
Escrow settlement, audited guards ported verbatim:
- mark_tip_claiming: status IN('pending','claiming') AND confs>=required AND
  funding_amount_verified AND sweep_confirmed_at IS NULL (double-claim guard).
- confirm_tip_sweep: RECORD-not-settle, first-recorder-wins (COALESCE +
  sweep_txid IS NULL OR =$3) — RBF-steal safe; reconciler owns claiming->claimed.
- confirm_sweep_onchain: SOLE writer of 'claimed', one-way status='claiming' AND
  sweep_confirmed_at IS NULL lock; settles only after on-chain sweep confirms to
  per-asset depth (btc/ltc 1, wow 4, xmr 10).
- clawback: 5-status set incl 'cancelled', blocked once sweep_confirmed_at set.
- revert_sweep_on_reorg: only from 'claimed', re-opens to 'claiming' + clears txid.
- sweep_reconciler: address-history-driven (not recorded txid), pick_winner
  (earliest height, lexi-smallest tie), pg advisory locks on a pinned conn for
  single-instance safety; reorg pass reverts only on affirmative vanish, never on
  probe error. DMs stubbed, exactly-once stamps kept.
- POST claim/confirm-sweep/clawback endpoints + openapi.
- 16 integration + 12 reconciler unit tests; build clean; SocialTipRow dead_code
  allow removed (all fields now read).
2026-07-10 15:34:14 -04:00
jwinterm b63cac19e2 feat(tips): stage 3 — attach-funding + funding-verification worker (money-in)
- attach_funding_to_tip: atomic guarded UPDATE (COALESCE + CASE), idempotent on
  same txid, 400 on a different txid; non-drafts land pending_confirmation so the
  verifier (not the caller) owns the ->pending transition.
- worker (src/tips/confirmation.rs): XMR/WOW confirmation counting via LWS;
  amount verification for BTC/LTC (Electrum full history, confirmed-only) +
  XMR/WOW (classify_funding_amount, separate-then-subtract net). Verified->pending,
  Short->funding_mismatch; Unfunded/Drained/LwsError never mutate status (outage-
  safe). Spawned behind FEATURE_TIPS on a 60s loop w/ catch_unwind guard.
- POST /tips/social/:id/attach-funding endpoint + openapi.
- 5 DB tests + 6 classify unit tests; build clean.
2026-07-10 14:53:46 -04:00
jwinterm f3d6be95b8 feat(tips): stage 2 — create / get-public / get-sent / cancel routes
Public-tip create/read/cancel surface matching the client contract:
- POST /tips/social (draft or funded), GET /tips/social/sent,
  GET /tips/social/:id/public (unauth; 404s non-public), POST .../:id/cancel.
- DB: insert (draft/pending_confirmation), get_sent, guarded cancel_draft
  (owner + draft-only).
- Validates is_public (targeted rejected), amount>0, claim_key_hash 64-hex,
  encrypted_key <=4096 hex, tip_address; share_url from TIP_SHARE_BASE_URL.
- ensure_tips_enabled gate; openapi paths+schemas+tag registered.
No funds move. 5 route/DB tests pass.
2026-07-10 08:50:28 -04:00
jwinterm 6f34cb9245 feat(tips): stage 1 — social_tips schema + TipStatus enum + row model
Public-only social-tips foundation ported from legacy smirk-backend:
- migration 20260710000001_social_tips.sql: consolidated public-tip table
  (status CHECK + public_tip_has_hash + worker indexes), targeted/recipient
  columns and the socials/bot surface dropped.
- models::tip_status: TipStatus single-source-of-truth enum (8 variants,
  as_str/from_db/is_terminal) + confirmations_for_asset.
- infra::db::social_tips: SocialTipRow (FromRow) + TIP_COLS + get_social_tip.

Inert (no routes yet); FEATURE_TIPS stays off. Verified: migration applies,
5 TipStatus unit tests pass.
2026-07-10 08:17:06 -04:00
jwinterm 81148945c0 fix(lws): make import_account idempotent for an existing account
Re-registering an XMR/WOW account carrying the fixed wallet birthday triggered a
backwards rescan (`if start_height < current { rescan }`), resetting the scan
cursor to the birthday. Because the wallet re-registers before every balance
read, an old-birthday account was reset on each poll: it read a 0 balance, slowly
re-backfilled, then got reset again and never caught up (users saw 0 XMR/WOW
despite real funds).

Only a NEWLY added account needs the backwards rescan (monero-lws add_account
starts at the chain tip). An account that already exists has already been
imported from its birthday, so re-registration is now a no-op here. Genuine
re-restore to an earlier birthday uses the explicit admin rescan path.
2026-07-10 07:29:57 -04:00
jwinterm 4d1d9006bc feat(grin): proxy scans through grin-lws (default) with grin-wallet fallback
Add a GrinLwsClient (a faithful mirror of the XMR LwsClient) + GrinLwsConfig
(enabled when GRIN_LWS_URL is set). /wallet/grin/scan now defaults to grin-lws
when configured and returns derivation paths (key_id/n_child) + spendability,
letting the wallet skip its client-side output-identify search.

Money-safety: the restore-depth/PoW gate runs once and is terminal (never
masked by fallback); grin-lws is trusted only when provably synced
(scanned_height + margin >= tip), otherwise the scan falls back to the
authoritative grin-wallet scan_rewind_hash; a grin-lws transport error also
falls back; grin-wallet errors propagate as 503. No path ever synthesizes an
empty/zero balance.

Off unless GRIN_LWS_URL is configured (prod grin behavior unchanged until
pointed at a grin-lws instance). Capabilities advertise `grin_lws`.
openapi.json regenerated.
2026-07-09 16:26:23 -04:00
jwinterm a7c4bbd238 feat(grin): address→npub bridge + mark relay same-instance-only
Enhance the grin address→user lookup to return the owner's linked npub (not just a
bool), so a bare grin slatepack address can resolve to its owner's npub and route
over the federated Nostr gift-wrap rail instead of only manual handoff. Doc-comment
the backend slatepack relay as a same-instance-only convenience — the federated
default transport is Nostr gift-wrap; the relay needs a recipient user_id and both
parties on this instance, so it is not the primary path in a public-backend world.
2026-07-09 04:47:13 -04:00
jwinterm 57ebb2a5aa fix(electrum): accept self-signed certs by default so ssl:// fallbacks work
The electrum client did strict webpki TLS verification (CA chain + hostname). But
public electrum servers are self-signed by ecosystem convention — e.g. emzy.de
presents a cert webpki rejects with `UnsupportedCertVersion`. So the ssl:// fallback
layer was non-functional: whenever the trusted primary Fulcrum hiccuped (the
recurring nebula-firewall issue), every fallback failed the TLS handshake and
BTC/LTC returned 503 NODE_UNAVAILABLE (observed live on api.smirk.cash).

TLS here is transport encryption, not the trust boundary — the client already
treats every server byte as hostile (size bounds, id correlation, checked amount
conversion, address validation), so CA-authenticating an untrusted PUBLIC fallback
buys nothing while breaking it. Default to accepting any server cert; gate strict
webpki behind ELECTRUM_STRICT_TLS=1 for operators who run a CA-cert'd Fulcrum.

Verified (network-gated test): the lenient connector completes get_tip_height
against blockstream (CA-signed) AND emzy.de (self-signed) where strict fails.
cargo check + electrum/config unit tests green.
2026-07-08 13:59:49 -04:00
jwinterm 2f610aecb2 test(harness): expose response headers + config override; verify per-route CORS
The integration harness dropped response headers (returned only status + JSON body),
which is exactly why CORS/security-header behavior couldn't be tested. Add:
  - request_full(method, uri, extra_headers, body) -> (status, HeaderMap, body):
    sends arbitrary request headers (e.g. an Origin) and returns response headers.
  - try_app_with(|cfg| ...): tweak the validated Config before building the router
    (e.g. restrict cors_allowed_origins). try_app now delegates to it.

New tests/cors.rs proves the well-known fix end-to-end: with the API CORS restricted
to one origin, /.well-known/nostr.json still serves ACAO: * to a foreign origin
(federation works) while /api/v1/* does NOT — the two policies are genuinely
distinct. Verified against Postgres (passes); existing auth_flow/capabilities unaffected.
2026-07-08 09:23:17 -04:00
jwinterm fea90e3bf1 fix(nip05): always serve the well-known directory with CORS * (federation)
The NIP-05 directory (/.well-known/nostr.json) is public, read-only, unauth'd data
that MUST be cross-origin fetchable — any Nostr client on any domain resolves
`user@this-instance` by fetching it. It was covered by the single global CORS layer,
so it only got `Access-Control-Allow-Origin: *` when CORS_ALLOWED_ORIGINS was empty
(the default). An operator who restricts CORS to their own frontend would silently
break NIP-05 resolution for every other domain/wallet — federation dies for that
instance with no error.

Split CORS into two route groups: the public surface (well-known + health + landing)
always serves `*`; the /api/v1 surface keeps the operator's configurable policy. The
API's CORS behavior is unchanged (it still gets the same `cors` layer); only the
public directory is now unconditionally cross-origin. cargo check clean.
2026-07-08 08:58:50 -04:00
jwinterm 2f248c2c3d feat(relay): operator write-allowlist (owner/announcements exemption)
RELAY_WRITE_ALLOWLIST_NPUBS (npub1… or hex, comma-separated): listed authors may
publish ANY kind regardless of write policy or premium status. The use case is a
feed-owner / announcements account that seeds a premium-post feed without holding
a subscription — the admission engine previously had no owner/allowlist bypass
(FEED_OWNER_NPUB is read-side only).

- policy.rs: decide() gains author_allowlisted, checked first (permit-any).
- mod.rs: RelayProvider::is_write_allowlisted; entries decoded npub→hex once at
  boot (bech32), malformed entries logged + skipped. npub-decode KAT.
- nauthz.rs: resolves the flag (in-memory set, no DB) and short-circuits the
  registration/premium/recipient lookups when exempt.
- .env.example: document the var + the premium-post policy.

Relay tests 21/21.
2026-07-07 04:56:55 -04:00
jwinterm 00c0f9302e feat(prices): Kraken + nonlogs provider with User-Agent (v0.2.x parity)
CoinGecko (the only v3 provider) 403s requests with no User-Agent — reqwest
sends none — so the feed never populated. Port the working v0.2.x approach:

- Add a browser User-Agent to the price HTTP client.
- Add a "kraken" provider: Kraken for the majors (BTC/LTC/XMR, USD, one
  multi-pair call) + nonlogs.io for WOW/GRIN (Kraken doesn't list them; priced
  via each coin's -BTC ×BTC/USD and -USDT quotes, averaged).
- Config validation accepts PRICES_PROVIDER=kraken.

Deployed to api.smirk.cash with PRICES_PROVIDER=kraken.
2026-07-06 17:08:48 -04:00
jwinterm b32b8a9059 security(auth): make gated registration fully atomic (F3/F4 fast-follow)
The prior fixes closed the common cases with pre-checks; this closes the
residual races by consuming the single-use gate token(s) AND creating the
user AND persisting keys in ONE transaction, so a failure never burns a
token without an account.

- New Database::create_user_consuming_gates: claims the invite + consumes
  the payment invoice + inserts the user + upserts keys inside a tx;
  rolls the whole thing back on a username collision (mapped to 409) or a
  raced/already-spent token.
- New plan_gate_consume: resolves which tokens to spend WITHOUT consuming
  (the payment settled-check stays non-consuming), so `all`-mode verifies
  the payment is settled before anything is spent, then both tokens are
  consumed atomically at creation.
- extension_register + nostr_register route NEW users through the atomic
  path (returning users keep the ordinary backfill path); the npub F2
  fail-closed check runs before the tx. Removed the now-dead
  enforce_method_gates / enforce_invite / enforce_payment.
- tests/registration_atomicity.rs proves the rollback: a username
  collision keeps the invite; an already-spent payment keeps the invite.

Validated against Postgres: full suite 286 passed / 0 failed.
2026-07-06 05:58:04 -04:00
jwinterm c802f416cd security(auth): harden registration/identity against verified review findings
Adversarial review of the auth/registration surface (findings tracked
privately). Backend half:

- F2: npub-native register no longer binds an npub onto an existing
  wallet row on a seed_fingerprint match alone (unauthenticated account
  takeover). get_or_create_user_by_nostr_pubkey fails closed (409);
  linking an npub goes through the authenticated /auth/nostr/link flow.
  Integration test rewritten to assert the refuse-and-409 contract.
- F3: `all`-mode gate verifies payment is settled (non-consuming) BEFORE
  consuming the single-use invite, so a still-confirming payment can't
  permanently burn the invite. verify_payment_settled split out.
- F4: username availability is pre-checked before any gate token is
  consumed; collisions return 409, not 500 — a taken username no longer
  burns a paid invoice / invite.
- F6: nostr_register now persists the submitted chain keys, so
  receive-by-username and restore self-verification work for npub wallets.
- F10: the pay-to-register not-yet-settled response carries a stable
  PAYMENT_PENDING code so polling clients don't parse a free-text string.

Validated against Postgres: full suite 283 passed / 0 failed.
2026-07-06 04:23:11 -04:00
jwinterm 1232a80cc8 feat(capabilities): advertise features.nostr_native_auth (npub register available)
The wallet gates its bootstrap on this: when true it registers via NIP-98
(no BTC signature); a legacy backend that omits the flag gets the BTC path.
Shares the NIP-98 infra requirement (PUBLIC_API_URL) with nostr_identity.
2026-07-05 08:04:32 -04:00
jwinterm c49872b62b feat(auth): npub-native registration (POST /auth/nostr/register) - Nostr-native identity
Make npub the identity anchor: a wallet can register from its seed-derived Nostr
key alone, no BTC signature. Breaks the link chicken-and-egg (nostr_login is
create-never; nostr_link needs a prior BTC-authed JWT). Additive - /auth/extension
(BTC) and the link flow keep working for legacy backends during the transition.

- get_or_create_user_by_nostr_pubkey (db twin of the BTC path): keys/inserts on
  nostr_pubkey (raw, never peppered), pubkey_hash NULL. THE load-bearing dedup:
  looks up by seed_fingerprint and MERGES the npub onto an existing (BTC-anchored
  or link-less) row rather than splitting one wallet into two identities; a
  different npub already on that row fails closed (409). backfill_optionals shared
  helper.
- POST /auth/nostr/register + GET /auth/nostr/register-challenge: verify a NIP-98
  signed-action over a subject-less single-use nonce (replay-safe), then re-run
  the abuse gates keyed on the npub (a create path must not bypass PoW/invite/
  payment); pay-to-register invoices still bind to the BTC key carried in keys[].
  Empty-body descriptor contract (mirrors /auth/nostr/link); binding the key list
  into the proof is a documented hardening follow-up.
- Housekeeping: get_user_count counts npub-only users; record_login_event labels
  the nostr path.

3 integration KATs (create-by-npub + idempotent, replay rejected, seed_fingerprint
MERGE dedup) pass against a real DB; 211 lib tests pass; openapi regenerated.
2026-07-05 08:02:47 -04:00
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