XMR/WOW atomic amounts (piconero/wownoshi) can exceed 2^53, which a
JavaScript number cannot hold exactly, so a large balance or output was
silently rounded when the wallet JSON.parsed it. Serialize every atomic
amount (balance total_received/locked/pending, spent + unspent output
amounts, history totals) as a decimal string; heights, indices, counts,
and fee params stay numbers. utoipa value_type=String keeps the OpenAPI
honest. Tests assert the string wire contract for a 2^53+1 value.
- tools/secret-scan.sh now also flags nsec1 Nostr secret keys and
*_PEPPER/*_SALT/*INTEGRITY_SECRET hex assignments, the secret classes
this backend actually handles (previously only PEM/seed shapes).
- .gitignore: *.hex + admin-key* so an operator running inside the
checkout cannot commit the raw admin key (create-admin-wallet --out).
- PRIVACY.md pointing to the canonical smirk.cash/privacy, framed for
self-hosting operators as independent data controllers.
- New docs/operations/CONSOLE.md: reaching /admin, NIP-98 login, the
defaults->env->DB precedence model, per-section runtime-safe vs
restart-required table, expected_versions concurrency, restart_apply_mode
(with the systemd Restart=always requirement for auto), clearing a DB
override. Linked from README + OPERATOR_SETUP.
- All six example recipes gained an explicit chain-enablement block
(FEATURE_XMR/WOW on, BTC/LTC/GRIN off) so they no longer hard-fail
Config::validate in production (default-on chains with empty sources).
- Documented all 16 previously-undocumented env vars in .env.example
(RESTART_APPLY_MODE, TOR_ADMIN_ONION, DEPLOYMENT_MODE, TIP_SHARE_BASE_URL,
...); RELAY.md gained the premium-post policy + RELAY_WRITE_ALLOWLIST_NPUBS;
ADMIN_PUBKEYS noted as reserved/inert.
- Keys tab crashed on render: api.keys() expected an array but the
backend returns {keys:[...]}; unwrap it (KeysTab maps over the array).
- Config save now sends only CHANGED fields, not the whole section, so
the backend stops spuriously flagging restarts and pinning env-sourced
values into the DB overlay.
- Config edit state survives a reload (saving one section no longer
discards unsaved edits in others); numeric inputs ignore empty/NaN
instead of silently coercing to 0.
- Mint and Revoke now surface errors instead of swallowing them.
Rebuilt admin-ui/dist.
A runtime-safe config PUT rebuilt the effective config from config_base +
the FULL persisted overlay and stored it, which live-applied any
restart-required section persisted by an earlier PUT (features/relay/
premium) while the boot-built clients still ran the old value — and
cleared restart_pending in the process. The exact desync RuntimeClass
exists to prevent.
Now the hot-swap applies ONLY the (all-runtime-safe) patch onto the
currently-running config, and restart_pending is computed honestly via a
shared helper (GET and PUT agree). Also reclassify registration.
require_payment as restart-required: it gates the boot-built payment
client (None unless require_payment||premium at boot), so hot-enabling it
would 500 every registration until restart. Regression tests for both.
The repo pins channel=stable (no rustfmt version), so CI now pulls
rustfmt 1.9.0, which reformats code last touched by an older rustfmt.
main itself failed `cargo fmt --check` under the current toolchain;
this is a formatting-only sweep across 24 files to make the fmt gate
green. No behavior change; tests + clippy -D warnings still pass.
Extends the operator settings overlay with five sections, all non-secret
by construction (api_key, ALTCHA hmac_key, peppers, and processor wiring
stay in env):
- registration: require_invite, gate_mode, require_payment + payment
amount/currency/confirmations/expires (runtime-safe: read per-request)
- pow: enabled, required, cost (runtime-safe)
- features: prices + provider/interval/currency, tips, nostr_identity,
grin_relay, and per-chain enablement (restart-required: boot workers)
- relay: enabled, mode, advertised_url, write_policy, inbound_pow_bits,
max_event_bytes, retention (restart-required; bind + allowlist excluded)
- premium: enabled, currency (restart-required; priced plans stay in env)
Every PUT re-runs Config::validate, so enabling a gate with incomplete
wiring (e.g. pow without a key, pay-to-register without a processor) is
rejected at save, never booted disarmed. Tests: identity round-trip
(proves read-field == write-field for every entry), a hard assert that
no secret can appear in the editable overlay, gate_mode typo rejection,
and pow-without-key fail-closed.
- admin.rs: make the config/invite JSON response structs pub (they are
returned from pub handlers; matches the already-pub AdminKeyView etc.)
- invite_codes/operator_settings: drop redundant reborrow (&mut *tx -> &mut tx)
- electrum: minimize a boolean (De Morgan, behavior-identical)
- relay: move the test module to end of file (items_after_test_module)
Pure lint hygiene surfaced by the current stable toolchain; no behavior
change. cargo clippy --all-targets -- -D warnings is now clean.
New console-drift job rebuilds admin-ui from the lockfile (npm ci) and
fails if the committed admin-ui/dist drifts. rust-embed bakes dist into
the binary, so a stale dist ships an old console; this mirrors the
openapi-drift gate. Pinned to Node 22 (the version that built the
committed bundle) so the byte-diff is deterministic.
The SPA issued GET /admin/auth/challenge but the route is POST (nonce
issuance is a write). Left as-is the login flow 405'd at step one. Fixed
the client verb and rebuilt dist.
Route-level test (tower oneshot, no DB) confirms GET /admin returns the
console HTML with text/html, and GET /admin/assets/<hash>.js returns the
built bundle with a JS content-type. Pairs with the embed test so both
'not built' and 'not wired' regressions fail CI.
Preact + Vite standalone admin-ui bundled into the server binary via
rust-embed. Login is NIP-07 (Smirk wallet signs the NIP-98 admin
challenge); tabs cover instance status, admin keys, invite codes, and
the effective-config editor (live vs restart-required per field).
admin-ui/dist is committed because rust-embed reads it at compile time;
node_modules is gitignored (npm ci reproduces it). A lib test asserts
index.html and the /admin/ asset base are embedded so an unbuilt
console fails the build instead of an operator.
The console is served on the admin plane only (loopback bind), behind
the existing NIP-98 admin_guard + MAC allowlist.
- console section: restart_apply_mode (manual | auto), an operator setting (runtime-safe,
DB-overridable via /admin/config), env RESTART_APPLY_MODE default manual.
- Graceful shutdown: main serve now awaits SIGTERM/Ctrl-C OR an in-app shutdown signal
(AppState.shutdown Notify) via with_graceful_shutdown, draining in-flight requests.
- PUT /admin/config in `auto` mode self-restarts shortly after responding to apply a
restart-required change (systemd Restart=always brings it back with the new config);
`manual` just flags restart_pending. The console warns before an auto save.
Stage 3 complete: operator config is readable + editable through the real validated,
audited, precedence-based layer; runtime-safe edits apply live, infra edits restart.
cargo all-targets clean, lib tests pass.
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.
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.
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.
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).
- 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.
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:[]}).
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).
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).
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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).
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.
`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.
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).