diff --git a/.env.example b/.env.example index a83dcb4..9a4d80f 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,19 @@ SERVER_PORT=8080 # so every real deployment must replace it. PUBLIC_API_URL=https://backend.example.org/api/v1 ENVIRONMENT=development +# Deployment topology: single (default) permits in-process challenge/nonce state; +# fleet means a load-balanced set of instances and requires shared/stateless +# challenge state. Must be single|fleet or boot fails. +DEPLOYMENT_MODE=single +# Comma-separated CIDRs or bare IPs whose X-Forwarded-For is trusted for the +# client IP (rate-limiting + audit). Empty (default) = always use the real TCP +# peer. Set this to your reverse proxy's address(es) when it terminates TLS. +TRUSTED_PROXIES= +# Comma-separated browser origins allowed by CORS (e.g. the web wallet). Empty +# (default) allows any origin — safe here because auth is a Bearer token, not a +# cookie, so no ambient credentials ride a cross-origin request. Credentials are +# never allowed. +CORS_ALLOWED_ORIGINS= # ── Database ────────────────────────────────────────────────────────────── DATABASE_URL=postgres://user:password@127.0.0.1:5432/smirk @@ -36,6 +49,10 @@ FEATURE_POW=false ALTCHA_HMAC_KEY= POW_REQUIRED=false ALTCHA_COST=100000 +# Testing aid: comma-separated lowercase pubkey hashes that ALWAYS require PoW, +# even when POW_REQUIRED=false (opt-in, for exercising the gate). Requires +# FEATURE_POW=true or boot fails closed. Leave empty in normal deployments. +TEST_POW_REQUIRED_FOR_PUBKEYS= # ── Registration gates (composable; each advertised via /capabilities) ──── # Require an operator-minted invite code to create a NEW wallet. Mint codes with @@ -74,10 +91,32 @@ PAYMENT_EXPIRES_MINUTES=60 # enabled the two modes are identical. REGISTRATION_GATE_MODE=all +# ── Chain enablement (each chain is independently feature-flagged) ───────── +# All default to TRUE. FAIL-CLOSED RULE: in production, an ENABLED chain with no +# source configured makes the server REFUSE TO BOOT — a chain that cannot be +# served must not advertise itself as available via /capabilities. So set the +# ones you do not run to false, or configure the source for each you keep on: +# BTC / LTC — an electrum/Fulcrum URL (BTC_ELECTRUM_URL / LTC_ELECTRUM_URL) or +# a fallback list. +# XMR / WOW — the *_LWS_ADMIN_KEY (plus its LWS + daemon URLs). +# GRIN — GRIN_OWNER_API_SECRET (plus the GRIN_* wallet/node wiring). +# In development these are warnings rather than a hard boot error. +FEATURE_BTC=true +FEATURE_LTC=true +FEATURE_XMR=true +FEATURE_WOW=true +FEATURE_GRIN=true + # ── Bitcoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ───── BTC_NETWORK=mainnet BTC_ELECTRUM_URL= BTC_ELECTRUM_FALLBACKS= +# Require CA-verified TLS (webpki + hostname) for ssl:// electrum servers. Applies +# to BOTH BTC and LTC. Default false: public electrum servers are self-signed by +# convention and their data is treated as hostile regardless, so accepting any +# cert keeps the public fallbacks usable. Set true if you run your own CA-cert'd +# Fulcrum and want to harden. +ELECTRUM_STRICT_TLS=false # ── Litecoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ──── LTC_NETWORK=mainnet @@ -142,8 +181,26 @@ WALLET_RESTORE_POW_MAX_BITS=24 FEATURE_PRICES=true PRICES_PROVIDER=coingecko PRICES_FETCH_INTERVAL_SECS=300 +# Which assets the price feed quotes. UNSET = all supported (btc,ltc,xmr,wow,grin); +# a subset like "btc,xmr" narrows it; PRESENT-BUT-EMPTY (PRICES_ASSETS= with no +# value) means none. Leave commented to serve all. +#PRICES_ASSETS= +# Currency the feed quotes in. One of: usd, eur, gbp, jpy, cny, aud, cad, chf, btc. +# A typo fails closed at boot (rather than serving an empty feed). +PRICES_CURRENCY=usd +# Nostr-native identity (NIP-98 login/link, NIP-05 directory). ON by default; when +# on, PUBLIC_API_URL is required (and must be https in production). +FEATURE_NOSTR_IDENTITY=true +# Grin slatepack relay: a non-custodial encrypted store-and-forward mailbox for +# interactive Grin transfers. ON by default; can be disabled independently of Grin +# chain access. +FEATURE_GRIN_RELAY=true # Tips are parked for the conditional-transfer redesign; off until that lands. FEATURE_TIPS=false +# Base URL for public-tip share links (a tip's share URL is {base}/{tip_id}). +# REQUIRED when FEATURE_TIPS=true (boot fails closed without it); never hardcode a +# host, so federated/self-hosted instances build their own share links. +TIP_SHARE_BASE_URL= # ── Nostr relay (optional messaging plane; see docs/operations/RELAY.md) ──── # Off by default. When on, the operator runs a relay (the packaged @@ -205,11 +262,21 @@ FEED_OWNER_NPUB= FEED_ALLOWLIST_NPUBS= FEED_EXTRA_RELAYS= -# ── Admin surface (see docs; default posture is loopback/Tor) ────────────── +# ── Admin surface (see docs/operations/CONSOLE.md; default posture is loopback/Tor) ─ # Admin auth is "Sign in with Smirk": only admin PUBLIC keys are stored here, # never a seed or private key. The allowlist is mutated CLI/loopback-only in v1. +# When enabled, the embedded Operator Console SPA is served at /admin on this plane. ADMIN_ENABLED=false ADMIN_BIND=127.0.0.1:8081 +# Allow ADMIN_BIND to be a NON-loopback address. Default false: boot REFUSES a +# public admin bind (confidentiality is by socket). Only set true if you front the +# plane with Tor client-auth or a trusted proxy and firewall it. +ADMIN_ALLOW_PUBLIC_BIND=false +# Tor onion host you reach the admin plane at (e.g. .onion). REQUIRED for the +# Tor-onion posture: it is added to the admin Host allowlist, so without it a +# request whose Host header is the onion is rejected 403 (anti DNS-rebinding). +# Never logged or surfaced publicly. Leave empty for the loopback/SSH-tunnel path. +TOR_ADMIN_ONION= # Public absolute URL of THIS node's admin surface. The per-node id that binds # signed admin actions (so a challenge signed for one node can't be replayed to # another across a fleet) is DERIVED from this value — give each node in a fleet a @@ -221,6 +288,17 @@ ADMIN_JWT_SECRET= ADMIN_KEY_INTEGRITY_SECRET= ADMIN_MAX_KEYS=8 ADMIN_PENDING_KEY_TTL_DAYS=7 +# RESERVED / inert: ADMIN_PUBKEYS is parsed but NOT consumed anywhere in this +# version — it does not gate admin access. Authorization is the MAC-protected key +# allowlist in the database (managed via `smirk-admin` and the console Keys tab), +# never this variable. Leave it unset; setting it has no effect. +ADMIN_PUBKEYS= +# How the Operator Console applies a RESTART-REQUIRED config change (see CONSOLE.md): +# manual (default) — persist + flag; you apply it with a graceful restart. +# auto — the instance self-restarts after saving. REQUIRES a +# supervisor that brings it back (systemd Restart=always); +# otherwise it goes down and stays down. +RESTART_APPLY_MODE=manual # ── Public landing page (OFF by default; full + per-field tunable when on) ── PUBLIC_LANDING_ENABLED=false @@ -234,9 +312,15 @@ PUBLIC_STATS_ENABLED=false PUBLIC_STATS_CACHE_HOURS=24 # ── Data retention / erasure ────────────────────────────────────────────── +# When ERASURE_ENABLED=true, self-service erasure needs ADMIN_KEY_INTEGRITY_SECRET +# (its audit trail is the integrity-MAC'd hash chain) and PUBLIC_API_URL (its +# proofs bind that URL); boot fails closed otherwise. ERASURE_ENABLED=false ERASURE_PURGE_LOGIN_EVENTS=true ERASURE_EXPORT_PER_DAY=3 +# Grace window (hours) between a confirmed erasure and its execution. Max 87600 +# (10 years). +ERASURE_GRACE_PERIOD_HOURS=72 # Log retention (auto-deletion); 0 = keep indefinitely. RETENTION_LOGIN_EVENTS_DAYS=90 RETENTION_AUDIT_DAYS=365 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29b8a57..5cadc26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,26 @@ jobs: cargo run --quiet --bin gen_openapi > /tmp/openapi.json diff openapi.json /tmp/openapi.json + console-drift: + name: operator console drift gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + # Rebuild the admin-ui bundle from source (npm ci = reproducible from the + # lockfile) and fail if the committed admin-ui/dist drifts. rust-embed bakes + # dist into the server binary, so a stale dist would silently ship an old + # console. Same guarantee the openapi-drift job gives the API client. + - name: Rebuild admin-ui; fail if dist drifts from committed + working-directory: admin-ui + run: | + npm ci + npm run build + - name: git diff (dist must be committed and current) + run: git diff --exit-code -- admin-ui/dist + rust: name: fmt / clippy / build / test / doc-test runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6b6cafe..0078946 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ !.env.example *.key *.pem +# Bare-hex secret files (e.g. `smirk-admin create-admin-wallet --out admin-key.hex`) +# so an operator running inside the checkout cannot commit the raw admin key. +*.hex +admin-key* *.p12 secrets/ secret_store/ diff --git a/Cargo.lock b/Cargo.lock index f183ae3..b460950 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -77,14 +77,14 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e7e126d41c3e0a6c85db7ba216bce5e41d53c156191c67d993613685fcc9410" dependencies = [ - "digest", + "digest 0.10.7", "hex", "hmac", "pbkdf2", "rand", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "subtle", "thiserror 2.0.18", ] @@ -110,6 +110,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -346,6 +355,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bs58" version = "0.5.1" @@ -409,7 +427,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -436,6 +454,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -471,6 +495,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -539,6 +572,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -555,9 +597,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -600,7 +642,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -617,12 +659,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -653,7 +706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -679,7 +732,7 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -701,7 +754,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -1193,7 +1246,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1272,6 +1325,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -1618,7 +1680,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "sha2", + "sha2 0.10.9", "signature", ] @@ -1715,7 +1777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1730,6 +1792,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2029,7 +2101,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", ] @@ -2134,7 +2206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2571,8 +2643,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -2585,6 +2657,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.118", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rustc-hex" version = "2.1.0" @@ -2690,6 +2797,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2843,8 +2959,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2854,8 +2970,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2889,7 +3016,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core", ] @@ -2929,6 +3056,7 @@ version = "0.3.0" dependencies = [ "aes-gcm", "altcha", + "arc-swap", "async-trait", "axum", "axum-extra", @@ -2952,11 +3080,12 @@ dependencies = [ "rand", "reqwest", "rmp-serde", + "rust-embed", "rustls 0.22.4", "rustls-pki-types", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx", "subtle", "tokio", @@ -3083,7 +3212,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlformat", "thiserror 1.0.69", @@ -3123,7 +3252,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -3147,7 +3276,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -3168,7 +3297,7 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -3209,7 +3338,7 @@ dependencies = [ "rand", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -3812,6 +3941,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3857,7 +3992,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -3993,6 +4128,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4151,6 +4296,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 7643558..b0bef83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,9 @@ tower_governor = "0.4" governor = "0.6" # Database -sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork", "migrate"] } +rust-embed = { version = "8", features = ["debug-embed"] } +arc-swap = "1" +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork", "migrate", "json"] } ipnetwork = "0.20" # Serialization @@ -119,6 +121,7 @@ tonic-build = "0.12" protoc-bin-vendored = "3" [dev-dependencies] +arc-swap = "1" tokio-test = "0.4" mockall = "0.12" diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..2f969f3 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,28 @@ +# Privacy Policy + +The canonical Smirk privacy policy lives at **https://smirk.cash/privacy** and +describes the data practices of the instance operated by Such Software LLC +(api.smirk.cash). + +## For operators who self-host + +This backend is open source and self-hostable. **If you run your own instance, you +are an independent data controller for it** and the Such Software policy does not +speak for you. What your instance receives from wallets that connect to it: + +- **Monero/Wownero view keys** (to scan for users' incoming funds; a view key + cannot spend) and their scan/registration state. +- **Public addresses** for BTC/LTC balance lookups and **signed transactions** to + broadcast. Recipient address and amount are not sent on broadcast. +- A one-way **seed fingerprint** (SHA-256) as a non-reversible wallet identifier. +- If enabled: the **username to npub** mapping published in your NIP-05 directory, + and, for the relay, encrypted message envelopes (ciphertext only). +- IP addresses only as a **salted one-way hash**, for rate-limiting. + +Private keys and seed phrases are never transmitted to the backend. + +If you offer your instance to others, publish your own privacy policy describing +how you handle this data, your retention, and your jurisdiction. The knobs that +affect retention and data exposure (login-event and audit retention, erasure, +relay retention, landing-page exposure) are documented in +[docs/operations/CONSOLE.md](docs/operations/CONSOLE.md) and the root `.env.example`. diff --git a/README.md b/README.md index f6a9b62..7b49fc3 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ Operator/admin functions live on a **separate loopback listener** (front it with Tor or an SSH tunnel) — confidentiality by socket, never merged into the public router or OpenAPI. +- **Operator Console**: an embedded single-page app served at `/admin` on the + loopback admin plane. Sign in with your admin key, then review status, manage + admin keys and invite codes, and edit the runtime config overlay (env plus a + validated DB layer). Guide: [docs/operations/CONSOLE.md](docs/operations/CONSOLE.md). - **Sign-in-with-Smirk admin auth** — a NIP-98 *signed action* over a single-use nonce (AUTHN) + a MAC-protected key allowlist (AUTHZ), composed so a route can't run without both. diff --git a/admin-ui/.gitignore b/admin-ui/.gitignore new file mode 100644 index 0000000..d830e9c --- /dev/null +++ b/admin-ui/.gitignore @@ -0,0 +1,6 @@ +# Node deps — reproducible from package-lock.json (`npm ci`). +node_modules/ + +# NOTE: dist/ is intentionally NOT ignored. rust-embed bakes admin-ui/dist/ into +# the server binary at compile time, so the built console is committed. Rebuild it +# with `npm run build` in this directory whenever src/ changes (CI checks freshness). diff --git a/admin-ui/dist/assets/index-7a1tzG-S.js b/admin-ui/dist/assets/index-7a1tzG-S.js new file mode 100644 index 0000000..96b8d5e --- /dev/null +++ b/admin-ui/dist/assets/index-7a1tzG-S.js @@ -0,0 +1,5 @@ +(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const _ of document.querySelectorAll('link[rel="modulepreload"]'))o(_);new MutationObserver(_=>{for(const r of _)if(r.type==="childList")for(const s of r.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&o(s)}).observe(document,{childList:!0,subtree:!0});function n(_){const r={};return _.integrity&&(r.integrity=_.integrity),_.referrerPolicy&&(r.referrerPolicy=_.referrerPolicy),_.crossOrigin==="use-credentials"?r.credentials="include":_.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function o(_){if(_.ep)return;_.ep=!0;const r=n(_);fetch(_.href,r)}})();var J,y,xe,P,ce,Se,Te,Y,D,M,Ce,ie,ee,te,R={},z=[],De=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,G=Array.isArray;function N(t,e){for(var n in e)t[n]=e[n];return t}function _e(t){t&&t.parentNode&&t.parentNode.removeChild(t)}function Fe(t,e,n){var o,_,r,s={};for(r in e)r=="key"?o=e[r]:r=="ref"?_=e[r]:s[r]=e[r];if(arguments.length>2&&(s.children=arguments.length>3?J.call(arguments,2):n),typeof t=="function"&&t.defaultProps!=null)for(r in t.defaultProps)s[r]===void 0&&(s[r]=t.defaultProps[r]);return F(t,s,o,_,null)}function F(t,e,n,o,_){var r={type:t,props:e,key:n,ref:o,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:_??++xe,__i:-1,__u:0};return _==null&&y.vnode!=null&&y.vnode(r),r}function V(t){return t.children}function j(t,e){this.props=t,this.context=e}function H(t,e){if(e==null)return t.__?H(t.__,t.__i+1):null;for(var n;ee&&P.sort(Te),t=P.shift(),e=P.length,je(t)}finally{P.length=q.__r=0}}function Ne(t,e,n,o,_,r,s,a,f,l,d){var p,i,u,h,w,v,k,m=o&&o.__k||z,S=e.length;for(f=We(n,e,m,f,S),p=0;p0?s=t.__k[r]=F(s.type,s.props,s.key,s.ref?s.ref:null,s.__v):t.__k[r]=s,f=r+i,s.__=t,s.__b=t.__b+1,a=null,(l=s.__i=Re(s,n,f,p))!=-1&&(p--,(a=n[l])&&(a.__u|=2)),a==null||a.__v==null?(l==-1&&(_>d?i--:_f?i--:i++,s.__u|=4))):t.__k[r]=null;if(p)for(r=0;r(d?1:0)){for(_=n-1,r=n+1;_>=0||r=0?_--:r++])!=null&&!(2&l.__u)&&a==l.key&&f==l.type)return s}return-1}function de(t,e,n){e[0]=="-"?t.setProperty(e,n??""):t[e]=n==null?"":typeof n!="number"||De.test(e)?n:n+"px"}function U(t,e,n,o,_){var r,s;e:if(e=="style")if(typeof n=="string")t.style.cssText=n;else{if(typeof o=="string"&&(t.style.cssText=o=""),o)for(e in o)n&&e in n||de(t.style,e,"");if(n)for(e in n)o&&n[e]==o[e]||de(t.style,e,n[e])}else if(e[0]=="o"&&e[1]=="n")r=e!=(e=e.replace(Ce,"$1")),s=e.toLowerCase(),e=s in t||e=="onFocusOut"||e=="onFocusIn"?s.slice(2):e.slice(2),t.l||(t.l={}),t.l[e+r]=n,n?o?n[M]=o[M]:(n[M]=ie,t.addEventListener(e,r?te:ee,r)):t.removeEventListener(e,r?te:ee,r);else{if(_=="http://www.w3.org/2000/svg")e=e.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(e!="width"&&e!="height"&&e!="href"&&e!="list"&&e!="form"&&e!="tabIndex"&&e!="download"&&e!="rowSpan"&&e!="colSpan"&&e!="role"&&e!="popover"&&e in t)try{t[e]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&e[4]!="-"?t.removeAttribute(e):t.setAttribute(e,e=="popover"&&n==1?"":n))}}function fe(t){return function(e){if(this.l){var n=this.l[e.type+t];if(e[D]==null)e[D]=ie++;else if(e[D]0?t:G(t)?t.map(Ae):t.constructor!==void 0?null:N({},t)}function ze(t,e,n,o,_,r,s,a,f){var l,d,p,i,u,h,w,v=n.props||R,k=e.props,m=e.type;if(m=="svg"?_="http://www.w3.org/2000/svg":m=="math"?_="http://www.w3.org/1998/Math/MathML":_||(_="http://www.w3.org/1999/xhtml"),r!=null){for(l=0;l=n.__.length&&n.__.push({}),n.__[t]}function x(t){return ne=1,Ge(Be,t)}function Ge(t,e,n){var o=Le(K++,2);if(o.t=t,!o.__c&&(o.__=[Be(void 0,e),function(a){var f=o.__N?o.__N[0]:o.__[0],l=o.t(f,a);f!==l&&(o.__N=[l,o.__[1]],o.__c.setState({}))}],o.__c=g,!g.__f)){var _=function(a,f,l){if(!o.__c.__H)return!0;var d=!1,p=o.__c.props!==a;if(o.__c.__H.__.some(function(u){if(u.__N){d=!0;var h=u.__[0];u.__=u.__N,u.__N=void 0,h!==u.__[0]&&(p=!0)}}),r){var i=r.call(this,a,f,l);return d?i||p:i}return!d||p};g.__f=!0;var r=g.shouldComponentUpdate,s=g.componentWillUpdate;g.componentWillUpdate=function(a,f,l){if(this.__e){var d=r;r=void 0,_(a,f,l),r=d}s&&s.call(this,a,f,l)},g.shouldComponentUpdate=_}return o.__N||o.__}function Me(t,e){var n=Le(K++,3);!b.__s&&Xe(n.__H,e)&&(n.__=t,n.u=e,g.__H.__h.push(n))}function Ve(){for(var t;t=Ie.shift();){var e=t.__H;if(t.__P&&e)try{e.__h.some(W),e.__h.some(re),e.__h=[]}catch(n){e.__h=[],b.__e(n,t.__v)}}}b.__b=function(t){g=null,he&&he(t)},b.__=function(t,e){t&&e.__k&&e.__k.__m&&(t.__m=e.__k.__m),be&&be(t,e)},b.__r=function(t){me&&me(t),K=0;var e=(g=t.__c).__H;e&&(Z===g?(e.__h=[],g.__h=[],e.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(e.__h.some(W),e.__h.some(re),e.__h=[],K=0)),Z=g},b.diffed=function(t){ye&&ye(t);var e=t.__c;e&&e.__H&&(e.__H.__h.length&&(Ie.push(e)!==1&&pe===b.requestAnimationFrame||((pe=b.requestAnimationFrame)||Qe)(Ve)),e.__H.__.some(function(n){n.u&&(n.__H=n.u,n.u=void 0)})),Z=g=null},b.__c=function(t,e){e.some(function(n){try{n.__h.some(W),n.__h=n.__h.filter(function(o){return!o.__||re(o)})}catch(o){e.some(function(_){_.__h&&(_.__h=[])}),e=[],b.__e(o,n.__v)}}),ge&&ge(t,e)},b.unmount=function(t){ve&&ve(t);var e,n=t.__c;n&&n.__H&&(n.__H.__.some(function(o){try{W(o)}catch(_){e=_}}),n.__H=void 0,e&&b.__e(e,n.__v))};var ke=typeof requestAnimationFrame=="function";function Qe(t){var e,n=function(){clearTimeout(o),ke&&cancelAnimationFrame(e),setTimeout(t)},o=setTimeout(n,35);ke&&(e=requestAnimationFrame(n))}function W(t){var e=g,n=t.__c;typeof n=="function"&&(t.__c=void 0,n()),g=e}function re(t){var e=g;t.__c=t.__(),g=e}function Xe(t,e){return!t||t.length!==e.length||e.some(function(n,o){return n!==t[o]})}function Be(t,e){return typeof e=="function"?e(t):e}let oe=null;function Ue(t){oe=t}async function T(t,e,n){const o={};oe&&(o.Authorization=`Bearer ${oe}`),n!==void 0&&(o["Content-Type"]="application/json");const _=await fetch(e,{method:t,headers:o,body:n!==void 0?JSON.stringify(n):void 0}),r=await _.text(),s=r?JSON.parse(r):null;if(!_.ok){const a=s?.error??s?.message??`${_.status} ${_.statusText}`;throw new Error(a)}return s}const C={challenge:()=>T("POST","/admin/auth/challenge",{}),verify:(t,e)=>T("POST","/admin/auth/verify",{admin_token:t,challenge:e}),logout:()=>T("POST","/admin/auth/logout",{}),me:()=>T("GET","/admin/me"),features:()=>T("GET","/admin/features"),keys:()=>T("GET","/admin/keys").then(t=>t.keys),addKey:(t,e)=>T("POST","/admin/keys",e?{pubkey:t,label:e}:{pubkey:t}),revokeKey:t=>T("DELETE",`/admin/keys/${t}`),config:()=>T("GET","/admin/config"),putConfig:(t,e)=>T("PUT","/admin/config",{patch:t,expected_versions:e}),invites:()=>T("GET","/admin/invites"),mintInvites:(t,e)=>T("POST","/admin/invites",e?{count:t,label:e}:{count:t})};async function we(t){const e=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(t));return[...new Uint8Array(e)].map(n=>n.toString(16).padStart(2,"0")).join("")}async function Ye(t,e){const o=`POST +/admin/auth/verify + +${await we("")}`,_=await we(o),r=await e.signEvent({kind:27235,created_at:Math.floor(Date.now()/1e3),content:"",tags:[["u",t.url],["method","POST"],["purpose","admin_login"],["challenge",t.challenge],["payload",_],["instance_id",t.instance_id]]});return`Nostr ${btoa(JSON.stringify(r))}`}function Ze(){const t=window.nostr;return t&&typeof t.signEvent=="function"?t:null}function et({onDone:t}){const[e,n]=x(!1),[o,_]=x(null);return c("div",{style:{maxWidth:420,margin:"12vh auto",padding:24,textAlign:"center"},children:[c("div",{style:{fontSize:40},children:"🐸"}),c("h1",{children:"Smirk Operator Console"}),c("p",{style:{color:"var(--smirk-fg-muted)"},children:"Sign in with an admin Nostr key on your instance's allowlist."}),c("button",{class:"primary",style:{padding:"12px 20px",fontSize:16},disabled:e,onClick:async()=>{n(!0),_(null);try{const s=Ze();if(!s)throw new Error("No NIP-07 signer found. Enable your Smirk wallet (or nos2x/Alby) on this origin, then reload.");const a=await C.challenge(),f=await Ye(a,s),{access_token:l}=await C.verify(f,a.challenge);Ue(l),t()}catch(s){_(s instanceof Error?s.message:"Sign-in failed")}finally{n(!1)}},children:e?"Signing in…":"Sign in with Smirk"}),o&&c("p",{style:{color:"var(--smirk-danger)",marginTop:16},children:o})]})}function A({title:t,children:e}){return c("div",{style:{background:"var(--smirk-bg-elevated)",border:"1px solid var(--smirk-border)",borderRadius:"var(--smirk-radius)",padding:16,marginBottom:16},children:[c("h3",{style:{marginTop:0},children:t}),e]})}function Q(t,e=[]){const[n,o]=x(null),[_,r]=x(null),s=()=>{r(null),t().then(o).catch(a=>r(a instanceof Error?a.message:String(a)))};return Me(s,e),{data:n,err:_,reload:s}}function tt(){const{data:t,err:e}=Q(()=>C.features());return c(A,{title:"Instance features",children:[e&&c("p",{style:{color:"var(--smirk-danger)"},children:e}),c("pre",{style:{overflow:"auto",fontSize:12},children:JSON.stringify(t,null,2)})]})}function nt(){const{data:t,err:e,reload:n}=Q(()=>C.keys()),[o,_]=x(""),[r,s]=x(!1),[a,f]=x(null),l=async()=>{s(!0),f(null);try{await C.addKey(o.trim().toLowerCase()),_(""),n()}catch(p){f(p instanceof Error?p.message:String(p))}finally{s(!1)}},d=async p=>{if(confirm("Revoke this admin key?")){f(null);try{await C.revokeKey(p),n()}catch(i){f(i instanceof Error?i.message:String(i))}}};return c(A,{title:"Admin keys",children:[e&&c("p",{style:{color:"var(--smirk-danger)"},children:e}),c("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:13},children:c("tbody",{children:(t??[]).map(p=>c("tr",{style:{borderTop:"1px solid var(--smirk-border)"},children:[c("td",{style:{fontFamily:"monospace",padding:"6px 4px"},children:[p.pubkey.slice(0,16),"…"]}),c("td",{style:{padding:"6px 4px"},children:p.status}),c("td",{style:{padding:"6px 4px",textAlign:"right"},children:p.status!=="revoked"&&c("button",{class:"danger",onClick:()=>d(p.id),children:"Revoke"})})]}))})}),c("div",{style:{display:"flex",gap:8,marginTop:12},children:[c("input",{style:{flex:1},placeholder:"new admin pubkey (64 hex)",value:o,onInput:p=>_(p.target.value)}),c("button",{class:"primary",disabled:r||o.trim().length!==64,onClick:l,children:"Add key"})]}),a&&c("p",{style:{color:"var(--smirk-danger)"},children:a})]})}function rt(){const{data:t,err:e,reload:n}=Q(()=>C.invites()),[o,_]=x(5),[r,s]=x(""),[a,f]=x(null),[l,d]=x(!1),[p,i]=x(null),u=async()=>{d(!0),i(null);try{const h=await C.mintInvites(o,r.trim()||void 0);f(h.codes),n()}catch(h){i(h instanceof Error?h.message:String(h))}finally{d(!1)}};return c(A,{title:`Invite codes${t?` — ${t.unused_count} unused`:""}`,children:[e&&c("p",{style:{color:"var(--smirk-danger)"},children:e}),c("div",{style:{display:"flex",gap:8,alignItems:"center",marginBottom:12},children:[c("input",{type:"number",min:1,max:1e3,style:{width:80},value:o,onInput:h=>_(Number(h.target.value))}),c("input",{style:{flex:1},placeholder:"label (optional)",value:r,onInput:h=>s(h.target.value)}),c("button",{class:"primary",disabled:l||o<1,onClick:u,children:"Mint"})]}),p&&c("p",{style:{color:"var(--smirk-danger)"},children:p}),a&&c("div",{style:{background:"var(--smirk-bg-sunken)",padding:12,borderRadius:8,marginBottom:12},children:[c("b",{children:"New codes — copy now, they are shown once:"}),c("pre",{style:{margin:"6px 0 0",whiteSpace:"pre-wrap"},children:a.join(` +`)})]}),c("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:13},children:c("tbody",{children:(t?.invites??[]).map(h=>c("tr",{style:{borderTop:"1px solid var(--smirk-border)"},children:[c("td",{style:{fontFamily:"monospace",padding:"6px 4px"},children:[h.code_prefix,"…"]}),c("td",{style:{padding:"6px 4px"},children:h.label??""}),c("td",{style:{padding:"6px 4px",color:"var(--smirk-fg-muted)"},children:h.created_at.slice(0,10)}),c("td",{style:{padding:"6px 4px"},children:h.status})]}))})})]})}function ot(){const{data:t,err:e,reload:n}=Q(()=>C.config()),[o,_]=x({}),[r,s]=x(null);if(Me(()=>{t&&_(l=>{const d={...l};for(const p of Object.keys(t.effective))p in d||(d[p]=structuredClone(t.effective[p]));return d})},[t]),e)return c(A,{title:"Config",children:c("p",{style:{color:"var(--smirk-danger)"},children:e})});if(!t)return c(A,{title:"Config",children:"Loading…"});const a=(l,d,p)=>_(i=>({...i,[l]:{...i[l]??{},[d]:p}})),f=async l=>{s(null);const d=t.effective[l]??{},p=o[l]??{},i={};for(const u of Object.keys(p))JSON.stringify(p[u])!==JSON.stringify(d[u])&&(i[u]=p[u]);if(Object.keys(i).length===0){s(`No changes in ${l}.`);return}try{await C.putConfig({[l]:i},{[l]:t.versions[l]??0}),s(`Saved ${l}.`),n()}catch(u){s(u instanceof Error?u.message:String(u))}};return c("div",{children:[t.restart_pending&&c("div",{style:{background:"rgba(245,158,11,0.12)",border:"1px solid var(--smirk-warn)",borderRadius:8,padding:10,marginBottom:16},children:"⚠ A saved change needs a restart to take effect (systemctl restart, or auto mode)."}),r&&c("p",{style:{color:"var(--smirk-accent)"},children:r}),Object.keys(o).sort().map(l=>c(A,{title:l,children:[Object.entries(o[l]??{}).map(([d,p])=>{const i=t.runtime_class[`${l}.${d}`]??"restart-required",u=t.overlay[l]?.[d]!==void 0;return c("div",{style:{display:"flex",alignItems:"center",gap:8,padding:"4px 0"},children:[c("label",{style:{flex:1,minWidth:0},children:[d," ",c("span",{style:{fontSize:11,color:"var(--smirk-fg-muted)"},children:["· ",i==="restart-required"?"restart":"live",u?" · db":""]})]}),typeof p=="boolean"?c("input",{type:"checkbox",checked:p,onChange:h=>a(l,d,h.target.checked)}):c("input",{style:{width:220},value:String(p??""),onInput:h=>{const w=h.target.value;if(typeof p=="number"){if(w==="")return;const v=Number(w);Number.isNaN(v)||a(l,d,v)}else a(l,d,w)}})]})}),c("button",{class:"primary",style:{marginTop:8},onClick:()=>f(l),children:["Save ",l]})]}))]})}const it=["Status","Keys","Invites","Config"];function _t({onLogout:t}){const[e,n]=x("Status");return c("div",{style:{maxWidth:760,margin:"0 auto",padding:20},children:[c("header",{style:{display:"flex",alignItems:"center",marginBottom:16},children:[c("h2",{style:{margin:0,flex:1},children:"🐸 Smirk Operator Console"}),c("button",{onClick:()=>{C.logout().catch(()=>{}),Ue(null),t()},children:"Sign out"})]}),c("nav",{style:{display:"flex",gap:6,marginBottom:16},children:it.map(o=>c("button",{class:o===e?"primary":"",onClick:()=>n(o),children:o}))}),e==="Status"&&c(tt,{}),e==="Keys"&&c(nt,{}),e==="Invites"&&c(rt,{}),e==="Config"&&c(ot,{})]})}function lt(){const[t,e]=x(!1);return t?c(_t,{onLogout:()=>e(!1)}):c(et,{onDone:()=>e(!0)})}Ke(c(lt,{}),document.getElementById("app")); diff --git a/admin-ui/dist/assets/index-uKG4VNAS.css b/admin-ui/dist/assets/index-uKG4VNAS.css new file mode 100644 index 0000000..d25cc27 --- /dev/null +++ b/admin-ui/dist/assets/index-uKG4VNAS.css @@ -0,0 +1 @@ +:root{--smirk-bg: #2c1e12;--smirk-bg-elevated: #3a2a1b;--smirk-bg-sunken: #241810;--smirk-fg: #f3e9dd;--smirk-fg-muted: #b9a891;--smirk-accent: #f5c542;--smirk-accent-fg: #2c1e12;--smirk-border: rgba(255, 255, 255, .12);--smirk-border-strong: rgba(255, 255, 255, .22);--smirk-danger: #ef4444;--smirk-ok: #22c55e;--smirk-warn: #f59e0b;--smirk-radius: 10px;--smirk-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif}*{box-sizing:border-box}body{margin:0;background:var(--smirk-bg);color:var(--smirk-fg);font-family:var(--smirk-font);font-size:14px;line-height:1.5}h1,h2,h3{font-weight:700}button{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--smirk-border);background:var(--smirk-bg-elevated);color:inherit;padding:8px 14px}button.primary{background:var(--smirk-accent);color:var(--smirk-accent-fg);border:none;font-weight:700}button.danger{color:var(--smirk-danger);border-color:var(--smirk-danger)}button:disabled{opacity:.5;cursor:default}input,textarea,select{font:inherit;color:inherit;background:var(--smirk-bg-sunken);border:1px solid var(--smirk-border);border-radius:8px;padding:8px 10px}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}a{color:var(--smirk-accent)} diff --git a/admin-ui/dist/index.html b/admin-ui/dist/index.html new file mode 100644 index 0000000..ec363f3 --- /dev/null +++ b/admin-ui/dist/index.html @@ -0,0 +1,14 @@ + + + + + + + Smirk Operator Console + + + + +
+ + diff --git a/admin-ui/index.html b/admin-ui/index.html new file mode 100644 index 0000000..93bba08 --- /dev/null +++ b/admin-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Smirk Operator Console + + +
+ + + diff --git a/admin-ui/package-lock.json b/admin-ui/package-lock.json new file mode 100644 index 0000000..de5db9e --- /dev/null +++ b/admin-ui/package-lock.json @@ -0,0 +1,1023 @@ +{ + "name": "smirk-admin-ui", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "smirk-admin-ui", + "version": "0.3.0", + "dependencies": { + "preact": "^10.24.3" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/admin-ui/package.json b/admin-ui/package.json new file mode 100644 index 0000000..b41b5be --- /dev/null +++ b/admin-ui/package.json @@ -0,0 +1,18 @@ +{ + "name": "smirk-admin-ui", + "private": true, + "version": "0.3.0", + "type": "module", + "description": "Operator console SPA, embedded into smirk-backend-core and served at /admin.", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "preact": "^10.24.3" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/admin-ui/src/api.ts b/admin-ui/src/api.ts new file mode 100644 index 0000000..d999a77 --- /dev/null +++ b/admin-ui/src/api.ts @@ -0,0 +1,82 @@ +// Fetch layer for the /admin JSON API. Same-origin (the SPA is served from the admin +// plane), bearer access token held in memory only (never localStorage). Admin routes +// are outside the public OpenAPI, so this is hand-written. + +let accessToken: string | null = null; + +export function setToken(t: string | null): void { + accessToken = t; +} +export function hasToken(): boolean { + return accessToken !== null; +} + +async function req(method: string, path: string, body?: unknown): Promise { + const headers: Record = {}; + if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + const res = await fetch(path, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + const data: unknown = text ? JSON.parse(text) : null; + if (!res.ok) { + const msg = + (data as { error?: string; message?: string })?.error ?? + (data as { message?: string })?.message ?? + `${res.status} ${res.statusText}`; + throw new Error(msg); + } + return data; +} + +export interface ChallengeResp { + challenge: string; + url: string; + instance_id: string; + expires_in: number; +} +export interface TokenResp { + access_token: string; + refresh_token?: string; + expires_in: number; +} +export interface KeyRow { + id: string; + pubkey: string; + status: string; +} +export interface InviteView { + code_prefix: string; + label: string | null; + created_at: string; + status: string; +} + +export const api = { + // POST (not GET): issuing a single-use nonce is a write, and must never be + // cached or prefetched. Matches the route wired in src/api/admin.rs. + challenge: () => req('POST', '/admin/auth/challenge', {}) as Promise, + verify: (admin_token: string, challenge: string) => + req('POST', '/admin/auth/verify', { admin_token, challenge }) as Promise, + logout: () => req('POST', '/admin/auth/logout', {}), + me: () => req('GET', '/admin/me'), + features: () => req('GET', '/admin/features'), + // The backend wraps the list as {keys:[...]} (KeysListResponse); unwrap here so + // callers get the array they expect (KeysTab maps over it directly). + keys: () => req('GET', '/admin/keys').then((r) => (r as { keys: KeyRow[] }).keys), + addKey: (pubkey: string, label?: string) => + req('POST', '/admin/keys', label ? { pubkey, label } : { pubkey }), + revokeKey: (id: string) => req('DELETE', `/admin/keys/${id}`), + config: () => req('GET', '/admin/config'), + putConfig: (patch: unknown, expected_versions: Record) => + req('PUT', '/admin/config', { patch, expected_versions }), + invites: () => + req('GET', '/admin/invites') as Promise<{ invites: InviteView[]; unused_count: number }>, + mintInvites: (count: number, label?: string) => + req('POST', '/admin/invites', label ? { count, label } : { count }) as Promise<{ + codes: string[]; + }>, +}; diff --git a/admin-ui/src/main.tsx b/admin-ui/src/main.tsx new file mode 100644 index 0000000..d0d9852 --- /dev/null +++ b/admin-ui/src/main.tsx @@ -0,0 +1,400 @@ +import { render } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; +import './tokens.css'; +import { api, setToken, type KeyRow, type InviteView } from './api'; +import { buildAdminToken, nip07 } from './nip98'; + +// ── login ───────────────────────────────────────────────────────────────────── + +function Login({ onDone }: { onDone: () => void }) { + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + const signIn = async () => { + setBusy(true); + setErr(null); + try { + const signer = nip07(); + if (!signer) { + throw new Error( + 'No NIP-07 signer found. Enable your Smirk wallet (or nos2x/Alby) on this origin, then reload.', + ); + } + const c = await api.challenge(); + const token = await buildAdminToken(c, signer); + const { access_token } = await api.verify(token, c.challenge); + setToken(access_token); + onDone(); + } catch (e) { + setErr(e instanceof Error ? e.message : 'Sign-in failed'); + } finally { + setBusy(false); + } + }; + + return ( +
+
🐸
+

Smirk Operator Console

+

+ Sign in with an admin Nostr key on your instance's allowlist. +

+ + {err &&

{err}

} +
+ ); +} + +// ── shared ──────────────────────────────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: unknown }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function useAsync(fn: () => Promise, deps: unknown[] = []) { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + const reload = () => { + setErr(null); + fn() + .then(setData) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(reload, deps); + return { data, err, reload }; +} + +// ── status ──────────────────────────────────────────────────────────────────── + +function StatusTab() { + const { data, err } = useAsync(() => api.features()); + return ( +
+ {err &&

{err}

} +
{JSON.stringify(data, null, 2)}
+
+ ); +} + +// ── admin keys ──────────────────────────────────────────────────────────────── + +function KeysTab() { + const { data, err, reload } = useAsync(() => api.keys()); + const [pubkey, setPubkey] = useState(''); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + + const add = async () => { + setBusy(true); + setMsg(null); + try { + await api.addKey(pubkey.trim().toLowerCase()); + setPubkey(''); + reload(); + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + const revoke = async (id: string) => { + if (!confirm('Revoke this admin key?')) return; + setMsg(null); + try { + await api.revokeKey(id); + reload(); + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)); + } + }; + + return ( +
+ {err &&

{err}

} + + + {(data ?? []).map((k: KeyRow) => ( + + + + + + ))} + +
{k.pubkey.slice(0, 16)}…{k.status} + {k.status !== 'revoked' && ( + + )} +
+
+ setPubkey((e.target as HTMLInputElement).value)} + /> + +
+ {msg &&

{msg}

} +
+ ); +} + +// ── invites ─────────────────────────────────────────────────────────────────── + +function InvitesTab() { + const { data, err, reload } = useAsync(() => api.invites()); + const [count, setCount] = useState(5); + const [label, setLabel] = useState(''); + const [minted, setMinted] = useState(null); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + + const mint = async () => { + setBusy(true); + setMsg(null); + try { + const r = await api.mintInvites(count, label.trim() || undefined); + setMinted(r.codes); + reload(); + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( +
+ {err &&

{err}

} +
+ setCount(Number((e.target as HTMLInputElement).value))} + /> + setLabel((e.target as HTMLInputElement).value)} + /> + +
+ {msg &&

{msg}

} + {minted && ( +
+ New codes — copy now, they are shown once: +
{minted.join('\n')}
+
+ )} + + + {(data?.invites ?? []).map((i: InviteView) => ( + + + + + + + ))} + +
{i.code_prefix}…{i.label ?? ''}{i.created_at.slice(0, 10)}{i.status}
+
+ ); +} + +// ── config ──────────────────────────────────────────────────────────────────── + +type Overlay = Record>; +interface ConfigResp { + effective: Overlay; + overlay: Overlay; + versions: Record; + runtime_class: Record; + restart_pending: boolean; +} + +function ConfigTab() { + const { data, err, reload } = useAsync(() => api.config() as Promise); + const [edit, setEdit] = useState({}); + const [msg, setMsg] = useState(null); + + useEffect(() => { + if (!data) return; + // Seed edit state for sections we are NOT already editing, and preserve + // in-progress edits — a reload after saving one section must not discard + // unsaved edits in the others. + setEdit((prev) => { + const next = { ...prev }; + for (const section of Object.keys(data.effective)) { + if (!(section in next)) next[section] = structuredClone(data.effective[section]); + } + return next; + }); + }, [data]); + + if (err) return
{

{err}

}
; + if (!data) return
Loading…
; + + const setField = (section: string, field: string, value: unknown) => + setEdit((e) => ({ ...e, [section]: { ...(e[section] ?? {}), [field]: value } })); + + const save = async (section: string) => { + setMsg(null); + // Send ONLY the fields that actually changed. Sending the whole section makes the + // backend count every populated field as "touched" — spuriously flagging a restart + // and pinning env-sourced values into the DB overlay. + const orig = (data.effective[section] ?? {}) as Record; + const cur = (edit[section] ?? {}) as Record; + const patch: Record = {}; + for (const k of Object.keys(cur)) { + if (JSON.stringify(cur[k]) !== JSON.stringify(orig[k])) patch[k] = cur[k]; + } + if (Object.keys(patch).length === 0) { + setMsg(`No changes in ${section}.`); + return; + } + try { + await api.putConfig({ [section]: patch }, { [section]: data.versions[section] ?? 0 }); + setMsg(`Saved ${section}.`); + reload(); + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)); + } + }; + + return ( +
+ {data.restart_pending && ( +
+ ⚠ A saved change needs a restart to take effect (systemctl restart, or auto mode). +
+ )} + {msg &&

{msg}

} + {Object.keys(edit) + .sort() + .map((section) => ( +
+ {Object.entries(edit[section] ?? {}).map(([field, value]) => { + const cls = data.runtime_class[`${section}.${field}`] ?? 'restart-required'; + const overridden = data.overlay[section]?.[field] !== undefined; + return ( +
+ + {typeof value === 'boolean' ? ( + setField(section, field, (e.target as HTMLInputElement).checked)} + /> + ) : ( + { + const v = (e.target as HTMLInputElement).value; + if (typeof value === 'number') { + // Ignore empty / non-numeric input instead of coercing to 0. + if (v === '') return; + const n = Number(v); + if (!Number.isNaN(n)) setField(section, field, n); + } else { + setField(section, field, v); + } + }} + /> + )} +
+ ); + })} + +
+ ))} +
+ ); +} + +// ── shell ───────────────────────────────────────────────────────────────────── + +const TABS = ['Status', 'Keys', 'Invites', 'Config'] as const; +type Tab = (typeof TABS)[number]; + +function Console({ onLogout }: { onLogout: () => void }) { + const [tab, setTab] = useState('Status'); + return ( +
+
+

🐸 Smirk Operator Console

+ +
+ + {tab === 'Status' && } + {tab === 'Keys' && } + {tab === 'Invites' && } + {tab === 'Config' && } +
+ ); +} + +function App() { + const [authed, setAuthed] = useState(false); + return authed ? setAuthed(false)} /> : setAuthed(true)} />; +} + +render(, document.getElementById('app')!); diff --git a/admin-ui/src/nip98.ts b/admin-ui/src/nip98.ts new file mode 100644 index 0000000..2f5a9cc --- /dev/null +++ b/admin-ui/src/nip98.ts @@ -0,0 +1,60 @@ +// Admin login = a NIP-98 kind-27235 "signed action" over a server-issued nonce. +// The backend (admin_verify) binds SIX tags exactly once and a request-descriptor +// hash over an EMPTY body. Get any of this wrong and verify returns an opaque 401, +// so this mirrors src/core/crypto/nip98.rs precisely. + +async function sha256Hex(input: string): Promise { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +export interface Challenge { + challenge: string; + url: string; + instance_id: string; + expires_in: number; +} + +/** A NIP-07 signer: `window.nostr` (Smirk / Alby / nos2x). */ +export interface Nip07 { + getPublicKey(): Promise; + signEvent(event: { + kind: number; + created_at: number; + content: string; + tags: string[][]; + }): Promise<{ id: string; pubkey: string; sig: string; [k: string]: unknown }>; +} + +/** + * Build + sign the admin_login event for a challenge and return the + * `Nostr ` token the backend's `admin_token` field expects. + */ +export async function buildAdminToken(c: Challenge, signer: Nip07): Promise { + // request_descriptor("POST", "/admin/auth/verify", "", b"") then its sha256. + const emptyBodyHash = await sha256Hex(''); + const descriptor = `POST\n/admin/auth/verify\n\n${emptyBodyHash}`; + const payload = await sha256Hex(descriptor); + + const signed = await signer.signEvent({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [ + ['u', c.url], + ['method', 'POST'], + ['purpose', 'admin_login'], + ['challenge', c.challenge], + ['payload', payload], + ['instance_id', c.instance_id], + ], + }); + + return `Nostr ${btoa(JSON.stringify(signed))}`; +} + +/** The page-injected NIP-07 provider, or null. Smirk installs `window.nostr`. */ +export function nip07(): Nip07 | null { + const n = (window as unknown as { nostr?: Nip07 }).nostr; + return n && typeof n.signEvent === 'function' ? n : null; +} diff --git a/admin-ui/src/tokens.css b/admin-ui/src/tokens.css new file mode 100644 index 0000000..81dcf0e --- /dev/null +++ b/admin-ui/src/tokens.css @@ -0,0 +1,50 @@ +/* Smirk brand tokens (vendored — the admin-ui is standalone, no @smirk/ui dep). */ +:root { + --smirk-bg: #2c1e12; + --smirk-bg-elevated: #3a2a1b; + --smirk-bg-sunken: #241810; + --smirk-fg: #f3e9dd; + --smirk-fg-muted: #b9a891; + --smirk-accent: #f5c542; + --smirk-accent-fg: #2c1e12; + --smirk-border: rgba(255, 255, 255, 0.12); + --smirk-border-strong: rgba(255, 255, 255, 0.22); + --smirk-danger: #ef4444; + --smirk-ok: #22c55e; + --smirk-warn: #f59e0b; + --smirk-radius: 10px; + --smirk-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--smirk-bg); + color: var(--smirk-fg); + font-family: var(--smirk-font); + font-size: 14px; + line-height: 1.5; +} +h1, h2, h3 { font-weight: 700; } +button { + font: inherit; + cursor: pointer; + border-radius: 8px; + border: 1px solid var(--smirk-border); + background: var(--smirk-bg-elevated); + color: inherit; + padding: 8px 14px; +} +button.primary { background: var(--smirk-accent); color: var(--smirk-accent-fg); border: none; font-weight: 700; } +button.danger { color: var(--smirk-danger); border-color: var(--smirk-danger); } +button:disabled { opacity: 0.5; cursor: default; } +input, textarea, select { + font: inherit; + color: inherit; + background: var(--smirk-bg-sunken); + border: 1px solid var(--smirk-border); + border-radius: 8px; + padding: 8px 10px; +} +code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +a { color: var(--smirk-accent); } diff --git a/admin-ui/tsconfig.json b/admin-ui/tsconfig.json new file mode 100644 index 0000000..6bfd734 --- /dev/null +++ b/admin-ui/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [] + }, + "include": ["src"] +} diff --git a/admin-ui/vite.config.ts b/admin-ui/vite.config.ts new file mode 100644 index 0000000..1c83d17 --- /dev/null +++ b/admin-ui/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; + +// Served by the Rust backend at /admin (rust-embed). `base: '/admin/'` makes the +// built asset URLs absolute under /admin/assets/ so they resolve regardless of the +// trailing slash. Preact JSX via esbuild (no plugin, keeps the toolchain tiny). +export default defineConfig({ + base: '/admin/', + build: { + outDir: 'dist', + target: 'es2022', + emptyOutDir: true, + assetsDir: 'assets', + }, + esbuild: { + jsx: 'automatic', + jsxImportSource: 'preact', + }, +}); diff --git a/docs/operations/CONSOLE.md b/docs/operations/CONSOLE.md new file mode 100644 index 0000000..595f150 --- /dev/null +++ b/docs/operations/CONSOLE.md @@ -0,0 +1,219 @@ +# Operator Console: admin surface guide + +The Operator Console is an embedded single-page app served at `/admin` on the +backend's loopback admin plane. It is the browser front end for the same admin +JSON API the `smirk-admin` CLI complements: sign in with your admin key, review +status, manage admin keys and invite codes, and edit the runtime config overlay. + +The console ships inside the binary (built from `admin-ui/` and embedded at +compile time), so self-hosting needs no separate front-end deploy. It is off +unless the admin plane is enabled. + +## 1. Enable and reach the admin plane + +The admin plane is a separate listener from the public API; confidentiality is by +socket, not by middleware ordering. It never joins the public router or the +OpenAPI surface. + +Enable it in your `.env`: + +```sh +ADMIN_ENABLED=true +ADMIN_BIND=127.0.0.1:8081 # loopback by default +ADMIN_PUBLIC_URL=https://admin.example.org +ADMIN_JWT_SECRET= +ADMIN_KEY_INTEGRITY_SECRET= # back this up; losing it locks out admin +``` + +`ADMIN_BIND` defaults to loopback. Boot refuses a non-loopback bind unless you +also set `ADMIN_ALLOW_PUBLIC_BIND=true`, so the plane cannot be exposed on a +public interface by accident. Never bind it to a public address; reach it one of +two ways instead: + +- **SSH tunnel.** `ssh -L 8081:127.0.0.1:8081 you@your-server`, then open + `http://127.0.0.1:8081/admin` in your local browser. Because the Host is + `127.0.0.1`, the admin plane's Host allowlist accepts it with no extra config. +- **Tor onion service.** Point a hidden service at `127.0.0.1:8081` and set + `TOR_ADMIN_ONION=.onion`. The onion host is added to the admin Host + allowlist; without `TOR_ADMIN_ONION`, a request whose `Host` header is the + onion is rejected with `403` (the allowlist defends the loopback socket against + DNS rebinding, and only loopback names plus the one configured onion pass). + Then browse `http://.onion/admin` in Tor Browser. + +The console shell (`GET /admin`, `/admin/`, `/admin/assets/*`) is served +unauthenticated: it loads without a session and runs the login client-side. Every +JSON route it calls (`/admin/keys`, `/admin/config`, `/admin/invites`, ...) is +guarded, so serving the shell openly exposes nothing. + +### `ADMIN_PUBLIC_URL` is the per-node identity + +`ADMIN_PUBLIC_URL` is the value your wallet's signed action binds against (the +NIP-98 `u` tag), and a per-node id is derived from it so a challenge signed for +one node cannot be replayed to another node in a fleet. Give each node in a fleet +a distinct `ADMIN_PUBLIC_URL`. The server dictates and verifies this value from +config, never from the request `Host` header, so the value you reach the plane at +(loopback, tunnel, onion) does not have to equal `ADMIN_PUBLIC_URL`. + +## 2. Sign in with your admin key + +Login is a NIP-98 signed action ("Sign in with Smirk"): you prove control of your +admin private key by signing a single-use, server-issued challenge with your +Smirk wallet. Only admin public keys are ever stored; the server never holds an +admin seed or private key. + +1. Bootstrap a first admin key if you have not already (see + [OPERATOR_SETUP.md](OPERATOR_SETUP.md) section 5): `smirk-admin setup --pubkey + ` seeds your pubkey active, or `smirk-admin create-admin-wallet` + generates a pending key that activates on its first login. +2. Open the console and start login. It calls `POST /admin/auth/challenge`, which + returns a nonce, the `u` URL to bind, and the instance id. +3. Sign the kind-27235 (NIP-98) challenge event with the Smirk wallet holding your + admin key. The console posts it to `POST /admin/auth/verify`. +4. On success you receive an admin access token plus a refresh token, and the + console loads the authenticated tabs. A pending key activates on this first + login. + +Every admin-auth rejection (bad signature, a valid signature for a non-admin key, +a missing session) collapses to the same opaque `401`, so the surface is not an +enumeration oracle. Login and privileged actions are written to a hash-chained, +tamper-evident audit trail. + +## 3. Console tabs + +- **Status**: the effective feature/capability view, including any downgrades + (a flag that is on in config but serving as disabled because its secret or URL + is unset, e.g. `xmr` on with `XMR_LWS_ADMIN_KEY` empty). +- **Keys**: add, list, revoke, and rotate admin keys. A new key is added pending + and activates on its holder's first login. Revoking the last live key over the + network is refused (use the CLI); rotate is allowed on a solo key because it is + add-plus-revoke, the in-band recovery for a compromised sole key. +- **Invites**: mint single-use invite codes (`POST /admin/invites`) and list + them (`GET /admin/invites`). Raw codes are shown once; only their hash is + stored. +- **Config**: the runtime config overlay editor (below). + +## 4. Config: the env-vs-DB precedence model + +Effective config is layered: + +``` +defaults -> environment (.env) -> DB overlay (operator settings) -> validate +``` + +The DB overlay is a sparse, per-section patch stored in the `operator_settings` +table, one MAC'd row per section. A field present in the overlay overrides env for +that field; an absent field keeps its env (or default) value. `GET /admin/config` +returns, for every editable field, its effective value, the DB overlay, the +per-section version, its runtime class, and whether a restart is pending. So the +console can show each field's current value and whether its source is env/default +or the DB overlay. + +Editing goes through `PUT /admin/config` with a sparse patch. The patch is merged +onto the persisted overlay and re-run through the exact same `Config::validate` +that boot uses, so a bad edit is rejected with the identical error boot would +raise and nothing invalid is ever persisted or applied. Secrets are excluded from +the editable surface by construction: no overlay field maps to a secret (JWT +secrets, peppers, the ALTCHA HMAC key, the payment processor API key, the relay +admission bind), so the console can neither read nor set one. Secrets stay in env. + +The nine editable sections are: `landing`, `retention`, `restore`, `console`, +`registration`, `pow`, `features`, `relay`, `premium`. + +### Runtime-safe vs restart-required fields + +Each field is classified as runtime-safe (the change hot-swaps onto the live +config and takes effect on the next request) or restart-required (the change is +persisted and applied on the next graceful restart, because a boot-built client, +socket, or worker captured the old value). The classification is the single +source of truth in `runtime_class()` and is returned per field by +`GET /admin/config`. The exact classification: + +| Section | Class | Notes | +| --- | --- | --- | +| `landing.*` | runtime-safe | read per request on the landing/render path | +| `retention.*` | runtime-safe | **except** `retention.erasure_enabled` | +| `retention.erasure_enabled` | restart-required | gates a boot-spawned erasure sweep worker | +| `restore.*` | runtime-safe | restore policy is read per request | +| `console.*` | runtime-safe | `restart_apply_mode` is read at save time | +| `registration.*` | runtime-safe | **except** `registration.require_payment` | +| `registration.require_payment` | restart-required | gates the boot-built payment client (the provider is `None` at boot unless payment or premium is on) | +| `pow.*` | runtime-safe | the PoW gate is read per request | +| `features.*` | restart-required | every field: feature flags and per-chain enablement gate boot-built clients/workers (price poller, chain adapters) | +| `relay.*` | restart-required | the relay/admission service is boot-bound and the URL is advertised at boot | +| `premium.*` | restart-required | premium is enforced by the boot-bound relay admission service | + +Any `(section, field)` not listed defaults to restart-required, the safe default: +never hot-swap something whose consumers have not been reasoned about. + +A `PUT` that touches only runtime-safe fields hot-swaps just that patch onto the +running config and reports `applied: "runtime"`. A `PUT` that touches any +restart-required field persists the change and reports `applied: +"restart-required"` with `restart_pending: true`. `restart_pending` stays true +until a restart applies it, and it reflects any restart-required change saved by +an earlier `PUT`, even when the current edit was itself live. + +### Optimistic concurrency (`expected_versions`) + +Each section carries a version that increments on every write. To guard against +two operators editing at once, send `expected_versions` in the `PUT` body with the +version you last read per touched section. If the stored version has moved on, the +write is rejected with `409 Conflict` and nothing is persisted; reload and retry. +Omitting `expected_versions` skips the check (last write wins). + +### `restart_apply_mode`: manual vs auto + +`console.restart_apply_mode` (env `RESTART_APPLY_MODE`, default `manual`) decides +how a restart-required change is applied: + +- `manual` (default): the change is persisted and flagged `restart_pending`. You + apply it yourself with a graceful restart of the service. +- `auto`: after saving a restart-required change, the instance self-restarts + shortly after responding so the change takes effect. The console warns before + saving in this mode. + +Auto mode REQUIRES the service to be brought back on exit. The self-restart is a +graceful shutdown (it drains in-flight requests and exits); nothing relaunches the +process on its own. Run under systemd with `Restart=always` (or an equivalent +supervisor) so the instance comes back with the new config applied. Without that, +`auto` takes the instance down and leaves it down. Do not use `auto` for a +process you start by hand. + +### Clearing a DB override to fall back to env + +The overlay is stored per section, and a value you set through the console is a DB +override that wins over env for that field. Note the behavior in this version: + +- There is no per-field null clear. A `null` in a `PUT` patch means "leave this + field unchanged" and is skipped, so sending `null` does not remove an existing + override. +- To make a field's effect match env again, set it back through the console to the + same value env provides. The effective value then equals env, though the field's + source still reports `db`. +- `landing.title` is the one special case: setting it to an empty string clears it + back to the env value (unset). +- To fully drop a section's DB overlay so its source returns to env/default (and + future env edits for that section flow through again), delete that section's row + from the `operator_settings` table and restart, for example: + + ```sql + DELETE FROM operator_settings WHERE section = 'landing'; + ``` + + On load, any section whose row is absent, whose MAC fails, or which fails to + decode is ignored and falls back to env, fail-closed on tamper and + fail-open-to-env on a missing section. There is no console/API/CLI command to + clear a section in this version; the direct table delete is the supported path. + +## 5. Reserved / inert config + +- `ADMIN_PUBKEYS` is parsed from the environment but is NOT consumed anywhere in + this version: it does not gate admin access. Admin authorization is the + MAC-protected key allowlist in the database (managed via `smirk-admin` and the + Keys tab), never this variable. Treat `ADMIN_PUBKEYS` as reserved and leave it + unset; setting it has no effect and does not grant access. + +## Related + +- [OPERATOR_SETUP.md](OPERATOR_SETUP.md): full deployment and admin bootstrap. +- [RELAY.md](RELAY.md): the optional Nostr relay messaging plane. +- The root [`.env.example`](../../.env.example) documents every environment key. diff --git a/docs/operations/OPERATOR_SETUP.md b/docs/operations/OPERATOR_SETUP.md index 902fbbe..c24800e 100644 --- a/docs/operations/OPERATOR_SETUP.md +++ b/docs/operations/OPERATOR_SETUP.md @@ -100,6 +100,19 @@ Expose the admin plane over a Tor onion or an SSH tunnel; never bind it to a public interface. `ADMIN_BIND` defaults to loopback, and a non-loopback bind requires an explicit `ADMIN_ALLOW_PUBLIC_BIND=true`. +- **SSH tunnel:** `ssh -L 8081:127.0.0.1:8081 you@server`, then reach the plane at + `http://127.0.0.1:8081`. The loopback Host passes the admin allowlist with no + extra config. +- **Tor onion:** point a hidden service at `127.0.0.1:8081` and set + `TOR_ADMIN_ONION=.onion`. That onion host is added to the admin Host + allowlist; without it, a request whose `Host` header is the onion is rejected + (`403`), because the allowlist only accepts loopback names plus the one + configured onion (anti DNS-rebinding). + +The Operator Console (an embedded admin SPA served at `/admin` on this same +plane) gives you a browser front end for keys, invites, status, and the runtime +config overlay. See [CONSOLE.md](CONSOLE.md). + ## 6. Registration policy (optional) Gates are composable and advertised via `/capabilities`; returning wallets and diff --git a/docs/operations/RELAY.md b/docs/operations/RELAY.md index 8be3e5e..29ac414 100644 --- a/docs/operations/RELAY.md +++ b/docs/operations/RELAY.md @@ -37,11 +37,29 @@ recipient), which a static pubkey whitelist can't do. | `inbox-outbox` (default) | registered npubs publish their own events (outbox); **anyone** may deliver a NIP-17 gift-wrap (kind 1059) **addressed to a registered user** (inbox) | | `author-allowlist` | only registered Smirk npubs may publish anything (no external inbound) | | `open` | accept everything (the relay's resource caps still apply) | +| `premium-post` | registered users post wallet-functional events (DMs, tips, swap coordination) free; **general** notes require an active premium subscription (see the premium tier below). This is the flagship monetized relay policy | `RELAY_INBOUND_POW_BITS` (0 = off) requires a NIP-13 proof-of-work on cross-ecosystem (non-registered-author) inbound events — spam friction without an author allowlist. Only the `inbox-outbox` external-inbound branch is gated. +`RELAY_WRITE_ALLOWLIST_NPUBS` (comma-separated `npub1…` or 64-char hex; empty by +default) lists write-exempt npubs that may publish **any** kind regardless of the +write policy or premium status. The intended use is an announcements or +feed-owner account that seeds a `premium-post` feed without itself holding a +subscription. It is an exemption list, not the `author-allowlist` policy. + +## Premium tier (`premium-post`) + +The `premium-post` policy pairs with the premium subscription block +(`PREMIUM_ENABLED=true`, `PREMIUM_CURRENCY`, `PREMIUM_PLANS`) and the `PAYMENT_*` +processor. Wallet use stays free; premium unlocks general Nostr posting to your +relay. `Config::validate` fails closed if `PREMIUM_ENABLED` is set without +`RELAY_ENABLED=true`, `RELAY_WRITE_POLICY=premium-post`, the processor +credentials, and at least one priced `PREMIUM_PLANS` entry. Full design: +[../design/premium-relay.md](../design/premium-relay.md); ready-to-copy config: +the [`paid-relay`](../../examples/paid-relay/) recipe. + ## Enable it 1. Set the relay env (see `.env.example` → *Nostr relay*): diff --git a/examples/community/.env.example b/examples/community/.env.example index 7e2e5e2..e7d310a 100644 --- a/examples/community/.env.example +++ b/examples/community/.env.example @@ -43,6 +43,19 @@ RELAY_WRITE_POLICY=author-allowlist RELAY_INBOUND_POW_BITS=0 RELAY_ADMISSION_BIND=127.0.0.1:8090 +# ── Chains: enable ONLY what this recipe wires up ──────────────────────────── +# FAIL-CLOSED RULE: in production, an ENABLED chain with no source configured makes +# the server REFUSE TO BOOT. Every FEATURE_* chain defaults to true, so this recipe +# turns off the chains it does not wire up. It wires up Monero + Wownero (the +# XMR_/WOW_ blocks below); fill their *_LWS_ADMIN_KEY and it boots. To add BTC/LTC +# (electrum URL) or GRIN (GRIN_OWNER_API_SECRET + wiring), configure the source +# first, THEN flip its flag to true. +FEATURE_XMR=true +FEATURE_WOW=true +FEATURE_BTC=false +FEATURE_LTC=false +FEATURE_GRIN=false + # Chain endpoints — see ../../.env.example. XMR_LWS_URL= XMR_LWS_ADMIN_URL= diff --git a/examples/friends/.env.example b/examples/friends/.env.example index c54bca9..6eb8077 100644 --- a/examples/friends/.env.example +++ b/examples/friends/.env.example @@ -45,6 +45,19 @@ RELAY_WRITE_POLICY=inbox-outbox RELAY_INBOUND_POW_BITS=0 RELAY_ADMISSION_BIND=127.0.0.1:8090 +# ── Chains: enable ONLY what this recipe wires up ──────────────────────────── +# FAIL-CLOSED RULE: in production, an ENABLED chain with no source configured makes +# the server REFUSE TO BOOT. Every FEATURE_* chain defaults to true, so this recipe +# turns off the chains it does not wire up. It wires up Monero + Wownero (the +# XMR_/WOW_ blocks below); fill their *_LWS_ADMIN_KEY and it boots. To add BTC/LTC +# (electrum URL) or GRIN (GRIN_OWNER_API_SECRET + wiring), configure the source +# first, THEN flip its flag to true. +FEATURE_XMR=true +FEATURE_WOW=true +FEATURE_BTC=false +FEATURE_LTC=false +FEATURE_GRIN=false + # Chain endpoints — point at your own nodes / LWS. See ../../.env.example. XMR_LWS_URL= XMR_LWS_ADMIN_URL= diff --git a/examples/paid-access/.env.example b/examples/paid-access/.env.example index 3206d08..ca49ae5 100644 --- a/examples/paid-access/.env.example +++ b/examples/paid-access/.env.example @@ -50,6 +50,19 @@ FEATURE_PRICES=true FEATURE_TIPS=false RELAY_ENABLED=false +# ── Chains: enable ONLY what this recipe wires up ──────────────────────────── +# FAIL-CLOSED RULE: in production, an ENABLED chain with no source configured makes +# the server REFUSE TO BOOT. Every FEATURE_* chain defaults to true, so this recipe +# turns off the chains it does not wire up. It wires up Monero + Wownero (the +# XMR_/WOW_ blocks below); fill their *_LWS_ADMIN_KEY and it boots. To add BTC/LTC +# (electrum URL) or GRIN (GRIN_OWNER_API_SECRET + wiring), configure the source +# first, THEN flip its flag to true. +FEATURE_XMR=true +FEATURE_WOW=true +FEATURE_BTC=false +FEATURE_LTC=false +FEATURE_GRIN=false + # Chain endpoints — see ../../.env.example. XMR_LWS_URL= XMR_LWS_ADMIN_URL= diff --git a/examples/paid-relay/.env.example b/examples/paid-relay/.env.example index 11443bd..087ac21 100644 --- a/examples/paid-relay/.env.example +++ b/examples/paid-relay/.env.example @@ -71,6 +71,19 @@ FEATURE_PRICES=true FEATURE_TIPS=false ADMIN_ENABLED=false +# ── Chains: enable ONLY what this recipe wires up ──────────────────────────── +# FAIL-CLOSED RULE: in production, an ENABLED chain with no source configured makes +# the server REFUSE TO BOOT. Every FEATURE_* chain defaults to true, so this recipe +# turns off the chains it does not wire up. It wires up Monero + Wownero (the +# XMR_/WOW_ blocks below); fill their *_LWS_ADMIN_KEY and it boots. To add BTC/LTC +# (electrum URL) or GRIN (GRIN_OWNER_API_SECRET + wiring), configure the source +# first, THEN flip its flag to true. +FEATURE_XMR=true +FEATURE_WOW=true +FEATURE_BTC=false +FEATURE_LTC=false +FEATURE_GRIN=false + # Chain endpoints — see ../../.env.example (XMR/WOW LWS + daemon, GRIN_*, electrum). XMR_LWS_URL= XMR_LWS_ADMIN_URL= diff --git a/examples/personal/.env.example b/examples/personal/.env.example index b2752a3..89826c6 100644 --- a/examples/personal/.env.example +++ b/examples/personal/.env.example @@ -45,6 +45,19 @@ RELAY_ENABLED=false ADMIN_ENABLED=false ADMIN_KEY_INTEGRITY_SECRET= +# ── Chains: enable ONLY what this recipe wires up ──────────────────────────── +# FAIL-CLOSED RULE: in production, an ENABLED chain with no source configured makes +# the server REFUSE TO BOOT. Every FEATURE_* chain defaults to true, so this recipe +# turns off the chains it does not wire up. It wires up Monero + Wownero (the +# XMR_/WOW_ blocks below); fill their *_LWS_ADMIN_KEY and it boots. Running your own +# node, you will likely want more chains: to add BTC/LTC set the electrum URL, to +# add GRIN set GRIN_OWNER_API_SECRET (+ wiring), THEN flip that chain's flag to true. +FEATURE_XMR=true +FEATURE_WOW=true +FEATURE_BTC=false +FEATURE_LTC=false +FEATURE_GRIN=false + # Chain endpoints — point at YOUR nodes / light-wallet servers. See the full list # and formats in ../../.env.example (XMR/WOW LWS + daemon, GRIN_*, BTC/LTC electrum). XMR_LWS_URL= diff --git a/examples/public-free/.env.example b/examples/public-free/.env.example index 9ae4c19..6534ba8 100644 --- a/examples/public-free/.env.example +++ b/examples/public-free/.env.example @@ -49,6 +49,19 @@ RELAY_ENABLED=false # Admin HTTP plane off at launch; enable + bootstrap a key when you need it. ADMIN_ENABLED=false +# ── Chains: enable ONLY what this recipe wires up ──────────────────────────── +# FAIL-CLOSED RULE: in production, an ENABLED chain with no source configured makes +# the server REFUSE TO BOOT. Every FEATURE_* chain defaults to true, so this recipe +# turns off the chains it does not wire up. It wires up Monero + Wownero (the +# XMR_/WOW_ blocks below); fill their *_LWS_ADMIN_KEY and it boots. To add BTC/LTC +# (electrum URL) or GRIN (GRIN_OWNER_API_SECRET + wiring), configure the source +# first, THEN flip its flag to true. +FEATURE_XMR=true +FEATURE_WOW=true +FEATURE_BTC=false +FEATURE_LTC=false +FEATURE_GRIN=false + # Chain endpoints — reuse your existing daemons / LWS. See ../../.env.example. XMR_LWS_URL= XMR_LWS_ADMIN_URL= diff --git a/migrations/20260718000001_operator_settings.sql b/migrations/20260718000001_operator_settings.sql new file mode 100644 index 0000000..42a3a76 --- /dev/null +++ b/migrations/20260718000001_operator_settings.sql @@ -0,0 +1,15 @@ +-- Operator-editable settings overlay: the DB precedence tier UNDER the env-derived +-- Config (effective = defaults -> env -> DB overlay). One JSONB row per section; the +-- effective Config is rebuilt from these at boot and on change. Each row is MAC'd with +-- ADMIN_KEY_INTEGRITY_SECRET (same anchor as server_config / admin_keys) so a DB-write +-- attacker cannot silently flip operator policy, and every write is appended to the +-- hash-chained admin_audit log. A missing section = env-only (no seed rows). Secrets are +-- NEVER stored here (overlays carry no secret fields by construction). +CREATE TABLE operator_settings ( + section TEXT PRIMARY KEY, + doc JSONB NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + updated_by UUID REFERENCES admin_keys(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + integrity_mac TEXT NOT NULL +); diff --git a/openapi.json b/openapi.json index 7c4da52..5d65d13 100644 --- a/openapi.json +++ b/openapi.json @@ -3728,15 +3728,11 @@ "minimum": 0 }, "locked_balance": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string" }, "pending_balance": { - "type": "integer", - "format": "int64", - "description": "Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool\nrows (the monero-lws mempool feature); never negative.", - "minimum": 0 + "type": "string", + "description": "Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool\nrows (the monero-lws mempool feature); never negative." }, "scanned_height": { "type": "integer", @@ -3756,9 +3752,8 @@ "minimum": 0 }, "total_received": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "example": "12345000000000" }, "transaction_count": { "type": "integer", @@ -4626,9 +4621,7 @@ ], "properties": { "amount": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string" }, "key_image": { "type": "string" @@ -4742,15 +4735,11 @@ "type": "string" }, "total_received": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string" }, "total_sent": { - "type": "integer", - "format": "int64", - "description": "\"Possible\" sent — candidate spends, not authoritative.", - "minimum": 0 + "type": "string", + "description": "\"Possible\" sent — candidate spends, not authoritative." }, "unlock_time": { "type": "integer", @@ -4776,9 +4765,7 @@ ], "properties": { "amount": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string" }, "global_index": { "type": "integer", diff --git a/src/api/admin.rs b/src/api/admin.rs index 6106358..81bbd0f 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -66,14 +66,14 @@ fn admin_manager(state: &AppState) -> Result<&AdminSessionManager, AppError> { fn admin_verify_url(state: &AppState) -> String { format!( "{}/admin/auth/verify", - state.config.admin.public_url.trim_end_matches('/') + state.cfg().admin.public_url.trim_end_matches('/') ) } /// A stable per-instance id bound into the signed action, so a challenge signed /// for this instance cannot be relayed to another. fn admin_instance_id(state: &AppState) -> String { - hex::encode(Sha256::digest(state.config.admin.public_url.as_bytes()))[..16].to_string() + hex::encode(Sha256::digest(state.cfg().admin.public_url.as_bytes()))[..16].to_string() } fn pubkey_prefix(pubkey: &str) -> String { @@ -121,7 +121,7 @@ pub async fn admin_plane_guard( .unwrap_or(""); // Compute the response on both paths, then apply the security headers once so // the 403 Host-reject carries them too. - let mut resp = if host_allowed(host, state.config.admin.onion.as_deref()) { + let mut resp = if host_allowed(host, state.cfg().admin.onion.as_deref()) { next.run(req).await } else { (StatusCode::FORBIDDEN, "forbidden").into_response() @@ -190,7 +190,7 @@ pub async fn admin_guard( // Live, uncached allowlist re-check (MAC re-verified inside): must be active // AND activated (a pending key is not yet authorized for protected routes). - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let key = state .db .get_active_admin_key(&info.pubkey, secret) @@ -279,7 +279,7 @@ pub async fn admin_verify( ) -> Result, AppError> { let mgr = admin_manager(&state)?; let ip = client_ip(&state, &headers, peer); - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; // 1. Prove the signature + bindings (purpose/nonce/descriptor/instance). The // descriptor binds the verify URL with an EMPTY body (the proof rides in the @@ -335,7 +335,7 @@ pub async fn admin_verify( let pair = mgr.create_token_pair(&pubkey, session_id)?; let refresh_hash = hash_refresh_token( &pair.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); let audit = NewAdminAudit { action: "admin_login".into(), @@ -386,7 +386,7 @@ pub async fn admin_refresh( // Re-authorize against the live allowlist (closes the "8h refresh is the real // blast radius" gap). - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let key = state .db .get_active_admin_key(&pubkey, secret) @@ -403,7 +403,7 @@ pub async fn admin_refresh( .ok_or_else(admin_auth_fail)?; let presented = hash_refresh_token( &req.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); let hash_ok: bool = presented .as_bytes() @@ -435,7 +435,7 @@ pub async fn admin_logout( ) -> Result, AppError> { let ctx = admin_guard(&state, &headers).await?; state.db.revoke_admin_session(ctx.session_id).await?; - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let _ = state .db .record_admin_audit( @@ -520,7 +520,7 @@ fn pending_key(state: &AppState, pubkey: String, label: Option) -> NewAd scope: "admin".into(), created_by_kind: "admin".into(), activation_deadline: Some( - Utc::now() + Duration::days(state.config.admin.pending_key_ttl_days as i64), + Utc::now() + Duration::days(state.cfg().admin.pending_key_ttl_days as i64), ), } } @@ -536,7 +536,7 @@ pub async fn admin_keys_add( let pubkey = req.pubkey.to_lowercase(); validate_admin_pubkey(&pubkey)?; - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let audit = NewAdminAudit { action: "admin_key_added".into(), actor_kind: "admin".into(), @@ -552,7 +552,7 @@ pub async fn admin_keys_add( pending_key(&state, pubkey, req.label), &audit, secret, - state.config.admin.max_keys as i64, + state.cfg().admin.max_keys as i64, ) .await? { @@ -593,7 +593,7 @@ pub async fn admin_keys_revoke( Path(id): Path, ) -> Result, AppError> { let ctx = admin_guard(&state, &headers).await?; - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let audit = NewAdminAudit { action: "admin_key_revoked".into(), actor_kind: "admin".into(), @@ -632,7 +632,7 @@ pub async fn admin_keys_rotate( let ctx = admin_guard(&state, &headers).await?; let pubkey = req.pubkey.to_lowercase(); validate_admin_pubkey(&pubkey)?; - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let audit = NewAdminAudit { action: "admin_key_rotated".into(), actor_kind: "admin".into(), @@ -679,7 +679,7 @@ pub async fn admin_features( headers: HeaderMap, ) -> Result, AppError> { admin_guard(&state, &headers).await?; - let cfg = &state.config; + let cfg = &state.cfg(); let effective = crate::api::capabilities::effective_capabilities(cfg); let mut downgrades = Vec::new(); @@ -738,8 +738,392 @@ pub async fn admin_features( // ── router ─────────────────────────────────────────────────────────────────── /// Admin-plane routes. Mounted by [`crate::admin_router`] on the loopback socket. +// ── Operator config (GET + PUT /admin/config) ───────────────────────────────── +// The editable settings surface, on the effective-config layer (config_overlay.rs). +// GET reports each field's effective value + the DB overlay + per-section version + +// runtime_class + restart_pending. PUT validates a patch with the SAME rules boot +// uses, persists the changed sections (audited, version-guarded), then hot-swaps the +// effective config for runtime-safe edits or flags restart-required. + +#[derive(serde::Serialize)] +pub struct AdminConfigResponse { + /// Effective value of every editable field (all populated). + effective: crate::config_overlay::SettingsOverlay, + /// DB overlay (operator-set values only): a field present here has source=db; + /// absent = env/default. + overlay: crate::config_overlay::SettingsOverlay, + /// Persisted version per section (echo back as `expected_versions` on PUT). + versions: std::collections::HashMap, + /// "section.field" -> runtime-safe | restart-required. + runtime_class: std::collections::HashMap, + /// A persisted change hasn't been applied to the running config yet (needs restart). + restart_pending: bool, +} + +/// `restart_pending`: the persisted overlay, applied to the boot base, differs from +/// the currently-RUNNING effective config in at least one field — i.e. a +/// restart-required change was saved but has not been applied by a restart yet. +/// Shared by GET and PUT so both report the identical signal. A persisted overlay +/// that no longer validates is treated as not-pending (the running config is the +/// authority; GET/PUT surface the validation error separately). +fn overlay_restart_pending( + config_base: &crate::config::Config, + persisted: &crate::config_overlay::SettingsOverlay, + running: &crate::config::Config, +) -> bool { + match config_base.apply_overlay(persisted) { + Ok(p) => { + serde_json::to_value(p.editable_overlay()).ok() + != serde_json::to_value(running.editable_overlay()).ok() + } + Err(_) => false, + } +} + +#[instrument(skip(state, headers))] +pub async fn admin_config_get( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + admin_guard(&state, &headers).await?; + let secret = state.cfg().admin.key_integrity_secret.clone(); + + let overlay = state.db.load_settings_overlay(&secret).await?; + let versions = state.db.settings_versions().await?; + let running = state.cfg(); + let effective = running.editable_overlay(); + + // A restart-required field was saved but not yet applied (see the shared helper). + let restart_pending = overlay_restart_pending(&state.config_base, &overlay, &running); + + let runtime_class = crate::config_overlay::EDITABLE_FIELDS + .iter() + .map(|(section, field)| { + ( + format!("{section}.{field}"), + crate::config_overlay::runtime_class(section, field), + ) + }) + .collect(); + + Ok(Json(AdminConfigResponse { + effective, + overlay, + versions, + runtime_class, + restart_pending, + })) +} + +#[derive(serde::Deserialize)] +pub struct PutConfigRequest { + /// Sparse patch: only the fields to change (unset fields left as-is). + patch: crate::config_overlay::SettingsOverlay, + /// Optional optimistic-concurrency guard: expected current version per touched + /// section. A mismatch is a 409 (another operator edited it first). + #[serde(default)] + expected_versions: std::collections::HashMap, +} + +#[derive(serde::Serialize)] +pub struct PutConfigResponse { + /// "runtime" = hot-swapped, live now. "restart-required" = persisted, applies on + /// the next (graceful) restart. + applied: String, + restart_pending: bool, +} + +#[instrument(skip(state, headers, req))] +pub async fn admin_config_put( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let ctx = admin_guard(&state, &headers).await?; + let secret = state.cfg().admin.key_integrity_secret.clone(); + + let patch_json = serde_json::to_value(&req.patch) + .map_err(|e| AppError::ValidationError(format!("bad patch: {e}")))?; + let patch_obj = patch_json.as_object().cloned().unwrap_or_default(); + + // merged = persisted overlay + patch (patch's non-null fields win), then validate + // with the SAME rules boot uses. Reject the whole PUT if invalid (nothing persists). + let persisted = state.db.load_settings_overlay(&secret).await?; + let mut merged_json = + serde_json::to_value(&persisted).map_err(|e| AppError::Internal(e.to_string()))?; + json_merge_skip_null(&mut merged_json, &patch_json); + let merged: crate::config_overlay::SettingsOverlay = + serde_json::from_value(merged_json.clone()) + .map_err(|e| AppError::ValidationError(format!("bad settings: {e}")))?; + // Validate the FULL merged overlay with the same rules boot uses; a bad edit is a + // 400 and nothing persists. (The hot-swap below re-validates the narrower change.) + state.config_base.apply_overlay(&merged)?; // ValidationError -> 400 + + // Classify the CHANGED fields: which sections changed + does any need a restart? + let mut any_restart = false; + let mut changed_sections: Vec = Vec::new(); + for (section, fields) in &patch_obj { + let Some(fields) = fields.as_object() else { + continue; + }; + let touched: Vec<&String> = fields + .iter() + .filter(|(_, v)| !v.is_null()) + .map(|(k, _)| k) + .collect(); + if touched.is_empty() { + continue; + } + changed_sections.push(section.clone()); + for field in touched { + if crate::config_overlay::runtime_class(section, field) + == crate::config_overlay::RuntimeClass::RestartRequired + { + any_restart = true; + } + } + } + if changed_sections.is_empty() { + return Err(AppError::ValidationError("empty config patch".into())); + } + + // Persist each changed section's merged doc, audited + version-guarded. + let merged_obj = merged_json.as_object().cloned().unwrap_or_default(); + for section in &changed_sections { + let doc = merged_obj + .get(section) + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + let audit = NewAdminAudit { + action: "config_updated".into(), + actor_kind: "admin".into(), + actor_pubkey_prefix: Some(pubkey_prefix(&ctx.pubkey)), + target: Some(section.clone()), + details: Some(serde_json::json!({ "section": section })), + ip_address: None, + }; + state + .db + .put_setting_audited( + section, + &doc, + Some(ctx.admin_key_id), + req.expected_versions.get(section).copied(), + &audit, + &secret, + ) + .await?; + } + + // Apply. Runtime-safe-only edits hot-swap the effective config atomically (live on + // the next request); any restart-required field is persisted + flagged so the + // operator applies it with a graceful restart. + if any_restart { + // In `auto` mode, self-restart shortly AFTER responding so the persisted change + // takes effect (systemd `Restart=always` brings us back). The console warns the + // operator before saving in auto mode; `manual` just flags restart_pending. + if state.cfg().console.restart_apply_mode == crate::config::RestartApplyMode::Auto { + let shutdown = state.shutdown.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(750)).await; + tracing::info!( + "config auto-apply: self-restart to apply a restart-required change" + ); + shutdown.notify_one(); + }); + } + Ok(Json(PutConfigResponse { + applied: "restart-required".into(), + restart_pending: true, + })) + } else { + // Hot-swap ONLY the runtime-safe patch onto the currently-RUNNING config. + // Rebuilding from the full persisted overlay (the old behavior) would drag a + // restart-required section persisted by an EARLIER PUT into the live config + // while the boot-built clients still run the old value — the exact desync + // RuntimeClass exists to prevent. Every field in this patch is runtime-safe + // (any_restart == false), so applying it to the running config is sound and + // re-validates the change. + let running = state.cfg(); + let hot = running + .apply_overlay(&req.patch) + .map_err(|e| AppError::Internal(format!("hot-swap re-validate failed: {e}")))?; + drop(running); + // A restart-required change from an EARLIER PUT may still be pending; report it + // honestly rather than clearing the signal just because THIS edit was live. + let restart_pending = overlay_restart_pending(&state.config_base, &merged, &hot); + state.config.store(std::sync::Arc::new(hot)); + Ok(Json(PutConfigResponse { + applied: "runtime".into(), + restart_pending, + })) + } +} + +/// Recursively merge `patch` into `base` (JSON objects); a `null` in `patch` means +/// "leave unset" and is skipped, so a sparse overlay patch only sets present fields. +fn json_merge_skip_null(base: &mut serde_json::Value, patch: &serde_json::Value) { + match (base, patch) { + (serde_json::Value::Object(b), serde_json::Value::Object(p)) => { + for (k, pv) in p { + if pv.is_null() { + continue; + } + json_merge_skip_null(b.entry(k.clone()).or_insert(serde_json::Value::Null), pv); + } + } + (b, p) => *b = p.clone(), + } +} + +// ── Invites (POST + GET /admin/invites) ─────────────────────────────────────── + +#[derive(serde::Deserialize)] +pub struct MintInvitesRequest { + #[serde(default)] + count: Option, + #[serde(default)] + label: Option, +} + +#[derive(serde::Serialize)] +pub struct MintInvitesResponse { + /// RAW single-use codes — shown ONCE (only the hash is stored, unrecoverable). + codes: Vec, +} + +#[instrument(skip(state, headers, req))] +pub async fn admin_invites_mint( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let ctx = admin_guard(&state, &headers).await?; + let secret = state.cfg().admin.key_integrity_secret.clone(); + let count = req.count.unwrap_or(1).clamp(1, 1000); + let audit = NewAdminAudit { + action: "invites_minted".into(), + actor_kind: "admin".into(), + actor_pubkey_prefix: Some(pubkey_prefix(&ctx.pubkey)), + target: req.label.clone(), + details: Some(serde_json::json!({ "count": count })), + ip_address: None, + }; + let codes = state + .db + .mint_invites_audited(count, req.label.as_deref(), &audit, &secret) + .await?; + Ok(Json(MintInvitesResponse { codes })) +} + +#[derive(serde::Serialize)] +pub struct InviteView { + /// Non-secret: first 12 hex chars of the code hash (operator eyeball only). + code_prefix: String, + label: Option, + created_at: String, + /// "unused" | "used" | "expired". + status: String, +} + +#[derive(serde::Serialize)] +pub struct InvitesListResponse { + invites: Vec, + unused_count: i64, +} + +#[instrument(skip(state, headers))] +pub async fn admin_invites_list( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + admin_guard(&state, &headers).await?; + let rows = state.db.list_invite_codes(200, 0).await?; + let now = Utc::now(); + let invites = rows + .into_iter() + .map(|r| { + let status = if r.used_at.is_some() { + "used" + } else if r.expires_at.map(|e| e <= now).unwrap_or(false) { + "expired" + } else { + "unused" + }; + InviteView { + code_prefix: r.code_hash.chars().take(12).collect(), + label: r.label, + created_at: r.created_at.to_rfc3339(), + status: status.into(), + } + }) + .collect(); + let unused_count = state.db.unused_invite_code_count().await?; + Ok(Json(InvitesListResponse { + invites, + unused_count, + })) +} + +// ── Operator console SPA (embedded, served at /admin) ───────────────────────── +// The Preact console built from admin-ui/ and embedded via rust-embed (single-binary +// self-host, no separate deploy). Served UNAUTHENTICATED (no admin_guard) — the shell +// loads without a session and runs the NIP-98 login client-side against the guarded +// JSON routes. Only exact /admin, /admin/, /admin/assets/*path are added, so the JSON +// routes (/admin/keys, /admin/config, ...) are never shadowed. + +#[derive(rust_embed::RustEmbed)] +#[folder = "admin-ui/dist/"] +struct AdminAssets; + +pub async fn admin_index() -> Response { + match AdminAssets::get("index.html") { + Some(f) => ( + [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + f.data.into_owned(), + ) + .into_response(), + None => ( + StatusCode::NOT_FOUND, + "operator console not built (run: npm --prefix admin-ui run build)", + ) + .into_response(), + } +} + +pub async fn admin_asset(Path(path): Path) -> Response { + match AdminAssets::get(&format!("assets/{path}")) { + Some(f) => { + let mime = if path.ends_with(".js") || path.ends_with(".mjs") { + "application/javascript; charset=utf-8" + } else if path.ends_with(".css") { + "text/css; charset=utf-8" + } else if path.ends_with(".svg") { + "image/svg+xml" + } else if path.ends_with(".woff2") { + "font/woff2" + } else if path.ends_with(".png") { + "image/png" + } else if path.ends_with(".json") { + "application/json" + } else { + "application/octet-stream" + }; + ( + [(axum::http::header::CONTENT_TYPE, mime)], + f.data.into_owned(), + ) + .into_response() + } + None => StatusCode::NOT_FOUND.into_response(), + } +} + pub fn routes() -> Router> { Router::new() + .route("/admin", get(admin_index)) + .route("/admin/", get(admin_index)) + .route("/admin/assets/*path", get(admin_asset)) .route("/admin/auth/challenge", post(admin_challenge)) .route("/admin/auth/verify", post(admin_verify)) .route("/admin/auth/refresh", post(admin_refresh)) @@ -749,11 +1133,91 @@ pub fn routes() -> Router> { .route("/admin/keys", post(admin_keys_add).get(admin_keys_list)) .route("/admin/keys/:id", delete(admin_keys_revoke)) .route("/admin/keys/:id/rotate", post(admin_keys_rotate)) + .route("/admin/config", get(admin_config_get).put(admin_config_put)) + .route( + "/admin/invites", + post(admin_invites_mint).get(admin_invites_list), + ) } #[cfg(test)] mod tests { - use super::host_allowed; + use super::{host_allowed, AdminAssets}; + + // The Operator Console SPA must be built into admin-ui/dist/ and embedded at + // compile time (rust-embed). If admin-ui was never built, index.html is absent + // and /admin serves nothing — this catches that before it reaches an operator. + #[test] + fn admin_console_is_embedded() { + let index = AdminAssets::get("index.html").expect("admin-ui/dist/index.html embedded"); + let html = std::str::from_utf8(&index.data).expect("index.html is utf-8"); + assert!( + html.contains("Smirk Operator Console"), + "console title missing" + ); + // Vite rewrites asset URLs to the /admin/ base; the served route matches. + assert!( + html.contains("/admin/assets/"), + "asset base not rewritten to /admin/" + ); + assert!( + AdminAssets::iter().any(|p| p.starts_with("assets/")), + "no built JS/CSS assets embedded", + ); + } + + // Exercise the real serve path (routing + content-type + body) without a DB: + // admin_index / admin_asset are stateless, so a tiny router reproduces exactly + // what an operator's browser hits at GET /admin and GET /admin/assets/.js. + #[tokio::test] + async fn admin_console_serves_over_http() { + use axum::{body::Body, routing::get, Router}; + use tower::ServiceExt; + + let app: Router = Router::new() + .route("/admin", get(super::admin_index)) + .route("/admin/assets/*path", get(super::admin_asset)); + + // GET /admin -> the console HTML. + let res = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri("/admin") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::OK); + assert_eq!(res.headers()["content-type"], "text/html; charset=utf-8"); + let body = axum::body::to_bytes(res.into_body(), 1 << 20) + .await + .unwrap(); + assert!(std::str::from_utf8(&body) + .unwrap() + .contains("Smirk Operator Console")); + + // GET /admin/assets/ -> served with a sane content-type. + let asset = AdminAssets::iter() + .find(|p| p.starts_with("assets/") && p.ends_with(".js")) + .expect("a built JS asset exists"); + let uri = format!("/admin/{asset}"); // asset already carries the "assets/" prefix + let res = app + .oneshot( + axum::http::Request::builder() + .uri(&uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::OK); + assert_eq!( + res.headers()["content-type"], + "application/javascript; charset=utf-8", + ); + } #[test] fn host_allowlist() { diff --git a/src/api/auth.rs b/src/api/auth.rs index 9dd6a16..e441f33 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -133,9 +133,17 @@ fn parse_asset(asset: &str) -> Result { /// Parse the wire key list into the `(AssetType, public_key, public_spend_key)` /// tuples the atomic-registration DB path binds directly. A bad asset is a 400. -fn parse_keys(keys: &[AssetPublicKey]) -> Result)>, AppError> { +fn parse_keys( + keys: &[AssetPublicKey], +) -> Result)>, AppError> { keys.iter() - .map(|k| Ok((parse_asset(&k.asset)?, k.public_key.clone(), k.public_spend_key.clone()))) + .map(|k| { + Ok(( + parse_asset(&k.asset)?, + k.public_key.clone(), + k.public_spend_key.clone(), + )) + }) .collect() } @@ -170,22 +178,18 @@ fn validate_username(username: &str) -> Result<(), AppError> { /// validation only requires `ALTCHA_HMAC_KEY` when the feature is enabled). When /// disabled we never call `verify_payload`, so the empty key is never used. fn pow_applies(state: &AppState, pubkey_hash_lc: &str) -> bool { - state.config.pow.enabled && crate::core::pow::required_for(&state.config.pow, pubkey_hash_lc) + state.cfg().pow.enabled && crate::core::pow::required_for(&state.cfg().pow, pubkey_hash_lc) } /// The canonical absolute URL a NIP-98 token must bind for `path` (the value of /// the event's `u` tag). Built from `config.identity.public_api_url`, never the /// request Host. Fail closed when unset (Nostr identity is disabled). fn nip98_url(state: &AppState, path: &str) -> Result { - let base = state - .config - .identity - .public_api_url - .as_deref() - .ok_or_else(|| { - warn!("Nostr endpoint reached but PUBLIC_API_URL is unset; refusing"); - AppError::AuthError("Nostr authentication is not enabled".into()) - })?; + let cfg = state.cfg(); + let base = cfg.identity.public_api_url.as_deref().ok_or_else(|| { + warn!("Nostr endpoint reached but PUBLIC_API_URL is unset; refusing"); + AppError::AuthError("Nostr authentication is not enabled".into()) + })?; Ok(format!("{}{}", base.trim_end_matches('/'), path)) } @@ -215,7 +219,7 @@ async fn issue_session( let refresh_token_hash = hash_refresh_token( &pair.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); let expires_at = Utc::now() + state.sessions.refresh_token_expiry(); @@ -276,7 +280,7 @@ async fn upsert_all_keys( pub async fn pow_challenge( State(state): State>, ) -> Result, AppError> { - let challenge = crate::core::pow::issue_challenge(&state.config.pow)?; + let challenge = crate::core::pow::issue_challenge(&state.cfg().pow)?; Ok(Json(challenge)) } @@ -540,7 +544,10 @@ pub async fn extension_register( .db .create_user_consuming_gates( gates.invite_code_hash.as_deref(), - gates.payment.as_ref().map(|(id, pkh)| (id.as_str(), pkh.as_str())), + gates + .payment + .as_ref() + .map(|(id, pkh)| (id.as_str(), pkh.as_str())), NewUser { username: req.username.clone(), pubkey_hash: Some(pubkey_hash.clone()), @@ -622,13 +629,13 @@ fn enforce_pow( .into(), ) })?; - crate::core::pow::verify_payload(&state.config.pow, solution)?; + crate::core::pow::verify_payload(&state.cfg().pow, solution)?; info!(pow = "ok", "PoW solution accepted (new user)"); } else if let Some(solution) = solution { // Supplied but not required: verify anyway (clear error on malformed), // but only if the feature is enabled so we never touch an empty key. - if state.config.pow.enabled { - crate::core::pow::verify_payload(&state.config.pow, solution)?; + if state.cfg().pow.enabled { + crate::core::pow::verify_payload(&state.cfg().pow, solution)?; } } Ok(()) @@ -699,9 +706,14 @@ struct GatedRegistration { /// Require an invite code and return its hash, erroring if the gate needs one but /// none was supplied (the presence check + literal the old `enforce_invite` used). fn require_invite_hash(invite_code: Option<&str>) -> Result { - let code = invite_code.map(str::trim).filter(|c| !c.is_empty()).ok_or_else(|| { - AppError::ValidationError("An invite code is required to register on this instance.".into()) - })?; + let code = invite_code + .map(str::trim) + .filter(|c| !c.is_empty()) + .ok_or_else(|| { + AppError::ValidationError( + "An invite code is required to register on this instance.".into(), + ) + })?; Ok(crate::core::invite::hash_invite_code(code)) } @@ -723,15 +735,18 @@ async fn plan_gate_consume( .is_some_and(|s| !s.is_empty()); match plan_gates( - state.config.registration.gate_mode, - state.config.registration.require_invite, - state.config.registration.payment.require_payment, + state.cfg().registration.gate_mode, + state.cfg().registration.require_invite, + state.cfg().registration.payment.require_payment, has_invite, has_payment, ) { - GatePlan::Open => Ok(GatedRegistration { invite_code_hash: None, payment: None }), + GatePlan::Open => Ok(GatedRegistration { + invite_code_hash: None, + payment: None, + }), GatePlan::All => { - let invite_code_hash = if state.config.registration.require_invite { + let invite_code_hash = if state.cfg().registration.require_invite { Some(require_invite_hash(invite_code)?) } else { None @@ -776,7 +791,7 @@ async fn verify_payment_settled( pubkey_hash: &str, payment_invoice_id: Option<&str>, ) -> Result, AppError> { - if returning || !state.config.registration.payment.require_payment { + if returning || !state.cfg().registration.payment.require_payment { return Ok(None); } // Gate on but no provider built — config validation prevents this, so it is @@ -932,7 +947,7 @@ pub async fn payment_invoice( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let cfg = &state.config.registration.payment; + let cfg = &state.cfg().registration.payment; if !cfg.require_payment { return Err(AppError::ValidationError( "This instance does not require registration payment.".into(), @@ -1175,7 +1190,7 @@ pub async fn refresh_token( let token_hash = hash_refresh_token( &req.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); let session = state .db @@ -1256,7 +1271,7 @@ pub async fn logout( ) -> Result, AppError> { let token_hash = hash_refresh_token( &req.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); if let Some(session) = state.db.get_session_by_token_hash(&token_hash).await? { let _ = state.db.revoke_session(session.id).await; @@ -1711,7 +1726,10 @@ pub async fn nostr_register( .db .create_user_consuming_gates( gates.invite_code_hash.as_deref(), - gates.payment.as_ref().map(|(id, pkh)| (id.as_str(), pkh.as_str())), + gates + .payment + .as_ref() + .map(|(id, pkh)| (id.as_str(), pkh.as_str())), NewUser { username: req.username.clone(), pubkey_hash: None, @@ -1771,7 +1789,10 @@ pub fn routes() -> Router> { .route("/auth/logout", post(logout)) .route("/auth/me", get(get_me)) .route("/auth/nostr", post(nostr_login)) - .route("/auth/nostr/register-challenge", get(nostr_register_challenge)) + .route( + "/auth/nostr/register-challenge", + get(nostr_register_challenge), + ) .route("/auth/nostr/register", post(nostr_register)) .route("/auth/nostr/link-challenge", get(nostr_link_challenge)) .route("/auth/nostr/link", post(nostr_link)) @@ -1832,7 +1853,10 @@ mod tests { #[test] fn any_mode_single_gate_is_just_that_gate() { assert_eq!(plan_gates(Any, true, false, false, false), GatePlan::Invite); - assert_eq!(plan_gates(Any, false, true, false, false), GatePlan::Payment); + assert_eq!( + plan_gates(Any, false, true, false, false), + GatePlan::Payment + ); } #[test] diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index d64d885..0481d1c 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -354,7 +354,7 @@ fn tips_advertised(config: &Config) -> bool { )] #[instrument(skip(state))] pub async fn capabilities(State(state): State>) -> Json { - Json(effective_capabilities(&state.config)) + Json(effective_capabilities(&state.cfg())) } /// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth). diff --git a/src/api/erasure.rs b/src/api/erasure.rs index 42b0174..94fc8a5 100644 --- a/src/api/erasure.rs +++ b/src/api/erasure.rs @@ -41,7 +41,7 @@ const PURPOSE_EXPORT: &str = "erasure_export"; // ── helpers ────────────────────────────────────────────────────────────────── fn ensure_enabled(state: &AppState) -> Result<(), AppError> { - if state.config.retention.erasure_enabled { + if state.cfg().retention.erasure_enabled { Ok(()) } else { Err(AppError::NotFound("erasure is not enabled".into())) @@ -51,8 +51,8 @@ fn ensure_enabled(state: &AppState) -> Result<(), AppError> { /// The signed-action `u`-tag base for `path` (e.g. `/account/erasure`), from /// `PUBLIC_API_URL` (which already includes the `/api/v1` prefix). fn erasure_url(state: &AppState, path: &str) -> Result { - let base = state - .config + let cfg = state.cfg(); + let base = cfg .identity .public_api_url .as_deref() @@ -64,8 +64,8 @@ fn subject_hash(npub: &str) -> String { hex::encode(Sha256::digest(npub.as_bytes())) } -fn integrity_secret(state: &AppState) -> &str { - &state.config.admin.key_integrity_secret +fn integrity_secret(state: &AppState) -> String { + state.cfg().admin.key_integrity_secret.clone() } #[derive(Debug, Deserialize)] @@ -191,7 +191,7 @@ pub async fn request_erasure( Json(body): Json, ) -> Result, AppError> { ensure_enabled(&state)?; - let grace = state.config.retention.grace_period_hours as i64; + let grace = state.cfg().retention.grace_period_hours as i64; let (pubkey, user) = prove(&state, &body, PURPOSE_REQUEST, "/account/erasure", None).await?; // Constant-shape across present/absent AND across repeated calls: a STABLE @@ -209,13 +209,13 @@ pub async fn request_erasure( .db .record_admin_audit( &audit("account_erasure_requested", &req.id), - integrity_secret(&state), + &integrity_secret(&state), ) .await; info!(erasure_id = %req.id, "erasure requested"); req.id.to_string() } - None => synthetic_erasure_id(&pubkey, integrity_secret(&state)), + None => synthetic_erasure_id(&pubkey, &integrity_secret(&state)), }; Ok(Json(ErasureRequestResponse { erasure_id, @@ -244,7 +244,7 @@ pub async fn confirm_erasure( .await?; let user = user.ok_or_else(|| AppError::NotFound("no such erasure request".into()))?; - let grace = state.config.retention.grace_period_hours as i64; + let grace = state.cfg().retention.grace_period_hours as i64; let req = state .db .confirm_erasure_request(id, user.id, grace) @@ -256,7 +256,7 @@ pub async fn confirm_erasure( .db .record_admin_audit( &audit("account_erasure_confirmed", &req.id), - integrity_secret(&state), + &integrity_secret(&state), ) .await; info!(erasure_id = %req.id, "erasure confirmed"); @@ -295,7 +295,7 @@ pub async fn cancel_erasure( .db .record_admin_audit( &audit("account_erasure_cancelled", &req.id), - integrity_secret(&state), + &integrity_secret(&state), ) .await; Ok(Json(StatusResponse { @@ -359,7 +359,7 @@ pub async fn export( details: None, ip_address: None, }, - integrity_secret(&state), + &integrity_secret(&state), ) .await; Ok(Json(ExportResponse { @@ -388,11 +388,11 @@ fn audit(action: &str, erasure_id: &Uuid) -> NewAdminAudit { #[instrument(skip(state))] pub async fn run_erasure_sweep(state: &Arc, batch: i64) -> Result { let due = state.db.due_erasure_requests(batch).await?; - let purge = state.config.retention.purge_login_events; + let purge = state.cfg().retention.purge_login_events; let secret = integrity_secret(state); let mut done = 0; for req in due { - match state.db.execute_erasure(req.id, purge, secret).await { + match state.db.execute_erasure(req.id, purge, &secret).await { Ok(true) => done += 1, // false = another node/sweep already claimed it, or it was cancelled. Ok(false) => {} diff --git a/src/api/landing.rs b/src/api/landing.rs index b92642e..22c3c85 100644 --- a/src/api/landing.rs +++ b/src/api/landing.rs @@ -92,23 +92,19 @@ pub fn build_server_info(config: &Config) -> ServerInfo { /// is off (matching an unmatched route — the `{error,code}` envelope would itself /// hint the route exists, so it is deliberately not used here). pub async fn server_info(State(state): State>) -> Response { - if !state.config.landing.enabled { + if !state.cfg().landing.enabled { return StatusCode::NOT_FOUND.into_response(); } - Json(build_server_info(&state.config)).into_response() + Json(build_server_info(&state.cfg())).into_response() } /// `GET /` — minimal HTML rendered from the read model; bare `404` when off. pub async fn root(State(state): State>) -> Response { - if !state.config.landing.enabled { + if !state.cfg().landing.enabled { return StatusCode::NOT_FOUND.into_response(); } - let title = state - .config - .landing - .title - .as_deref() - .unwrap_or("smirk-backend-core"); + let cfg = state.cfg(); + let title = cfg.landing.title.as_deref().unwrap_or("smirk-backend-core"); let t = html_escape(title); let html = format!( "\ diff --git a/src/api/middleware.rs b/src/api/middleware.rs index 47c589b..11de4c4 100644 --- a/src/api/middleware.rs +++ b/src/api/middleware.rs @@ -69,7 +69,7 @@ pub fn client_ip(state: &AppState, headers: &HeaderMap, peer: SocketAddr) -> IpA let peer_ip = peer.ip(); let peer_is_trusted = state - .config + .cfg() .trusted_proxies .iter() .any(|net| net.contains(peer_ip)); diff --git a/src/api/nip05.rs b/src/api/nip05.rs index 5c1856a..bad5d78 100644 --- a/src/api/nip05.rs +++ b/src/api/nip05.rs @@ -108,7 +108,7 @@ pub async fn well_known_nostr( let name = raw.to_lowercase(); if let Some(user) = state.db.get_user_by_username(&name).await? { if let Some(pubkey) = user.nostr_pubkey { - relays.insert(pubkey.clone(), relay_hints(&state.config)); + relays.insert(pubkey.clone(), relay_hints(&state.cfg())); names.insert(name, pubkey); } } diff --git a/src/api/premium.rs b/src/api/premium.rs index 9679d07..2e0f0d3 100644 --- a/src/api/premium.rs +++ b/src/api/premium.rs @@ -52,7 +52,7 @@ pub struct StatusResp { /// Reject when the premium tier is not enabled on this instance. fn ensure_enabled(state: &AppState) -> Result<(), AppError> { - if state.config.premium.enabled { + if state.cfg().premium.enabled { Ok(()) } else { Err(AppError::ValidationError( @@ -83,8 +83,8 @@ pub async fn invoice( let user_id = extract_user_id_from_token(&state, &headers).await?; ensure_enabled(&state)?; - let plan = state - .config + let cfg = state.cfg(); + let plan = cfg .premium .plans .iter() @@ -106,8 +106,8 @@ pub async fn invoice( .as_ref() .ok_or_else(|| AppError::Internal("payment provider not configured".into()))?; - let pay = &state.config.registration.payment; - let currency = state.config.premium.currency.clone(); + let pay = &state.cfg().registration.payment; + let currency = state.cfg().premium.currency.clone(); let invoice = provider .create_invoice(&InvoiceRequest { amount: plan.amount.clone(), diff --git a/src/api/prices.rs b/src/api/prices.rs index 7f1dee6..941c2ff 100644 --- a/src/api/prices.rs +++ b/src/api/prices.rs @@ -37,7 +37,7 @@ pub struct PricesResponse { )] #[instrument(skip(state))] pub async fn prices(State(state): State>) -> Result, AppError> { - if !state.config.features.prices { + if !state.cfg().features.prices { return Err(AppError::NotFound( "price feed is not enabled on this server".into(), )); diff --git a/src/api/tips.rs b/src/api/tips.rs index ab86119..0792c8b 100644 --- a/src/api/tips.rs +++ b/src/api/tips.rs @@ -160,7 +160,7 @@ pub struct ClawbackTipResponse { /// Reject when tips are not enabled on this instance. fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> { - if state.config.features.tips { + if state.cfg().features.tips { Ok(()) } else { Err(AppError::ValidationError( @@ -171,7 +171,7 @@ fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> { /// Whether `asset` is a tip-capable chain enabled on this instance. fn supported_tip_asset(state: &AppState, asset: &str) -> bool { - let c = &state.config.features.chains; + let c = &state.cfg().features.chains; match asset { "btc" => c.btc, "ltc" => c.ltc, @@ -321,7 +321,9 @@ pub async fn create_social_tip( let encrypted_key: Option> = match req.encrypted_key.as_deref() { Some(h) if !h.is_empty() => { if h.len() > MAX_ENCRYPTED_KEY_HEX { - return Err(AppError::ValidationError("encrypted_key is too large.".into())); + return Err(AppError::ValidationError( + "encrypted_key is too large.".into(), + )); } Some( hex::decode(h) @@ -382,7 +384,7 @@ pub async fn create_social_tip( }; let share_url = state - .config + .cfg() .tip_share_base .as_ref() .map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id)); @@ -560,10 +562,14 @@ pub async fn attach_funding( // DB length-overflow 500 (funding_txid is VARCHAR(128)). let funding_txid = req.funding_txid.trim(); if funding_txid.is_empty() { - return Err(AppError::ValidationError("funding_txid is required.".into())); + return Err(AppError::ValidationError( + "funding_txid is required.".into(), + )); } if funding_txid.len() > 128 { - return Err(AppError::ValidationError("funding_txid is too long.".into())); + return Err(AppError::ValidationError( + "funding_txid is too long.".into(), + )); } // The DB fn returns Ok(row) / NotFound / ValidationError; just surface it. @@ -573,7 +579,7 @@ pub async fn attach_funding( .await?; let share_url = state - .config + .cfg() .tip_share_base .as_ref() .map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id)); @@ -716,10 +722,7 @@ pub fn routes() -> Router> { .route("/tips/social/claimable", get(get_claimable_social_tips)) .route("/tips/social/:tip_id/public", get(get_public_social_tip)) .route("/tips/social/:tip_id/cancel", post(cancel_social_tip)) - .route( - "/tips/social/:tip_id/attach-funding", - post(attach_funding), - ) + .route("/tips/social/:tip_id/attach-funding", post(attach_funding)) .route("/tips/social/:tip_id/claim", post(claim_social_tip)) .route("/tips/social/:tip_id/confirm-sweep", post(confirm_sweep)) .route("/tips/social/:tip_id/clawback", post(clawback_social_tip)) diff --git a/src/api/wallet/grin.rs b/src/api/wallet/grin.rs index 509230c..51395dc 100644 --- a/src/api/wallet/grin.rs +++ b/src/api/wallet/grin.rs @@ -202,8 +202,8 @@ pub async fn scan( // grin-wallet fallback, so it is enforced before either scan path is chosen. if let Some(h) = req.start_height { let tip = grin_tip(&state).await?; - state.config.restore.enforce("grin", h, tip)?; - state.config.restore.enforce_restore_pow( + state.cfg().restore.enforce("grin", h, tip)?; + state.cfg().restore.enforce_restore_pow( "grin", &req.rewind_hash, h, diff --git a/src/api/wallet/grin_relay.rs b/src/api/wallet/grin_relay.rs index 2524747..a504b44 100644 --- a/src/api/wallet/grin_relay.rs +++ b/src/api/wallet/grin_relay.rs @@ -63,7 +63,7 @@ const RELAY_TTL_DAYS: i64 = 7; /// 400 unless the relay feature is enabled on this server. fn ensure_enabled(state: &AppState) -> Result<(), AppError> { - if state.config.features.grin_relay { + if state.cfg().features.grin_relay { Ok(()) } else { Err(AppError::NotFound( diff --git a/src/api/wallet/xmr_wow.rs b/src/api/wallet/xmr_wow.rs index aa5e9eb..651d7e3 100644 --- a/src/api/wallet/xmr_wow.rs +++ b/src/api/wallet/xmr_wow.rs @@ -120,6 +120,14 @@ pub struct ConfirmationsRequest { // ── response DTOs ───────────────────────────────────────────────────────────── +/// Serialize a `u64` atomic amount as a decimal STRING. XMR/WOW amounts can exceed +/// 2^53, which a JavaScript `number` cannot hold exactly, so every atomic amount +/// crosses the wire as a string; the wallet BigInt-parses it. Heights, indices, +/// counts, and fee params stay numbers (always well under 2^53). +fn ser_u64_str(v: &u64, s: S) -> Result { + s.serialize_str(&v.to_string()) +} + /// Balance + scan state, as a **verification passthrough**. The backend holds no /// spend key, so it cannot net out spends; the wallet computes the true spendable /// balance client-side: `total_received − sum(spent_outputs it verifies with the @@ -129,10 +137,16 @@ pub struct ConfirmationsRequest { #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct LwsBalanceResponse { pub asset: String, + #[serde(serialize_with = "ser_u64_str")] + #[schema(value_type = String, example = "12345000000000")] pub total_received: u64, + #[serde(serialize_with = "ser_u64_str")] + #[schema(value_type = String)] pub locked_balance: u64, /// Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool /// rows (the monero-lws mempool feature); never negative. + #[serde(serialize_with = "ser_u64_str")] + #[schema(value_type = String)] pub pending_balance: u64, pub start_height: u64, pub scanned_height: u64, @@ -146,6 +160,8 @@ pub struct LwsBalanceResponse { /// A candidate spent output (verify with the spend key before trusting). #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SpentOutputDto { + #[serde(serialize_with = "ser_u64_str")] + #[schema(value_type = String)] pub amount: u64, pub key_image: String, pub tx_pub_key: String, @@ -159,8 +175,12 @@ pub struct TxDto { pub hash: String, pub height: u64, pub timestamp: String, + #[serde(serialize_with = "ser_u64_str")] + #[schema(value_type = String)] pub total_received: u64, /// "Possible" sent — candidate spends, not authoritative. + #[serde(serialize_with = "ser_u64_str")] + #[schema(value_type = String)] pub total_sent: u64, pub mempool: bool, pub unlock_time: u64, @@ -177,6 +197,8 @@ pub struct LwsHistoryResponse { /// An unspent output for spend construction. #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UnspentOutputDto { + #[serde(serialize_with = "ser_u64_str")] + #[schema(value_type = String)] pub amount: u64, pub public_key: String, pub tx_pub_key: String, @@ -320,8 +342,8 @@ pub async fn register( // Restore: gate the scan depth against this instance's policy (the // backfill cost lands on our LWS). `None` (create) needs no check. let tip = client.get_blockchain_height().await?; - state.config.restore.enforce(&asset, h, tip)?; - state.config.restore.enforce_restore_pow( + state.cfg().restore.enforce(&asset, h, tip)?; + state.cfg().restore.enforce_restore_pow( &asset, &req.address, h, @@ -609,3 +631,52 @@ pub fn routes() -> Router> { .route("/wallet/lws/height", post(height)) .route("/wallet/lws/confirmations", post(confirmations)) } + +#[cfg(test)] +mod amount_wire_tests { + use super::*; + + // XMR/WOW atomic amounts can exceed 2^53, so they must cross the wire as decimal + // STRINGS (a JS number would round them). This locks that contract. + const BIG: u64 = 9_007_199_254_740_993; // 2^53 + 1 + + #[test] + fn unspent_output_amount_serializes_as_string() { + let dto = UnspentOutputDto { + amount: BIG, + public_key: "aa".into(), + tx_pub_key: "bb".into(), + index: 0, + global_index: 1, + height: 2, + timestamp: "t".into(), + tx_hash: "h".into(), + rct: String::new(), + spend_key_images: vec![], + }; + let v = serde_json::to_value(&dto).unwrap(); + assert_eq!(v["amount"], serde_json::json!("9007199254740993")); + assert!(v["amount"].is_string(), "amount must be a JSON string"); + // A non-amount field stays a number. + assert!(v["global_index"].is_number()); + } + + #[test] + fn balance_amounts_serialize_as_strings() { + let bal = LwsBalanceResponse { + asset: "xmr".into(), + total_received: BIG, + locked_balance: 0, + pending_balance: 0, + start_height: 100, + scanned_height: 100, + blockchain_height: 100, + transaction_count: 1, + spent_outputs: vec![], + }; + let v = serde_json::to_value(&bal).unwrap(); + assert_eq!(v["total_received"], serde_json::json!("9007199254740993")); + assert!(v["locked_balance"].is_string() && v["pending_balance"].is_string()); + assert!(v["blockchain_height"].is_number(), "heights stay numbers"); + } +} diff --git a/src/api/website.rs b/src/api/website.rs index f469d2d..91dde8d 100644 --- a/src/api/website.rs +++ b/src/api/website.rs @@ -297,7 +297,7 @@ pub async fn website_verify( .create_token_pair(user.id, Platform::Web, session_id)?; let refresh_token_hash = hash_refresh_token( &pair.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); let expires_at = Utc::now() + state.sessions.refresh_token_expiry(); state diff --git a/src/config.rs b/src/config.rs index 44b71b4..8a729ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,7 +43,10 @@ fn looks_placeholder(s: &str) -> bool { /// never leaves the host (e.g. a payment processor reached over an SSH tunnel or /// a local reverse proxy), so the https requirement is relaxed for it. fn is_loopback_url(s: &str) -> bool { - match url::Url::parse(s).ok().and_then(|u| u.host().map(|h| h.to_owned())) { + match url::Url::parse(s) + .ok() + .and_then(|u| u.host().map(|h| h.to_owned())) + { Some(url::Host::Ipv4(ip)) => ip.is_loopback(), Some(url::Host::Ipv6(ip)) => ip.is_loopback(), Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), @@ -171,6 +174,32 @@ pub struct Config { pub messaging: MessagingConfig, pub premium: PremiumConfig, pub feed: FeedConfig, + pub console: ConsoleConfig, +} + +/// How a restart-required config change is applied. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RestartApplyMode { + /// Persist + flag; the operator applies it with a graceful restart. + Manual, + /// The instance self-restarts after saving (the console warns first). Requires the + /// service to be brought back on exit (systemd `Restart=always`). + Auto, +} + +impl RestartApplyMode { + pub fn as_str(&self) -> &'static str { + match self { + RestartApplyMode::Manual => "manual", + RestartApplyMode::Auto => "auto", + } + } +} + +/// Operator-console preferences (runtime-safe, DB-overridable via /admin/config). +#[derive(Clone)] +pub struct ConsoleConfig { + pub restart_apply_mode: RestartApplyMode, } #[derive(Clone)] @@ -912,6 +941,12 @@ impl Config { allowlist_npubs: env_list("FEED_ALLOWLIST_NPUBS"), extra_relays: env_list("FEED_EXTRA_RELAYS"), }, + console: ConsoleConfig { + restart_apply_mode: match env_or("RESTART_APPLY_MODE", "manual").as_str() { + "auto" => RestartApplyMode::Auto, + _ => RestartApplyMode::Manual, + }, + }, }; cfg.validate()?; @@ -1020,7 +1055,10 @@ impl Config { // booting a feed that can never populate (which would still advertise // prices:true and silently serve nothing). if self.features.prices { - if !matches!(self.features.prices_provider.as_str(), "coingecko" | "kraken") { + if !matches!( + self.features.prices_provider.as_str(), + "coingecko" | "kraken" + ) { return Err(cfg_err(format!( "PRICES_PROVIDER {:?} is not supported; supported: coingecko, kraken", self.features.prices_provider @@ -1268,7 +1306,8 @@ impl Config { if p.provider_url.trim().is_empty() { return Err(cfg_err("PREMIUM_ENABLED needs PAYMENT_PROVIDER_URL")); } - if prod && !p.provider_url.starts_with("https://") && !is_loopback_url(&p.provider_url) { + if prod && !p.provider_url.starts_with("https://") && !is_loopback_url(&p.provider_url) + { return Err(cfg_err( "PAYMENT_PROVIDER_URL must be https:// in production (loopback exempt)", )); @@ -1502,6 +1541,9 @@ mod tests { allowlist_npubs: Vec::new(), extra_relays: Vec::new(), }, + console: ConsoleConfig { + restart_apply_mode: RestartApplyMode::Manual, + }, } } @@ -1520,6 +1562,117 @@ mod tests { } } + // ── operator settings overlay (config_overlay.rs) ─────────────────────── + // These live here (not in config_overlay's own test mod) because they need the + // fully-populated `valid()` Config to exercise apply_overlay/editable_overlay. + + #[test] + fn editable_overlay_round_trips_as_identity() { + // Applying the effective overlay back onto the config must be a no-op: it + // proves every field editable_overlay() READS is the same field + // apply_overlay() WRITES. A single mismatched wire diverges the two views. + use crate::config_overlay::SettingsOverlay; + let base = valid(); + let effective = base.editable_overlay(); + let ov: SettingsOverlay = + serde_json::from_value(serde_json::to_value(&effective).unwrap()).unwrap(); + let applied = base.apply_overlay(&ov).expect("identity overlay applies"); + assert_eq!( + serde_json::to_value(applied.editable_overlay()).unwrap(), + serde_json::to_value(&effective).unwrap(), + ); + } + + #[test] + fn editable_overlay_never_exposes_a_secret() { + // The overlay is the read+write surface of the admin plane. No secret may + // ever appear in it — secrets are excluded BY CONSTRUCTION (no overlay + // field maps to one), and this asserts that structurally. + let mut c = valid(); + c.registration.payment.api_key = "LEAKMARK_PAYMENT_APIKEY".into(); + c.pow.hmac_key = "LEAKMARK_ALTCHA_HMAC".into(); + c.admin.jwt_secret = "LEAKMARK_ADMIN_JWT".into(); + c.admin.key_integrity_secret = "LEAKMARK_ADMIN_MAC".into(); + c.auth.jwt_secret = "LEAKMARK_AUTH_JWT".into(); + c.secrets.seed_fingerprint_pepper = "LEAKMARK_SEED_PEPPER".into(); + let json = serde_json::to_string(&c.editable_overlay()).unwrap(); + assert!( + !json.contains("LEAKMARK"), + "a secret leaked into the editable overlay: {json}" + ); + } + + #[test] + fn overlay_gate_mode_rejects_a_typo() { + use crate::config_overlay::SettingsOverlay; + let ov: SettingsOverlay = + serde_json::from_str(r#"{"registration":{"gate_mode":"nonsense"}}"#).unwrap(); + assert!(valid().apply_overlay(&ov).is_err()); + } + + #[test] + fn overlay_toggles_registration_gates() { + use crate::config_overlay::SettingsOverlay; + let ov: SettingsOverlay = + serde_json::from_str(r#"{"registration":{"require_invite":true,"gate_mode":"any"}}"#) + .unwrap(); + let applied = valid() + .apply_overlay(&ov) + .expect("valid registration overlay applies"); + assert!(applied.registration.require_invite); + assert_eq!(applied.registration.gate_mode, GateMode::Any); + } + + #[test] + fn runtime_safe_put_does_not_drag_pending_restart_required_change_live() { + // Reproduces the hot-swap desync: an earlier PUT persists a restart-required + // change (chain toggle) that is NOT yet applied to the running config; a later + // runtime-safe PUT must hot-swap ONLY its own change onto the RUNNING config, + // never rebuild from the full persisted overlay (which would drag the pending + // restart-required change live while boot-built clients still run the old one). + use crate::config_overlay::SettingsOverlay; + let base = valid(); + assert!(!base.features.chains.btc, "precondition: btc off at boot"); + let running = base.clone(); // boot state; the restart-required change is NOT here + + // The FIX: hot-swap applies only the runtime-safe patch to the running config. + let safe_patch: SettingsOverlay = + serde_json::from_str(r#"{"landing":{"title":"hello"}}"#).unwrap(); + let hot = running.apply_overlay(&safe_patch).unwrap(); + assert!( + !hot.features.chains.btc, + "restart-required chain toggle must NOT leak into the runtime-safe hot-swap" + ); + assert_eq!( + hot.landing.title.as_deref(), + Some("hello"), + "runtime-safe change applies" + ); + + // Regression guard: the OLD path (base + full persisted overlay) WOULD drag it. + let buggy_merged: SettingsOverlay = + serde_json::from_str(r#"{"features":{"chain_btc":true},"landing":{"title":"hello"}}"#) + .unwrap(); + let buggy = base.apply_overlay(&buggy_merged).unwrap(); + assert!( + buggy.features.chains.btc, + "sanity: the old rebuild-from-full-overlay path drags the pending change" + ); + } + + #[test] + fn overlay_enabling_pow_without_hmac_key_is_rejected() { + // Fail-closed: turning the PoW gate on via the console with no HMAC key + // (a secret that stays in env) must be rejected at PUT, not boot a disarmed + // gate that /capabilities reports as active. + use crate::config_overlay::SettingsOverlay; + let mut base = valid(); + base.pow.hmac_key = String::new(); + let ov: SettingsOverlay = + serde_json::from_str(r#"{"pow":{"enabled":true,"required":true}}"#).unwrap(); + assert!(base.apply_overlay(&ov).is_err()); + } + #[test] fn valid_config_passes() { assert!(valid().validate().is_ok()); @@ -1589,7 +1742,10 @@ mod tests { c.features.tips = true; c.features.chains.btc = true; c.tip_share_base = None; - assert!(c.validate().is_err(), "tips on without a share base must fail"); + assert!( + c.validate().is_err(), + "tips on without a share base must fail" + ); } #[test] diff --git a/src/config_overlay.rs b/src/config_overlay.rs new file mode 100644 index 0000000..09904ea --- /dev/null +++ b/src/config_overlay.rs @@ -0,0 +1,627 @@ +//! Operator-editable settings overlay — the DB precedence tier under the env-derived +//! [`Config`] (effective = defaults -> env -> DB overlay). A [`SettingsOverlay`] is a +//! SPARSE patch: one `Option<*Overlay>` per editable section, each a struct of +//! `Option` NON-SECRET fields (absent = "leave as env"). Secrets are excluded BY +//! CONSTRUCTION — no overlay field maps to a secret sub-struct — so the admin plane can +//! neither read nor set one. [`Config::apply_overlay`] merges an overlay onto the +//! env-derived config and re-runs the SAME [`Config::validate`] boot uses, so a bad edit +//! fails identically and nothing invalid is ever applied. Pure over `Config`. + +use serde::{Deserialize, Serialize}; + +use crate::config::{Config, GateMode, RestartApplyMode, RestorePolicy}; +use crate::error::AppError; + +/// Sparse patch over the env-derived [`Config`]. Each section is independent; a `None` +/// (absent) section leaves that whole group at its env value. Unknown sections/fields +/// are ignored on load (forward-compat); the PUT path validates before persisting. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct SettingsOverlay { + pub landing: Option, + pub retention: Option, + pub restore: Option, + pub console: Option, + pub registration: Option, + pub pow: Option, + pub features: Option, + pub relay: Option, + pub premium: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct LandingOverlay { + pub enabled: Option, + /// Empty string clears the title (env `None`); any other value sets it. + pub title: Option, + pub expose_version: Option, + pub expose_chains: Option, + pub expose_price_feed: Option, + pub expose_uptime: Option, + pub stats_enabled: Option, + pub stats_cache_hours: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RetentionOverlay { + pub login_events_days: Option, + pub audit_days: Option, + /// Gates a boot-spawned sweep worker -> RESTART-REQUIRED (see `runtime_class`). + pub erasure_enabled: Option, + pub purge_login_events: Option, + pub export_per_day: Option, + pub grace_period_hours: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RestoreOverlay { + /// One of `create-only` | `bounded` | `unlimited`. + pub policy: Option, + pub max_depth_days: Option, + pub pow_free_days: Option, + pub pow_days_per_bit: Option, + pub pow_max_bits: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ConsoleOverlay { + /// How a restart-required config change applies: `manual` | `auto`. + pub restart_apply_mode: Option, +} + +/// Registration gating POLICY knobs. Most are read per-request → runtime-safe; the +/// exception is `require_payment`, which gates the boot-built payment client and is +/// therefore restart-required (see `runtime_class`). The processor WIRING (provider, +/// provider_url, store_id) and the SECRET api_key stay in env by design: they pair +/// with the secret and are set-once infra, so they are deliberately absent here. +/// Toggling `require_payment` on with incomplete wiring is rejected by +/// `Config::validate` at PUT time (fail-closed). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RegistrationOverlay { + pub require_invite: Option, + /// `all` (every enabled gate) | `any` (one-of). PoW is orthogonal. + pub gate_mode: Option, + pub require_payment: Option, + /// Registration price as a decimal string (no float math). + pub payment_amount: Option, + pub payment_currency: Option, + pub payment_confirmations: Option, + pub payment_expires_minutes: Option, +} + +/// Proof-of-work signup gate (read per-request → runtime-safe). The HMAC key is a +/// SECRET and stays in env; enabling the gate without it is rejected by validate. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PowOverlay { + pub enabled: Option, + pub required: Option, + pub cost: Option, +} + +/// Feature flags + per-chain enablement. Each gates a boot-built client or worker +/// (price poller, chain adapters), so these are RESTART-REQUIRED. Enabling a chain +/// with no infra configured is rejected in production by validate (fail-closed). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct FeaturesOverlay { + pub prices: Option, + pub prices_provider: Option, + pub prices_interval_secs: Option, + pub prices_currency: Option, + pub tips: Option, + pub nostr_identity: Option, + pub grin_relay: Option, + pub chain_btc: Option, + pub chain_ltc: Option, + pub chain_xmr: Option, + pub chain_wow: Option, + pub chain_grin: Option, +} + +/// Nostr relay NON-BIND, non-secret scalars. The admission-service BIND address +/// (a registration oracle) and the write-allowlist (a list) are deliberately +/// absent — bind is security-load-bearing and stays in env. RESTART-REQUIRED (the +/// relay/admission service is boot-bound and the URL is advertised at boot). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RelayOverlay { + pub enabled: Option, + /// `bundled` | `external`. + pub mode: Option, + /// Public `ws(s)://` URL clients connect to + we advertise. + pub advertised_url: Option, + /// `inbox-outbox` | `author-allowlist` | `open` | `premium-post`. + pub write_policy: Option, + pub inbound_pow_bits: Option, + pub max_event_bytes: Option, + pub retention_days: Option, +} + +/// Premium subscription master switch + currency. The priced PLANS are a list and +/// stay env-configured until a structured list editor exists. RESTART-REQUIRED +/// (premium is enforced by the boot-bound relay admission service). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PremiumOverlay { + pub enabled: Option, + pub currency: Option, +} + +/// Whether a changed field applies live or needs a restart. Drives BOTH the hot-swap +/// decision and the `GET /admin/config` annotation — the single source of truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeClass { + /// Re-read off the effective config on the next request; hot-swappable. + RuntimeSafe, + /// Captured by a boot-built client/socket/worker; applied on graceful restart. + RestartRequired, +} + +/// Classify one `(section, field)`. Unknown pairs default to `RestartRequired` (the +/// safe default: never hot-swap something whose consumers we haven't reasoned about). +pub fn runtime_class(section: &str, field: &str) -> RuntimeClass { + use RuntimeClass::*; + match (section, field) { + // erasure_enabled gates a boot-spawned sweep worker; a live flip would desync + // the worker from the request gate. + ("retention", "erasure_enabled") => RestartRequired, + ("landing", _) => RuntimeSafe, + ("retention", _) => RuntimeSafe, + ("restore", _) => RuntimeSafe, + ("console", _) => RuntimeSafe, + // require_payment gates the BOOT-built payment client (main.rs: the provider is + // None unless require_payment || premium.enabled at boot), so hot-enabling it + // would 500 every registration until restart — restart-required, like premium. + ("registration", "require_payment") => RestartRequired, + // The other registration gates + PoW are read via `state.cfg()` on the + // registration path (auth.rs / capabilities.rs), so an overlay change lands on + // the next request — hot-swappable. (payment_amount/currency/etc. are read per + // invoice; they only matter once the boot-built client exists.) + ("registration", _) => RuntimeSafe, + ("pow", _) => RuntimeSafe, + // Feature flags / chain enablement / relay / premium all gate boot-built + // clients, workers, or the boot-bound admission service — a live flip would + // desync the running process, so they apply on a graceful restart. + ("features", _) => RestartRequired, + ("relay", _) => RestartRequired, + ("premium", _) => RestartRequired, + _ => RestartRequired, + } +} + +impl Config { + /// Merge a settings overlay onto this (env-derived) config, then re-run the SAME + /// validation boot uses. Returns the candidate effective config, or the identical + /// [`AppError`] boot would raise — so a bad edit is rejected BEFORE anything + /// persists and can never brick the instance. Pure: does not mutate `self`. + pub fn apply_overlay(&self, ov: &SettingsOverlay) -> Result { + let mut c = self.clone(); + + if let Some(l) = &ov.landing { + if let Some(v) = l.enabled { + c.landing.enabled = v; + } + if let Some(v) = &l.title { + c.landing.title = if v.is_empty() { None } else { Some(v.clone()) }; + } + if let Some(v) = l.expose_version { + c.landing.expose_version = v; + } + if let Some(v) = l.expose_chains { + c.landing.expose_chains = v; + } + if let Some(v) = l.expose_price_feed { + c.landing.expose_price_feed = v; + } + if let Some(v) = l.expose_uptime { + c.landing.expose_uptime = v; + } + if let Some(v) = l.stats_enabled { + c.landing.stats_enabled = v; + } + if let Some(v) = l.stats_cache_hours { + c.landing.stats_cache_hours = v; + } + } + + if let Some(r) = &ov.retention { + if let Some(v) = r.login_events_days { + c.retention.login_events_days = v; + } + if let Some(v) = r.audit_days { + c.retention.audit_days = v; + } + if let Some(v) = r.erasure_enabled { + c.retention.erasure_enabled = v; + } + if let Some(v) = r.purge_login_events { + c.retention.purge_login_events = v; + } + if let Some(v) = r.export_per_day { + c.retention.export_per_day = v; + } + if let Some(v) = r.grace_period_hours { + c.retention.grace_period_hours = v; + } + } + + if let Some(r) = &ov.restore { + if let Some(p) = &r.policy { + c.restore.policy = match p.as_str() { + "create-only" => RestorePolicy::CreateOnly, + "bounded" => RestorePolicy::Bounded, + "unlimited" => RestorePolicy::Unlimited, + other => { + return Err(AppError::ValidationError(format!( + "invalid restore.policy {other:?} (want create-only|bounded|unlimited)" + ))) + } + }; + } + if let Some(v) = r.max_depth_days { + c.restore.max_depth_days = v; + } + if let Some(v) = r.pow_free_days { + c.restore.pow_free_days = v; + } + if let Some(v) = r.pow_days_per_bit { + c.restore.pow_days_per_bit = v; + } + if let Some(v) = r.pow_max_bits { + c.restore.pow_max_bits = v; + } + } + + if let Some(cs) = &ov.console { + if let Some(m) = &cs.restart_apply_mode { + c.console.restart_apply_mode = match m.as_str() { + "manual" => RestartApplyMode::Manual, + "auto" => RestartApplyMode::Auto, + other => { + return Err(AppError::ValidationError(format!( + "invalid console.restart_apply_mode {other:?} (want manual|auto)" + ))) + } + }; + } + } + + if let Some(rg) = &ov.registration { + if let Some(v) = rg.require_invite { + c.registration.require_invite = v; + } + if let Some(m) = &rg.gate_mode { + // GateMode maps to a Rust enum, and env parsing is lenient (unknown + // -> All); a console PUT should reject a typo instead of silently + // weakening the gate, so match strictly here. + c.registration.gate_mode = match m.to_lowercase().as_str() { + "all" => GateMode::All, + "any" => GateMode::Any, + other => { + return Err(AppError::ValidationError(format!( + "invalid registration.gate_mode {other:?} (want all|any)" + ))) + } + }; + } + if let Some(v) = rg.require_payment { + c.registration.payment.require_payment = v; + } + if let Some(v) = &rg.payment_amount { + c.registration.payment.amount = v.clone(); + } + if let Some(v) = &rg.payment_currency { + // Match env normalization (`.to_uppercase()`). + c.registration.payment.currency = v.to_uppercase(); + } + if let Some(v) = rg.payment_confirmations { + c.registration.payment.confirmations = v; + } + if let Some(v) = rg.payment_expires_minutes { + c.registration.payment.expires_minutes = v; + } + } + + if let Some(p) = &ov.pow { + if let Some(v) = p.enabled { + c.pow.enabled = v; + } + if let Some(v) = p.required { + c.pow.required = v; + } + if let Some(v) = p.cost { + c.pow.cost = v; + } + } + + if let Some(f) = &ov.features { + if let Some(v) = f.prices { + c.features.prices = v; + } + if let Some(v) = &f.prices_provider { + c.features.prices_provider = v.to_lowercase(); + } + if let Some(v) = f.prices_interval_secs { + c.features.prices_interval_secs = v; + } + if let Some(v) = &f.prices_currency { + c.features.prices_currency = v.to_lowercase(); + } + if let Some(v) = f.tips { + c.features.tips = v; + } + if let Some(v) = f.nostr_identity { + c.features.nostr_identity = v; + } + if let Some(v) = f.grin_relay { + c.features.grin_relay = v; + } + if let Some(v) = f.chain_btc { + c.features.chains.btc = v; + } + if let Some(v) = f.chain_ltc { + c.features.chains.ltc = v; + } + if let Some(v) = f.chain_xmr { + c.features.chains.xmr = v; + } + if let Some(v) = f.chain_wow { + c.features.chains.wow = v; + } + if let Some(v) = f.chain_grin { + c.features.chains.grin = v; + } + } + + if let Some(r) = &ov.relay { + if let Some(v) = r.enabled { + c.messaging.relay.enabled = v; + } + if let Some(v) = &r.mode { + c.messaging.relay.mode = v.to_lowercase(); + } + if let Some(v) = &r.advertised_url { + c.messaging.relay.advertised_url = v.clone(); + } + if let Some(v) = &r.write_policy { + c.messaging.relay.write_policy = v.to_lowercase(); + } + if let Some(v) = r.inbound_pow_bits { + c.messaging.relay.inbound_pow_bits = v; + } + if let Some(v) = r.max_event_bytes { + c.messaging.relay.max_event_bytes = v; + } + if let Some(v) = r.retention_days { + c.messaging.relay.retention_days = v; + } + } + + if let Some(pr) = &ov.premium { + if let Some(v) = pr.enabled { + c.premium.enabled = v; + } + if let Some(v) = &pr.currency { + c.premium.currency = v.to_uppercase(); + } + } + + c.validate()?; + Ok(c) + } +} + +/// Every operator-editable `(section, field)` pair — the enumerable surface for +/// `GET /admin/config` (per-field runtime_class) and for classifying a PUT patch. +pub const EDITABLE_FIELDS: &[(&str, &str)] = &[ + ("landing", "enabled"), + ("landing", "title"), + ("landing", "expose_version"), + ("landing", "expose_chains"), + ("landing", "expose_price_feed"), + ("landing", "expose_uptime"), + ("landing", "stats_enabled"), + ("landing", "stats_cache_hours"), + ("retention", "login_events_days"), + ("retention", "audit_days"), + ("retention", "erasure_enabled"), + ("retention", "purge_login_events"), + ("retention", "export_per_day"), + ("retention", "grace_period_hours"), + ("restore", "policy"), + ("restore", "max_depth_days"), + ("restore", "pow_free_days"), + ("restore", "pow_days_per_bit"), + ("restore", "pow_max_bits"), + ("console", "restart_apply_mode"), + ("registration", "require_invite"), + ("registration", "gate_mode"), + ("registration", "require_payment"), + ("registration", "payment_amount"), + ("registration", "payment_currency"), + ("registration", "payment_confirmations"), + ("registration", "payment_expires_minutes"), + ("pow", "enabled"), + ("pow", "required"), + ("pow", "cost"), + ("features", "prices"), + ("features", "prices_provider"), + ("features", "prices_interval_secs"), + ("features", "prices_currency"), + ("features", "tips"), + ("features", "nostr_identity"), + ("features", "grin_relay"), + ("features", "chain_btc"), + ("features", "chain_ltc"), + ("features", "chain_xmr"), + ("features", "chain_wow"), + ("features", "chain_grin"), + ("relay", "enabled"), + ("relay", "mode"), + ("relay", "advertised_url"), + ("relay", "write_policy"), + ("relay", "inbound_pow_bits"), + ("relay", "max_event_bytes"), + ("relay", "retention_days"), + ("premium", "enabled"), + ("premium", "currency"), +]; + +impl Config { + /// A fully-populated overlay reflecting the EFFECTIVE value of every editable + /// field (all `Some`). `GET /admin/config` returns this so the console shows + /// current values; diffing against the persisted overlay yields each field's + /// source (default/env vs db). + pub fn editable_overlay(&self) -> SettingsOverlay { + SettingsOverlay { + landing: Some(LandingOverlay { + enabled: Some(self.landing.enabled), + title: Some(self.landing.title.clone().unwrap_or_default()), + expose_version: Some(self.landing.expose_version), + expose_chains: Some(self.landing.expose_chains), + expose_price_feed: Some(self.landing.expose_price_feed), + expose_uptime: Some(self.landing.expose_uptime), + stats_enabled: Some(self.landing.stats_enabled), + stats_cache_hours: Some(self.landing.stats_cache_hours), + }), + retention: Some(RetentionOverlay { + login_events_days: Some(self.retention.login_events_days), + audit_days: Some(self.retention.audit_days), + erasure_enabled: Some(self.retention.erasure_enabled), + purge_login_events: Some(self.retention.purge_login_events), + export_per_day: Some(self.retention.export_per_day), + grace_period_hours: Some(self.retention.grace_period_hours), + }), + restore: Some(RestoreOverlay { + policy: Some(self.restore.policy.as_str().to_string()), + max_depth_days: Some(self.restore.max_depth_days), + pow_free_days: Some(self.restore.pow_free_days), + pow_days_per_bit: Some(self.restore.pow_days_per_bit), + pow_max_bits: Some(self.restore.pow_max_bits), + }), + console: Some(ConsoleOverlay { + restart_apply_mode: Some(self.console.restart_apply_mode.as_str().to_string()), + }), + registration: Some(RegistrationOverlay { + require_invite: Some(self.registration.require_invite), + gate_mode: Some(self.registration.gate_mode.as_str().to_string()), + require_payment: Some(self.registration.payment.require_payment), + payment_amount: Some(self.registration.payment.amount.clone()), + payment_currency: Some(self.registration.payment.currency.clone()), + payment_confirmations: Some(self.registration.payment.confirmations), + payment_expires_minutes: Some(self.registration.payment.expires_minutes), + }), + pow: Some(PowOverlay { + enabled: Some(self.pow.enabled), + required: Some(self.pow.required), + cost: Some(self.pow.cost), + }), + features: Some(FeaturesOverlay { + prices: Some(self.features.prices), + prices_provider: Some(self.features.prices_provider.clone()), + prices_interval_secs: Some(self.features.prices_interval_secs), + prices_currency: Some(self.features.prices_currency.clone()), + tips: Some(self.features.tips), + nostr_identity: Some(self.features.nostr_identity), + grin_relay: Some(self.features.grin_relay), + chain_btc: Some(self.features.chains.btc), + chain_ltc: Some(self.features.chains.ltc), + chain_xmr: Some(self.features.chains.xmr), + chain_wow: Some(self.features.chains.wow), + chain_grin: Some(self.features.chains.grin), + }), + relay: Some(RelayOverlay { + enabled: Some(self.messaging.relay.enabled), + mode: Some(self.messaging.relay.mode.clone()), + advertised_url: Some(self.messaging.relay.advertised_url.clone()), + write_policy: Some(self.messaging.relay.write_policy.clone()), + inbound_pow_bits: Some(self.messaging.relay.inbound_pow_bits), + max_event_bytes: Some(self.messaging.relay.max_event_bytes), + retention_days: Some(self.messaging.relay.retention_days), + }), + premium: Some(PremiumOverlay { + enabled: Some(self.premium.enabled), + currency: Some(self.premium.currency.clone()), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_parses_sparse_json_ignoring_absent_and_unknown_sections() { + // Absent sections stay None; unknown sections are ignored (forward-compat). + let ov: SettingsOverlay = + serde_json::from_str(r#"{"restore":{"policy":"unlimited"},"future_section":{"x":1}}"#) + .expect("sparse overlay parses"); + assert!(ov.landing.is_none()); + assert!(ov.retention.is_none()); + assert_eq!(ov.restore.unwrap().policy.as_deref(), Some("unlimited")); + } + + #[test] + fn runtime_class_classifies_known_and_defaults_restart_for_unknown() { + assert_eq!( + runtime_class("landing", "enabled"), + RuntimeClass::RuntimeSafe + ); + assert_eq!( + runtime_class("restore", "max_depth_days"), + RuntimeClass::RuntimeSafe + ); + // erasure_enabled gates a boot worker; unknown pairs default restart-required. + assert_eq!( + runtime_class("retention", "erasure_enabled"), + RuntimeClass::RestartRequired + ); + assert_eq!( + runtime_class("console", "restart_apply_mode"), + RuntimeClass::RuntimeSafe + ); + // require_payment gates the boot-built payment client -> restart-required, + // while its sibling registration gates hot-swap. + assert_eq!( + runtime_class("registration", "require_payment"), + RuntimeClass::RestartRequired + ); + assert_eq!( + runtime_class("registration", "require_invite"), + RuntimeClass::RuntimeSafe + ); + assert_eq!( + runtime_class("mystery", "field"), + RuntimeClass::RestartRequired + ); + } + + #[test] + fn console_overlay_parses_restart_apply_mode() { + let ov: SettingsOverlay = + serde_json::from_str(r#"{"console":{"restart_apply_mode":"auto"}}"#).unwrap(); + assert_eq!( + ov.console.unwrap().restart_apply_mode.as_deref(), + Some("auto") + ); + } + + #[test] + fn every_editable_field_has_a_runtime_class() { + // EDITABLE_FIELDS is the enumerable surface GET /admin/config annotates. + for (section, field) in EDITABLE_FIELDS { + let _ = runtime_class(section, field); // no panic / all covered + } + assert!(EDITABLE_FIELDS + .iter() + .any(|(s, f)| *s == "console" && *f == "restart_apply_mode")); + } +} diff --git a/src/infra/db/invite_codes.rs b/src/infra/db/invite_codes.rs index 28a87dd..ba657bd 100644 --- a/src/infra/db/invite_codes.rs +++ b/src/infra/db/invite_codes.rs @@ -5,12 +5,28 @@ //! single-statement claim (`UPDATE ... WHERE used_at IS NULL ... RETURNING`) so //! two concurrent registrations presenting the same code can never both succeed. +use chrono::{DateTime, Utc}; use tracing::instrument; +use uuid::Uuid; +use crate::core::invite::{generate_invite_code, hash_invite_code}; use crate::error::AppError; +use crate::models::db::NewAdminAudit; use super::Database; +/// A row of the invite-code table for the admin console. Never carries a usable raw +/// code (only the hash prefix is exposed by the handler). +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct InviteCodeRow { + pub code_hash: String, + pub created_at: DateTime, + pub label: Option, + pub expires_at: Option>, + pub used_at: Option>, + pub used_by: Option, +} + impl Database { /// Store a freshly-minted code by its `sha256` hash with an optional operator /// `label`. A duplicate hash (re-mint of the same raw code — astronomically @@ -57,4 +73,47 @@ impl Database { .fetch_one(self.pool()) .await?) } + + /// List invite codes newest-first (admin console). Never returns raw codes — the + /// handler exposes only a hash prefix + status. + pub async fn list_invite_codes( + &self, + limit: i64, + offset: i64, + ) -> Result, AppError> { + Ok(sqlx::query_as::<_, InviteCodeRow>( + "SELECT code_hash, created_at, label, expires_at, used_at, used_by \ + FROM invite_codes ORDER BY created_at DESC LIMIT $1 OFFSET $2", + ) + .bind(limit) + .bind(offset) + .fetch_all(self.pool()) + .await?) + } + + /// Mint `count` single-use invite codes + append one audit entry in a single tx. + /// Returns the RAW codes (bearer secrets, shown once — only the hash is stored). + #[instrument(skip(self, audit, secret))] + pub async fn mint_invites_audited( + &self, + count: u32, + label: Option<&str>, + audit: &NewAdminAudit, + secret: &str, + ) -> Result, AppError> { + let mut tx = self.pool().begin().await?; + let mut codes = Vec::with_capacity(count as usize); + for _ in 0..count { + let code = generate_invite_code(); + sqlx::query("INSERT INTO invite_codes (code_hash, label) VALUES ($1, $2)") + .bind(hash_invite_code(&code)) + .bind(label) + .execute(&mut *tx) + .await?; + codes.push(code); + } + self.append_admin_audit(&mut tx, audit, secret).await?; + tx.commit().await?; + Ok(codes) + } } diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 41456ab..e059953 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -15,6 +15,7 @@ mod erasure; mod grin_slatepacks; mod invite_codes; mod login_events; +mod operator_settings; mod payment_invoices; mod premium_invoices; mod restore_attempts; diff --git a/src/infra/db/operator_settings.rs b/src/infra/db/operator_settings.rs new file mode 100644 index 0000000..b537f2f --- /dev/null +++ b/src/infra/db/operator_settings.rs @@ -0,0 +1,189 @@ +//! Operator settings overlay storage (`operator_settings`) — the DB tier under the +//! env-derived Config. One MAC'd JSONB row per section (`doc` = that section's sparse +//! patch). Loads verify the MAC per section (fail-closed: a tampered/undecodable +//! section is ignored and falls back to env); writes upsert + append to the +//! hash-chained admin_audit log in one transaction. Mirrors `server_config` (MAC) and +//! `admin_keys::create_admin_key_audited` (audited write). + +use chrono::{SubsecRound, Utc}; +use serde_json::Value; +use subtle::ConstantTimeEq; +use tracing::{instrument, warn}; +use uuid::Uuid; + +use crate::config_overlay::SettingsOverlay; +use crate::core::crypto::pepper::peppered_hex; +use crate::error::AppError; +use crate::models::db::{NewAdminAudit, OperatorSettingRow}; + +use super::Database; + +/// Recursively key-sorted, whitespace-free JSON so the MAC is stable across Postgres +/// JSONB normalization (JSONB does NOT preserve object key order). Both write and read +/// canonicalize the same way, so the MAC round-trips regardless of storage reordering. +fn canonical_json(v: &Value) -> String { + match v { + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + let inner: Vec = keys + .into_iter() + .map(|k| format!("{}:{}", Value::String(k.clone()), canonical_json(&map[k]))) + .collect(); + format!("{{{}}}", inner.join(",")) + } + Value::Array(arr) => { + let inner: Vec = arr.iter().map(canonical_json).collect(); + format!("[{}]", inner.join(",")) + } + other => other.to_string(), + } +} + +/// `HMAC(secret, "operator_settings" ‖ section ‖ version ‖ canonical(doc))`. +fn settings_mac(secret: &str, section: &str, version: i64, doc: &Value) -> String { + peppered_hex( + secret, + "operator_settings", + &format!("{section}\u{1f}{version}\u{1f}{}", canonical_json(doc)), + ) +} + +fn verify_settings_mac(secret: &str, row: &OperatorSettingRow) -> bool { + let expected = settings_mac(secret, &row.section, row.version, &row.doc); + expected + .as_bytes() + .ct_eq(row.integrity_mac.as_bytes()) + .into() +} + +impl Database { + /// Load + verify all settings rows into a [`SettingsOverlay`]. A row whose MAC + /// fails, or whose section fails to deserialize, is IGNORED (that section falls + /// back to env) and logged — never fails the caller. Fail-closed on tamper, + /// fail-open-to-env on a bad/absent section. + #[instrument(skip(self, secret))] + pub async fn load_settings_overlay(&self, secret: &str) -> Result { + let rows = sqlx::query_as::<_, OperatorSettingRow>( + "SELECT section, doc, version, updated_by, updated_at, integrity_mac \ + FROM operator_settings", + ) + .fetch_all(self.pool()) + .await?; + + let mut merged = serde_json::Map::new(); + for row in &rows { + if !verify_settings_mac(secret, row) { + warn!(section = %row.section, "operator_settings MAC mismatch; ignoring section (env-only)"); + continue; + } + merged.insert(row.section.clone(), row.doc.clone()); + } + match serde_json::from_value::(Value::Object(merged)) { + Ok(ov) => Ok(ov), + Err(e) => { + warn!(error = %e, "operator_settings overlay decode failed; using env-only"); + Ok(SettingsOverlay::default()) + } + } + } + + /// Upsert one section's `doc` (new MAC + bumped version) and append the audit entry + /// in a single transaction. Returns the new version. The caller MUST have already + /// validated the resulting effective config via `Config::apply_overlay` (this only + /// persists). `FOR UPDATE` serializes concurrent writers to the same section. + #[instrument(skip(self, doc, audit, secret))] + pub async fn put_setting_audited( + &self, + section: &str, + doc: &Value, + updated_by: Option, + expected_version: Option, + audit: &NewAdminAudit, + secret: &str, + ) -> Result { + let mut tx = self.pool().begin().await?; + + let current: Option = sqlx::query_scalar( + "SELECT version FROM operator_settings WHERE section = $1 FOR UPDATE", + ) + .bind(section) + .fetch_optional(&mut *tx) + .await?; + let cur = current.unwrap_or(0); + // Optimistic concurrency: reject a stale write (two operators editing at once). + if let Some(exp) = expected_version { + if cur != exp { + tx.rollback().await?; + return Err(AppError::Conflict(format!( + "settings section '{section}' changed concurrently (expected v{exp}, found v{cur}); reload and retry" + ))); + } + } + let version = cur + 1; + let mac = settings_mac(secret, section, version, doc); + let updated_at = Utc::now().trunc_subsecs(6); + + sqlx::query( + "INSERT INTO operator_settings \ + (section, doc, version, updated_by, updated_at, integrity_mac) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (section) DO UPDATE SET \ + doc = EXCLUDED.doc, version = EXCLUDED.version, \ + updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at, \ + integrity_mac = EXCLUDED.integrity_mac", + ) + .bind(section) + .bind(doc) + .bind(version) + .bind(updated_by) + .bind(updated_at) + .bind(&mac) + .execute(&mut *tx) + .await?; + + self.append_admin_audit(&mut tx, audit, secret).await?; + tx.commit().await?; + Ok(version) + } + + /// Current persisted version per section (for optimistic-concurrency PUTs + the + /// GET response). A section with no row is absent (its effective source is env). + pub async fn settings_versions( + &self, + ) -> Result, AppError> { + let rows: Vec<(String, i64)> = + sqlx::query_as("SELECT section, version FROM operator_settings") + .fetch_all(self.pool()) + .await?; + Ok(rows.into_iter().collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn canonical_json_is_key_order_independent() { + let a = json!({"b": 1, "a": {"y": 2, "x": [3, 2]}}); + let b = json!({"a": {"x": [3, 2], "y": 2}, "b": 1}); + assert_eq!(canonical_json(&a), canonical_json(&b)); + } + + #[test] + fn settings_mac_changes_with_version_and_doc() { + let doc = json!({"enabled": true}); + let m1 = settings_mac("secret-secret-secret-secret-32ch", "landing", 1, &doc); + let m2 = settings_mac("secret-secret-secret-secret-32ch", "landing", 2, &doc); + let m3 = settings_mac( + "secret-secret-secret-secret-32ch", + "landing", + 1, + &json!({"enabled": false}), + ); + assert_ne!(m1, m2); + assert_ne!(m1, m3); + } +} diff --git a/src/infra/db/social_tips.rs b/src/infra/db/social_tips.rs index 8a265e1..f3c0c17 100644 --- a/src/infra/db/social_tips.rs +++ b/src/infra/db/social_tips.rs @@ -169,11 +169,9 @@ impl Database { /// `pending_confirmation` so the funding verifier (not the caller) owns the /// transition to `pending`. #[instrument(skip(self, new))] - pub async fn create_social_tip( - &self, - new: NewSocialTip<'_>, - ) -> Result { - self.insert_social_tip(new, TipStatus::PendingConfirmation).await + pub async fn create_social_tip(&self, new: NewSocialTip<'_>) -> Result { + self.insert_social_tip(new, TipStatus::PendingConfirmation) + .await } /// All tips this user has sent, newest first (every status). @@ -858,9 +856,7 @@ impl Database { /// cleared the funding verifier and is a real tip the sender expects claimed; /// it must NOT be auto-cancelled here. #[instrument(skip(self))] - pub async fn cancel_stuck_pending_confirmation( - &self, - ) -> Result, AppError> { + pub async fn cancel_stuck_pending_confirmation(&self) -> Result, AppError> { let rows = sqlx::query_as::<_, GcCancelledTip>( "UPDATE social_tips \ SET status = 'cancelled', updated_at = NOW() \ diff --git a/src/infra/db/users.rs b/src/infra/db/users.rs index f127efa..7095d8b 100644 --- a/src/infra/db/users.rs +++ b/src/infra/db/users.rs @@ -230,10 +230,7 @@ impl Database { /// registered on *this* backend. Used to look up a recipient's linked Nostr /// pubkey so a bare-`grin1…`-addressed send can route over Nostr gift-wrap. #[instrument(skip(self, address))] - pub async fn find_user_by_grin_address( - &self, - address: &str, - ) -> Result, AppError> { + pub async fn find_user_by_grin_address(&self, address: &str) -> Result, AppError> { let sql = format!( "SELECT {USER_COLS} FROM users u \ JOIN user_keys k ON k.user_id = u.id \ diff --git a/src/infra/electrum.rs b/src/infra/electrum.rs index 3f485ee..12fc03e 100644 --- a/src/infra/electrum.rs +++ b/src/infra/electrum.rs @@ -489,7 +489,7 @@ impl ElectrumClient { let use_primary = self .primary .as_ref() - .filter(|_| !(self.primary_in_cooldown(now) && !self.fallbacks.is_empty())); + .filter(|_| !self.primary_in_cooldown(now) || self.fallbacks.is_empty()); let mut last_err = AppError::NodeError("all Electrum servers failed".into()); @@ -536,7 +536,8 @@ impl ElectrumClient { let count = self.primary_fail_count.fetch_add(1, Ordering::Relaxed) + 1; if count >= PRIMARY_FAIL_THRESHOLD { let until = now_ms.saturating_add(PRIMARY_COOLDOWN.as_millis() as u64); - self.primary_cooldown_until_ms.store(until, Ordering::Relaxed); + self.primary_cooldown_until_ms + .store(until, Ordering::Relaxed); } } @@ -981,7 +982,10 @@ mod tests { let client = ElectrumClient::new(UtxoNetwork::BitcoinMainnet, &cfg).unwrap(); let tip = client.get_tip_height().await; assert!(tip.is_ok(), "lenient TLS must connect to {server}: {tip:?}"); - assert!(tip.unwrap() > 800_000, "sanity: BTC tip height from {server}"); + assert!( + tip.unwrap() > 800_000, + "sanity: BTC tip height from {server}" + ); } } diff --git a/src/infra/grin/mod.rs b/src/infra/grin/mod.rs index d374646..4f28c08 100644 --- a/src/infra/grin/mod.rs +++ b/src/infra/grin/mod.rs @@ -158,9 +158,7 @@ impl GrinOutputInfo { /// bogus count. Mirrors [`ViewWalletOutputResult::confirmations`]. pub fn confirmations(&self, tip_height: u64) -> u64 { match self.block_height { - Some(h) if h > 0 && h <= tip_height => { - tip_height.saturating_sub(h).saturating_add(1) - } + Some(h) if h > 0 && h <= tip_height => tip_height.saturating_sub(h).saturating_add(1), _ => 0, } } diff --git a/src/infra/grin_lws/client.rs b/src/infra/grin_lws/client.rs index c06e6be..6dfb3c0 100644 --- a/src/infra/grin_lws/client.rs +++ b/src/infra/grin_lws/client.rs @@ -170,7 +170,10 @@ impl GrinLwsClient { } fn node_err_status(&self, label: &str, status: reqwest::StatusCode) -> AppError { - AppError::NodeError(format!("grin-lws {label} failed (HTTP {})", status.as_u16())) + AppError::NodeError(format!( + "grin-lws {label} failed (HTTP {})", + status.as_u16() + )) } } diff --git a/src/infra/lws/client.rs b/src/infra/lws/client.rs index 48d437d..6fec50f 100644 --- a/src/infra/lws/client.rs +++ b/src/infra/lws/client.rs @@ -242,8 +242,7 @@ impl LwsClient { Some(_) => Ok(()), None => { self.admin_add_account(address, view_key).await?; - let current = - self.account_scan_height(address).await?.unwrap_or(u64::MAX); + let current = self.account_scan_height(address).await?.unwrap_or(u64::MAX); if start_height < current { // The account is now added at the chain tip. If this backfill // rescan fails, the account is stranded at the tip: it reads a diff --git a/src/infra/prices.rs b/src/infra/prices.rs index 8a7c428..9835d40 100644 --- a/src/infra/prices.rs +++ b/src/infra/prices.rs @@ -27,7 +27,11 @@ const KRAKEN_URL: &str = "https://api.kraken.com/0/public/Ticker"; /// Nonlogs markets — WOW/GRIN, which Kraken doesn't list (priced via BTC/USDT). const NONLOGS_URL: &str = "https://api.nonlogs.io/api/markets"; /// Our majors → Kraken USD pair. WOW/GRIN come from nonlogs instead. -const KRAKEN_PAIRS: &[(&str, &str)] = &[("btc", "XXBTZUSD"), ("ltc", "XLTCZUSD"), ("xmr", "XXMRZUSD")]; +const KRAKEN_PAIRS: &[(&str, &str)] = &[ + ("btc", "XXBTZUSD"), + ("ltc", "XLTCZUSD"), + ("xmr", "XXMRZUSD"), +]; /// Lower bound on the refresh interval, to stay within free-tier rate limits. const MIN_INTERVAL_SECS: u64 = 60; /// Upper bound on the price response body. The real payload is a handful of @@ -146,7 +150,12 @@ impl PriceClient { .collect(); if !pairs.is_empty() { let joined = pairs.iter().map(|(_, p)| *p).collect::>().join(","); - if let Ok(resp) = self.http.get(KRAKEN_URL).query(&[("pair", joined.as_str())]).send().await + if let Ok(resp) = self + .http + .get(KRAKEN_URL) + .query(&[("pair", joined.as_str())]) + .send() + .await { if resp.status().is_success() { let body = read_capped(resp, MAX_PRICE_BODY_BYTES).await?; diff --git a/src/infra/relay/mod.rs b/src/infra/relay/mod.rs index 2455838..e8f0efd 100644 --- a/src/infra/relay/mod.rs +++ b/src/infra/relay/mod.rs @@ -145,6 +145,27 @@ struct NostrRelayProvider { write_allowlist: HashSet, } +impl RelayProvider for NostrRelayProvider { + fn kind(&self) -> &'static str { + self.kind + } + fn advertised_url(&self) -> &str { + &self.advertised_url + } + fn write_policy(&self) -> WritePolicy { + self.policy + } + fn inbound_pow_bits(&self) -> u8 { + self.inbound_pow_bits + } + fn max_event_bytes(&self) -> usize { + self.max_event_bytes + } + fn is_write_allowlisted(&self, author_hex: &str) -> bool { + !self.write_allowlist.is_empty() && self.write_allowlist.contains(author_hex) + } +} + #[cfg(test)] mod tests { use super::allowlist_entry_to_hex; @@ -175,24 +196,3 @@ mod tests { assert_eq!(allowlist_entry_to_hex("deadbeef"), None); // too short } } - -impl RelayProvider for NostrRelayProvider { - fn kind(&self) -> &'static str { - self.kind - } - fn advertised_url(&self) -> &str { - &self.advertised_url - } - fn write_policy(&self) -> WritePolicy { - self.policy - } - fn inbound_pow_bits(&self) -> u8 { - self.inbound_pow_bits - } - fn max_event_bytes(&self) -> usize { - self.max_event_bytes - } - fn is_write_allowlisted(&self, author_hex: &str) -> bool { - !self.write_allowlist.is_empty() && self.write_allowlist.contains(author_hex) - } -} diff --git a/src/infra/relay/nauthz.rs b/src/infra/relay/nauthz.rs index 47ab874..ca183ee 100644 --- a/src/infra/relay/nauthz.rs +++ b/src/infra/relay/nauthz.rs @@ -91,13 +91,12 @@ impl Authorization for AdmissionService { // Premium membership — only the `premium-post` policy consults it, so skip // the (cheap, indexed) lookup entirely under the other policies (and when // the author is already write-allowlisted). - let author_premium = if !author_allowlisted - && self.relay.write_policy() == WritePolicy::PremiumPost - { - self.db.is_premium_npub(&author_hex).await.unwrap_or(false) - } else { - false - }; + let author_premium = + if !author_allowlisted && self.relay.write_policy() == WritePolicy::PremiumPost { + self.db.is_premium_npub(&author_hex).await.unwrap_or(false) + } else { + false + }; // Recipient registration (only when the author isn't already registered / // allowlisted): collect up to MAX_P_TAGS distinct `p` tags and resolve them diff --git a/src/infra/relay/policy.rs b/src/infra/relay/policy.rs index 34d6ca9..e38cb8f 100644 --- a/src/infra/relay/policy.rs +++ b/src/infra/relay/policy.rs @@ -219,8 +219,26 @@ mod tests { #[test] fn author_allowlist_gates_on_author() { let m = meta(A, 1, FF); - assert!(decide(&m, WritePolicy::AuthorAllowlist, 0, true, false, false, false).is_permit()); - assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, false, false, false, false).is_permit()); + assert!(decide( + &m, + WritePolicy::AuthorAllowlist, + 0, + true, + false, + false, + false + ) + .is_permit()); + assert!(!decide( + &m, + WritePolicy::AuthorAllowlist, + 0, + false, + false, + false, + false + ) + .is_permit()); } #[test] diff --git a/src/lib.rs b/src/lib.rs index af59c1d..5a773c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod api; pub mod config; +pub mod config_overlay; pub mod core; pub mod error; pub mod infra; @@ -55,7 +56,13 @@ const NORMAL_BURST: u32 = 60; /// Shared application state injected into handlers via `State>`. #[derive(Clone)] pub struct AppState { - pub config: Config, + /// Effective config = env-derived base + validated DB overlay. Hot-swappable: + /// a runtime-safe operator edit stores a new `Arc` here (lock-free). + /// Read it via [`AppState::cfg`] (`state.cfg().field`), never a stale copy. + pub config: Arc>, + /// Immutable env-only config (no overlay). Used to recompute the effective + /// config on a settings change and to report a field's source (default/env/db). + pub config_base: Arc, pub db: Database, pub sessions: SessionManager, /// Per-chain data-source clients (present only for enabled chains). @@ -75,6 +82,21 @@ pub struct AppState { /// Admin token minter/verifier. `Some` only when the admin surface is /// enabled; the admin plane and guard refuse all requests otherwise. pub admin_sessions: Option, + /// Signalled to trigger a graceful shutdown from INSIDE the app — e.g. an operator + /// config change in `auto` restart mode. `main` awaits this alongside SIGTERM; + /// systemd `Restart=always` brings the instance back with the new config applied. + pub shutdown: Arc, +} + +impl AppState { + /// Load the current effective [`Config`] (lock-free, wait-free). The returned + /// guard derefs to `&Config`, so `state.cfg().field` reads the live value and + /// `&state.cfg()` passes to a `fn(&Config)` helper. Every config read goes + /// through here so a runtime-safe operator edit is seen on the next request. + #[inline] + pub fn cfg(&self) -> arc_swap::Guard> { + self.config.load() + } } /// Assemble the full application router with every route mounted and state @@ -82,7 +104,7 @@ pub struct AppState { /// the exact same wiring: `/health` and the NIP-05 directory at the root, the /// authenticated wallet/identity API nested under `/api/v1`. pub fn build_router(state: Arc) -> Router { - let cors = cors_layer(&state.config); + let cors = cors_layer(&state.cfg()); // Per-IP rate limiting (PeerIpKeyExtractor reads ConnectInfo — `main` serves // with `into_make_service_with_connect_info`; the test harness injects it). diff --git a/src/main.rs b/src/main.rs index 207e8fe..2ec0345 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,21 +19,42 @@ async fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); let _ = dotenvy::dotenv(); - // Fail-closed: aborts on a weak/missing/inconsistent secret. - let config = Config::from_env()?; - tracing::info!(environment = %config.environment, "configuration loaded"); + // Fail-closed: aborts on a weak/missing/inconsistent secret. This is the env-only + // truth; the effective config below layers the validated DB overlay on top. + let config_base = Config::from_env()?; + tracing::info!(environment = %config_base.environment, "configuration loaded"); let pool = PgPoolOptions::new() .max_connections(20) - .connect(&config.database_url) + .connect(&config_base.database_url) .await?; sqlx::migrate!("./migrations").run(&pool).await?; let db = Database::new( pool, - config.secrets.seed_fingerprint_pepper.clone(), - config.secrets.ip_salt.clone(), + config_base.secrets.seed_fingerprint_pepper.clone(), + config_base.secrets.ip_salt.clone(), ); + + // Effective config = env-derived base + validated DB overlay (operator settings). + // Overlay rows are MAC'd under ADMIN_KEY_INTEGRITY_SECRET; without that anchor + // (admin disabled) we stay env-only. A persisted overlay that no longer validates + // is ignored (boot env-only + loud log) rather than bricking the instance. Every + // client/worker/router below is built from `config` (the effective view). + let overlay = if config_base.admin.key_integrity_secret.is_empty() { + smirk_backend_core::config_overlay::SettingsOverlay::default() + } else { + db.load_settings_overlay(&config_base.admin.key_integrity_secret) + .await? + }; + let config = match config_base.apply_overlay(&overlay) { + Ok(effective) => effective, + Err(e) => { + tracing::error!(error = %e, "persisted config overlay invalid; ignoring it, booting env-only"); + config_base.clone() + } + }; + let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours); let chains = ChainClients::from_config(&config)?; // Payment processor for the pay-to-register gate (None unless it's enabled). @@ -87,7 +108,8 @@ async fn main() -> Result<(), Box> { .enabled .then(|| AdminSessionManager::new(&config.admin.jwt_secret)); let state = Arc::new(AppState { - config, + config: Arc::new(arc_swap::ArcSwap::from_pointee(config)), + config_base: Arc::new(config_base), db, sessions, chains, @@ -96,6 +118,7 @@ async fn main() -> Result<(), Box> { web_challenges: Arc::default(), prices: prices_cache, admin_sessions, + shutdown: Arc::new(tokio::sync::Notify::new()), }); // Periodic GC of expired website-auth challenges. Bounds the single-node @@ -116,7 +139,7 @@ async fn main() -> Result<(), Box> { // enabled). The respond/finalize paths already reject expired relays in their // UPDATE guard; this sweep flips past-TTL rows to Expired so the table stays // bounded and the lifecycle reflects reality. - if state.config.features.grin_relay { + if state.cfg().features.grin_relay { let db = state.db.clone(); tokio::spawn(async move { let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600)); @@ -146,7 +169,7 @@ async fn main() -> Result<(), Box> { // Background erasure execution: confirmed requests past their grace window // are deleted (per-table policy + cascade + hash-chained audit, one tx each). - if state.config.retention.erasure_enabled { + if state.cfg().retention.erasure_enabled { let state = state.clone(); tokio::spawn(async move { let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600)); @@ -165,8 +188,8 @@ async fn main() -> Result<(), Box> { // fetch the configured feeds and replace the snapshot; a failure logs and // keeps the last good values rather than blanking them. The first interval // tick fires immediately, so prices populate at startup. - if state.config.features.prices { - let f = &state.config.features; + if state.cfg().features.prices { + let f = &state.cfg().features; match prices::PriceClient::new(&f.prices_provider, &f.prices_currency, &f.prices_assets) { Ok(client) if !client.is_empty() => { let cache = state.prices.clone(); @@ -195,8 +218,8 @@ async fn main() -> Result<(), Box> { // not middleware ordering). The fail-closed non-loopback bind guard and the // browser/Host hardening land with the admin-posture subsystem; for now bind // to the configured (loopback-default) address and warn if it isn't local. - if state.config.admin.enabled { - let admin_addr = state.config.admin.bind.clone(); + if state.cfg().admin.enabled { + let admin_addr = state.cfg().admin.bind.clone(); // Fail-closed bind guard: refuse a non-loopback admin bind unless the // operator explicitly opts in (confidentiality is by socket). Resolve the // address (so "localhost" works) and require EVERY resolved addr to be @@ -209,7 +232,7 @@ async fn main() -> Result<(), Box> { } Err(_) => false, }; - if !is_loopback && !state.config.admin.allow_public_bind { + if !is_loopback && !state.cfg().admin.allow_public_bind { return Err(format!( "ADMIN_BIND {admin_addr} is not loopback; set ADMIN_ALLOW_PUBLIC_BIND=true to override \ (and front it with Tor client-auth / a trusted proxy)" @@ -238,7 +261,7 @@ async fn main() -> Result<(), Box> { use smirk_backend_core::infra::relay::WritePolicy; if relay.write_policy() != WritePolicy::Open { let addr: std::net::SocketAddr = state - .config + .cfg() .messaging .relay .admission_bind @@ -267,7 +290,7 @@ async fn main() -> Result<(), Box> { // pending_confirmation tips). Only runs when tips are enabled. A panic in a // single pass is caught + backed off so the loop survives (visible in logs) // rather than silently dying and stranding every pending tip. - if state.config.features.tips { + if state.cfg().features.tips { use futures::FutureExt; let state = state.clone(); tokio::spawn(async move { @@ -291,7 +314,7 @@ async fn main() -> Result<(), Box> { // an immediate startup pass (so a restart doesn't wait a full interval before // settling anything that confirmed while we were down) then every 60s. Same // catch_unwind guard as the money-in worker. - if state.config.features.tips { + if state.cfg().features.tips { use futures::FutureExt; let state = state.clone(); tokio::spawn(async move { @@ -312,7 +335,7 @@ async fn main() -> Result<(), Box> { // Public-tips reorg-check pass: re-probe settled tips and revert any whose // recorded sweep was orphaned by a chain reorg. Lowest-frequency, novel, and // recoverable, so it runs on a slower cadence (300s) than the confirm pass. - if state.config.features.tips { + if state.cfg().features.tips { use futures::FutureExt; let state = state.clone(); tokio::spawn(async move { @@ -333,7 +356,7 @@ async fn main() -> Result<(), Box> { // Public-tips draft GC: hourly, cancel `draft` rows abandoned mid-flow (> 7 // days, never funded-attached) and warn per cancelled row. Same catch_unwind // guard as the money-in/out workers. - if state.config.features.tips { + if state.cfg().features.tips { use futures::FutureExt; let state = state.clone(); tokio::spawn(async move { @@ -354,7 +377,7 @@ async fn main() -> Result<(), Box> { // Public-tips lifecycle GC: every 5 min, cancel stuck `pending_confirmation` // rows (> 7 days) and warn-only-scan stuck `claiming` rows (> 15 min). Same // catch_unwind guard. - if state.config.features.tips { + if state.cfg().features.tips { use futures::FutureExt; let state = state.clone(); tokio::spawn(async move { @@ -372,6 +395,7 @@ async fn main() -> Result<(), Box> { }); } + let shutdown = state.shutdown.clone(); let app = build_router(state); let listener = tokio::net::TcpListener::bind(&addr).await?; @@ -381,6 +405,32 @@ async fn main() -> Result<(), Box> { listener, app.into_make_service_with_connect_info::(), ) + .with_graceful_shutdown(shutdown_signal(shutdown)) .await?; Ok(()) } + +/// Completes on SIGTERM / Ctrl-C, or when the app signals a restart (an operator config +/// change in `auto` mode). Lets axum drain in-flight requests before exit; systemd +/// `Restart=always` then brings the instance back with the new config. +async fn shutdown_signal(app_triggered: Arc) { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let term = async { + if let Ok(mut sig) = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + sig.recv().await; + } + }; + #[cfg(not(unix))] + let term = std::future::pending::<()>(); + tokio::select! { + _ = ctrl_c => {} + _ = term => {} + _ = app_triggered.notified() => {} + } + tracing::info!("shutdown signal received; draining in-flight requests"); +} diff --git a/src/models/db.rs b/src/models/db.rs index 5d6c252..0ce12f5 100644 --- a/src/models/db.rs +++ b/src/models/db.rs @@ -307,6 +307,19 @@ pub struct ServerConfig { pub integrity_mac: String, } +/// A row of the operator-editable settings overlay (`operator_settings`). One row +/// per config section; `doc` is that section's sparse JSON patch over env. MAC'd +/// with ADMIN_KEY_INTEGRITY_SECRET + audited on every write. Holds no secrets. +#[derive(Debug, Clone, FromRow)] +pub struct OperatorSettingRow { + pub section: String, + pub doc: serde_json::Value, + pub version: i64, + pub updated_by: Option, + pub updated_at: DateTime, + pub integrity_mac: String, +} + /// A row in the tamper-evident privileged-action audit chain. #[derive(Debug, Clone, FromRow)] pub struct AdminAuditLog { diff --git a/src/models/tip_status.rs b/src/models/tip_status.rs index 7b4717d..56f9887 100644 --- a/src/models/tip_status.rs +++ b/src/models/tip_status.rs @@ -126,7 +126,10 @@ mod tests { #[test] fn wire_strings_match_check_constraint() { assert_eq!(TipStatus::Draft.as_str(), "draft"); - assert_eq!(TipStatus::PendingConfirmation.as_str(), "pending_confirmation"); + assert_eq!( + TipStatus::PendingConfirmation.as_str(), + "pending_confirmation" + ); assert_eq!(TipStatus::Pending.as_str(), "pending"); assert_eq!(TipStatus::Claiming.as_str(), "claiming"); assert_eq!(TipStatus::Claimed.as_str(), "claimed"); diff --git a/src/tips/confirmation.rs b/src/tips/confirmation.rs index 2f554df..c9adf5e 100644 --- a/src/tips/confirmation.rs +++ b/src/tips/confirmation.rs @@ -74,7 +74,11 @@ async fn check_asset_confirmations(state: &AppState, asset: &str) -> Result<(), debug!(asset, "no tips awaiting confirmation"); return Ok(()); } - info!(asset, count = tips.len(), "checking tip funding confirmations"); + info!( + asset, + count = tips.len(), + "checking tip funding confirmations" + ); for tip in &tips { let funding_txid = match tip.funding_txid.as_deref() { @@ -125,7 +129,10 @@ async fn check_grin_confirmations(state: &AppState) -> Result<(), AppError> { debug!("no grin tips awaiting confirmation"); return Ok(()); } - info!(count = tips.len(), "checking grin tip funding confirmations"); + info!( + count = tips.len(), + "checking grin tip funding confirmations" + ); // One tip-height read for the whole batch. A node error here aborts the pass // (logged/swallowed by the caller) leaving every row untouched — never a @@ -427,7 +434,11 @@ async fn process_pending_verifications(state: &AppState) -> Result<(), AppError> match verify_funding_amount(state, tip).await { FundingVerification::Verified { observed } => { let observed_i64 = i64::try_from(observed).unwrap_or(i64::MAX); - match state.db.mark_tip_funding_verified(tip.id, observed_i64).await { + match state + .db + .mark_tip_funding_verified(tip.id, observed_i64) + .await + { Ok(Some(_updated)) => { info!( tip_id = %tip.id, asset = %tip.asset, @@ -437,20 +448,32 @@ async fn process_pending_verifications(state: &AppState) -> Result<(), AppError> // Public tips deliver by share URL: no recipient DM / // announcement side-effect to fire here. } - Ok(None) => debug!(tip_id = %tip.id, "verified: row no longer pending_confirmation — skipping"), - Err(e) => warn!(tip_id = %tip.id, error = %e, "mark_tip_funding_verified DB error"), + Ok(None) => { + debug!(tip_id = %tip.id, "verified: row no longer pending_confirmation — skipping") + } + Err(e) => { + warn!(tip_id = %tip.id, error = %e, "mark_tip_funding_verified DB error") + } } } FundingVerification::Short { observed } => { let observed_i64 = i64::try_from(observed).unwrap_or(i64::MAX); - match state.db.mark_tip_funding_mismatch(tip.id, observed_i64).await { + match state + .db + .mark_tip_funding_mismatch(tip.id, observed_i64) + .await + { Ok(Some(_updated)) => warn!( tip_id = %tip.id, asset = %tip.asset, declared = tip.amount, observed = observed_i64, "tip funding mismatch — sender funded LESS than declared" ), - Ok(None) => debug!(tip_id = %tip.id, "mismatch: row no longer pending_confirmation — skipping"), - Err(e) => warn!(tip_id = %tip.id, error = %e, "mark_tip_funding_mismatch DB error"), + Ok(None) => { + debug!(tip_id = %tip.id, "mismatch: row no longer pending_confirmation — skipping") + } + Err(e) => { + warn!(tip_id = %tip.id, error = %e, "mark_tip_funding_mismatch DB error") + } } } FundingVerification::Unfunded => { @@ -560,7 +583,13 @@ mod tests { &resp(vec![tx(1_000, 0, 100, false), tx(0, 1_000, 110, false)]), 1_000, ); - assert_eq!(r, FundingVerification::Drained { net: 0, total_sent: 1_000 }); + assert_eq!( + r, + FundingVerification::Drained { + net: 0, + total_sent: 1_000 + } + ); } #[test] diff --git a/src/tips/gc.rs b/src/tips/gc.rs index 8bd23cd..0f8bbc5 100644 --- a/src/tips/gc.rs +++ b/src/tips/gc.rs @@ -50,7 +50,10 @@ pub async fn run_tip_draft_gc_cycle(state: Arc) { ); } if !cancelled.is_empty() { - info!(count = cancelled.len(), "tip-draft GC: cancelled abandoned drafts"); + info!( + count = cancelled.len(), + "tip-draft GC: cancelled abandoned drafts" + ); } } Err(e) => warn!(error = %e, "tip-draft GC cycle failed"), diff --git a/src/tips/sweep_reconciler.rs b/src/tips/sweep_reconciler.rs index 9c6b5a2..97ea4e8 100644 --- a/src/tips/sweep_reconciler.rs +++ b/src/tips/sweep_reconciler.rs @@ -142,10 +142,7 @@ async fn acquire_advisory_lock( /// Release the advisory lock on the SAME connection it was acquired on, then /// drop the connection back to the pool. Best-effort — the lock auto-releases /// when the connection closes. -async fn release_advisory_lock( - mut conn: sqlx::pool::PoolConnection, - key: i64, -) { +async fn release_advisory_lock(mut conn: sqlx::pool::PoolConnection, key: i64) { if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)") .bind(key) .execute(&mut *conn) @@ -172,7 +169,8 @@ pub async fn run_sweep_reconcile_cycle(state: Arc) { #[instrument(skip(state))] async fn run_confirm_cycle(state: &AppState) -> Result<(), AppError> { - let Some(lock_conn) = acquire_advisory_lock(state, SWEEP_CONFIRM_ADVISORY_LOCK_KEY).await? else { + let Some(lock_conn) = acquire_advisory_lock(state, SWEEP_CONFIRM_ADVISORY_LOCK_KEY).await? + else { debug!("sweep confirm cycle: advisory lock held by another instance — skipping"); return Ok(()); }; @@ -184,7 +182,10 @@ async fn run_confirm_cycle(state: &AppState) -> Result<(), AppError> { } async fn confirm_cycle_inner(state: &AppState) -> Result<(), AppError> { - let tips = state.db.get_tips_awaiting_sweep_confirmation(BATCH_SIZE).await?; + let tips = state + .db + .get_tips_awaiting_sweep_confirmation(BATCH_SIZE) + .await?; if tips.is_empty() { debug!("no tips awaiting sweep confirmation"); return Ok(()); @@ -257,7 +258,13 @@ async fn process_awaiting_tip(state: &AppState, tip: &SocialTipRow) -> Result<() let settled = if txid_matches { state .db - .confirm_sweep_onchain(tip.id, &winner.txid, winner.block_height as i32, block_hash, None) + .confirm_sweep_onchain( + tip.id, + &winner.txid, + winner.block_height as i32, + block_hash, + None, + ) .await? } else { debug!( @@ -267,7 +274,12 @@ async fn process_awaiting_tip(state: &AppState, tip: &SocialTipRow) -> Result<() ); state .db - .clear_claimed_by_on_settle(tip.id, &winner.txid, winner.block_height as i32, block_hash) + .clear_claimed_by_on_settle( + tip.id, + &winner.txid, + winner.block_height as i32, + block_hash, + ) .await? }; @@ -349,8 +361,9 @@ async fn probe_grin_spends( .as_ref() .ok_or_else(|| AppError::NodeError("grin client disabled".into()))?; - let commit = grin_commitment(tip) - .ok_or_else(|| AppError::ValidationError("grin commitment missing for sweep probe".into()))?; + let commit = grin_commitment(tip).ok_or_else(|| { + AppError::ValidationError("grin commitment missing for sweep probe".into()) + })?; let tip_height = grin.get_height().await? as i64; @@ -402,10 +415,9 @@ async fn probe_electrum_spends( } .ok_or_else(|| AppError::NodeError(format!("{asset} Electrum client disabled")))?; - let address = tip - .tip_address - .as_deref() - .ok_or_else(|| AppError::ValidationError("tip_address missing for Electrum probe".into()))?; + let address = tip.tip_address.as_deref().ok_or_else(|| { + AppError::ValidationError("tip_address missing for Electrum probe".into()) + })?; let history = electrum.get_history(address).await?; // Chain height comes from ELECTRUM, not a local node (the backend runs none). @@ -500,7 +512,10 @@ async fn run_reorg_cycle(state: &AppState) -> Result<(), AppError> { } async fn reorg_cycle_inner(state: &AppState) -> Result<(), AppError> { - let tips = state.db.get_settled_tips_for_reorg_check(BATCH_SIZE).await?; + let tips = state + .db + .get_settled_tips_for_reorg_check(BATCH_SIZE) + .await?; if tips.is_empty() { debug!("no settled tips to reorg-check"); return Ok(()); @@ -687,10 +702,22 @@ mod tests { // Funding tx: tip address RECEIVES; recv != 0 → not a spend. let tx = VerboseTransaction { txid: "fund".into(), - vin: vec![VerboseVin { txid: Some("alice_prev".into()), vout: Some(0), prevout: None }], + vin: vec![VerboseVin { + txid: Some("alice_prev".into()), + vout: Some(0), + prevout: None, + }], vout: vec![ - VerboseVout { value: 0.001, n: 0, script_pub_key: spk("ltc1qtip") }, - VerboseVout { value: 0.296, n: 1, script_pub_key: spk("ltc1qalice") }, + VerboseVout { + value: 0.001, + n: 0, + script_pub_key: spk("ltc1qtip"), + }, + VerboseVout { + value: 0.296, + n: 1, + script_pub_key: spk("ltc1qalice"), + }, ], }; assert!(!is_verbose_tx_spend_of(&tx, "ltc1qtip").unwrap()); @@ -701,8 +728,16 @@ mod tests { // Sweep: tip address absent from outputs (recv == 0) → spend. let tx = VerboseTransaction { txid: "sweep".into(), - vin: vec![VerboseVin { txid: Some("fund".into()), vout: Some(0), prevout: None }], - vout: vec![VerboseVout { value: 0.00099, n: 0, script_pub_key: spk("ltc1qbob") }], + vin: vec![VerboseVin { + txid: Some("fund".into()), + vout: Some(0), + prevout: None, + }], + vout: vec![VerboseVout { + value: 0.00099, + n: 0, + script_pub_key: spk("ltc1qbob"), + }], }; assert!(is_verbose_tx_spend_of(&tx, "ltc1qtip").unwrap()); } @@ -713,10 +748,22 @@ mod tests { // recv != 0 → not a spend (guards the funding-with-change case). let tx = VerboseTransaction { txid: "pay".into(), - vin: vec![VerboseVin { txid: Some("payer_prev".into()), vout: Some(0), prevout: None }], + vin: vec![VerboseVin { + txid: Some("payer_prev".into()), + vout: Some(0), + prevout: None, + }], vout: vec![ - VerboseVout { value: 0.5, n: 0, script_pub_key: spk("ltc1qtip") }, - VerboseVout { value: 0.3, n: 1, script_pub_key: spk("ltc1qpayer") }, + VerboseVout { + value: 0.5, + n: 0, + script_pub_key: spk("ltc1qtip"), + }, + VerboseVout { + value: 0.3, + n: 1, + script_pub_key: spk("ltc1qpayer"), + }, ], }; assert!(!is_verbose_tx_spend_of(&tx, "ltc1qtip").unwrap()); diff --git a/tests/admin_auth.rs b/tests/admin_auth.rs index 34761f7..c2ffd97 100644 --- a/tests/admin_auth.rs +++ b/tests/admin_auth.rs @@ -92,7 +92,7 @@ async fn admin_login_guard_refresh_logout_flow() { let app = require_app!(); let admin = app.admin_router(); - let secret = app.state.config.admin.key_integrity_secret.clone(); + let secret = app.state.cfg().admin.key_integrity_secret.clone(); // Seed a PENDING admin key for our signer. let sk = random_signer(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8b5766b..c32cf0e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -89,7 +89,8 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option { .enabled .then(|| AdminSessionManager::new(&config.admin.jwt_secret)); let state = Arc::new(AppState { - config, + config: Arc::new(arc_swap::ArcSwap::from_pointee(config.clone())), + config_base: Arc::new(config), db, sessions, chains, @@ -98,6 +99,7 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option { web_challenges: Arc::default(), prices, admin_sessions, + shutdown: Arc::default(), }); Some(TestApp { @@ -195,13 +197,14 @@ impl TestApp { extra_headers: &[(&str, &str)], body: Option, ) -> (StatusCode, axum::http::HeaderMap, Value) { - let mut builder = Request::builder() - .method(method) - .uri(uri) - .extension(axum::extract::ConnectInfo(std::net::SocketAddr::from(( - [127, 0, 0, 1], - 0, - )))); + let mut builder = + Request::builder() + .method(method) + .uri(uri) + .extension(axum::extract::ConnectInfo(std::net::SocketAddr::from(( + [127, 0, 0, 1], + 0, + )))); for (k, v) in extra_headers { builder = builder.header(*k, *v); } @@ -258,7 +261,7 @@ impl TestApp { .expect("mint token pair"); let hash = hash_refresh_token( &pair.refresh_token, - &self.state.config.secrets.refresh_token_pepper, + &self.state.cfg().secrets.refresh_token_pepper, ); self.state .db diff --git a/tests/erasure.rs b/tests/erasure.rs index f28606b..d0030d6 100644 --- a/tests/erasure.rs +++ b/tests/erasure.rs @@ -89,7 +89,7 @@ async fn erasure_request_confirm_execute_flow() { let app = require_app!(); let base = app .state - .config + .cfg() .identity .public_api_url .clone() @@ -221,7 +221,7 @@ async fn erasure_request_confirm_execute_flow() { assert!(body["erasure_id"].as_str().is_some()); assert_eq!(body["status"], "pending"); - let secret = app.state.config.admin.key_integrity_secret.clone(); + let secret = app.state.cfg().admin.key_integrity_secret.clone(); // [HIGH regression] a request CANCELLED after confirmation is NOT executed: // execute claims `WHERE status='confirmed'`, so a cancelled row is skipped. diff --git a/tests/nostr_auth.rs b/tests/nostr_auth.rs index 25de3e2..c091a31 100644 --- a/tests/nostr_auth.rs +++ b/tests/nostr_auth.rs @@ -78,7 +78,7 @@ async fn link_challenge_then_link_resolves_and_rejects_replay_and_wrong_user() { let app = require_app!(); let base = app .state - .config + .cfg() .identity .public_api_url .clone() diff --git a/tests/nostr_register.rs b/tests/nostr_register.rs index ba77cf8..fff124c 100644 --- a/tests/nostr_register.rs +++ b/tests/nostr_register.rs @@ -88,7 +88,7 @@ async fn register_creates_user_by_npub_and_is_idempotent() { let app = require_app!(); let base = app .state - .config + .cfg() .identity .public_api_url .clone() @@ -120,7 +120,7 @@ async fn register_replays_are_rejected() { let app = require_app!(); let base = app .state - .config + .cfg() .identity .public_api_url .clone() @@ -138,14 +138,23 @@ async fn register_replays_are_rejected() { "keys": [{ "asset": "btc", "public_key": format!("btcpub-{}", &pk[..16]) }], }); let (s1, _) = app - .request("POST", "/api/v1/auth/nostr/register", None, Some(body.clone())) + .request( + "POST", + "/api/v1/auth/nostr/register", + None, + Some(body.clone()), + ) .await; assert_eq!(s1, StatusCode::OK); // Same nonce again -> consumed -> rejected (single-use replay guard). let (s2, _) = app .request("POST", "/api/v1/auth/nostr/register", None, Some(body)) .await; - assert_ne!(s2, StatusCode::OK, "a consumed nonce must not register again"); + assert_ne!( + s2, + StatusCode::OK, + "a consumed nonce must not register again" + ); } #[tokio::test] @@ -160,7 +169,7 @@ async fn register_refuses_to_bind_npub_onto_existing_seed_fingerprint_row_withou let app = require_app!(); let base = app .state - .config + .cfg() .identity .public_api_url .clone() diff --git a/tests/registration_atomicity.rs b/tests/registration_atomicity.rs index 056dac1..b548d8f 100644 --- a/tests/registration_atomicity.rs +++ b/tests/registration_atomicity.rs @@ -35,7 +35,9 @@ async fn happy_path_consumes_tokens_creates_user_and_persists_keys() { let db = &app.state.db; let hash = hash_invite_code(&generate_invite_code()); - db.insert_invite_code(&hash, Some("atomic-test")).await.unwrap(); + db.insert_invite_code(&hash, Some("atomic-test")) + .await + .unwrap(); let pkh = format!("pay-pkh-{}", Uuid::new_v4()); let invoice = format!("inv-{}", Uuid::new_v4()); @@ -76,14 +78,19 @@ async fn username_collision_rolls_back_the_invite() { // A fresh, claimable invite. let hash = hash_invite_code(&generate_invite_code()); - db.insert_invite_code(&hash, Some("atomic-test")).await.unwrap(); + db.insert_invite_code(&hash, Some("atomic-test")) + .await + .unwrap(); // Register a NEW wallet that collides on the taken username. let err = db .create_user_consuming_gates(Some(&hash), None, new_user(Some(&taken)), &keys()) .await .expect_err("a colliding username must fail the registration"); - assert!(matches!(err, AppError::Conflict(_)), "expected 409, got {err:?}"); + assert!( + matches!(err, AppError::Conflict(_)), + "expected 409, got {err:?}" + ); // The invite must NOT have been burned — the tx rolled back. assert!( @@ -99,7 +106,9 @@ async fn failed_payment_consume_does_not_burn_the_invite() { // A fresh, claimable invite. let hash = hash_invite_code(&generate_invite_code()); - db.insert_invite_code(&hash, Some("atomic-test")).await.unwrap(); + db.insert_invite_code(&hash, Some("atomic-test")) + .await + .unwrap(); // A payment invoice that is ALREADY spent (simulates losing the consume race // in `all` mode after the invite was claimed). diff --git a/tests/relay_admission.rs b/tests/relay_admission.rs index 2322dda..b71740e 100644 --- a/tests/relay_admission.rs +++ b/tests/relay_admission.rs @@ -88,7 +88,7 @@ async fn is_registered_npub_membership() { #[tokio::test] async fn admission_inbox_outbox_end_to_end() { let app = require_app!(); - let svc = AdmissionService::new(inbox_outbox_relay(&app.state.config), app.state.db.clone()); + let svc = AdmissionService::new(inbox_outbox_relay(&app.state.cfg()), app.state.db.clone()); let author = hex64("b1"); // registered author (outbox) let recipient = hex64("b2"); // registered recipient (inbox) diff --git a/tests/tips.rs b/tests/tips.rs index a0f8e75..21e9471 100644 --- a/tests/tips.rs +++ b/tests/tips.rs @@ -42,7 +42,12 @@ async fn public_tip_draft_create_get_cancel_flow() { // 1. Create a draft (no funding_txid) -> draft + share_url. let (status, body) = app - .request("POST", "/api/v1/tips/social", Some(&token), Some(draft_body())) + .request( + "POST", + "/api/v1/tips/social", + Some(&token), + Some(draft_body()), + ) .await; assert_eq!(status, StatusCode::OK, "create: {body}"); assert_eq!(body["status"], "draft"); @@ -51,7 +56,12 @@ async fn public_tip_draft_create_get_cancel_flow() { // 2. Public read (UNAUTH) surfaces it and is not yet claimable. let (status, pub_body) = app - .request("GET", &format!("/api/v1/tips/social/{tip_id}/public"), None, None) + .request( + "GET", + &format!("/api/v1/tips/social/{tip_id}/public"), + None, + None, + ) .await; assert_eq!(status, StatusCode::OK, "get_public: {pub_body}"); assert_eq!(pub_body["is_public"], true); @@ -72,14 +82,24 @@ async fn public_tip_draft_create_get_cancel_flow() { // 4. Cancel the draft (owner) -> {ok:true}. let (status, cancel_body) = app - .request("POST", &format!("/api/v1/tips/social/{tip_id}/cancel"), Some(&token), None) + .request( + "POST", + &format!("/api/v1/tips/social/{tip_id}/cancel"), + Some(&token), + None, + ) .await; assert_eq!(status, StatusCode::OK, "cancel: {cancel_body}"); assert_eq!(cancel_body["ok"], true); // 5. Cancelling again -> 404 (no longer a draft). let (status, _) = app - .request("POST", &format!("/api/v1/tips/social/{tip_id}/cancel"), Some(&token), None) + .request( + "POST", + &format!("/api/v1/tips/social/{tip_id}/cancel"), + Some(&token), + None, + ) .await; assert_eq!(status, StatusCode::NOT_FOUND); @@ -89,7 +109,10 @@ async fn public_tip_draft_create_get_cancel_flow() { .await; assert_eq!(status, StatusCode::OK, "sent: {sent}"); let tips = sent["tips"].as_array().expect("tips array"); - let found = tips.iter().find(|t| t["id"] == tip_id).expect("tip in sent list"); + let found = tips + .iter() + .find(|t| t["id"] == tip_id) + .expect("tip in sent list"); assert_eq!(found["status"], "cancelled"); assert_eq!(found["is_public"], true); } @@ -113,7 +136,12 @@ async fn db_create_draft_roundtrips_all_columns() { grin_commitment: None, }; // Exercises the full 32-column FromRow decode on RETURNING. - let row = app.state.db.create_draft_social_tip(new).await.expect("create draft"); + let row = app + .state + .db + .create_draft_social_tip(new) + .await + .expect("create draft"); assert_eq!(row.status, "draft"); assert_eq!(row.amount, 100_000); assert!(row.is_public); @@ -136,7 +164,11 @@ async fn targeted_tip_is_rejected() { })), ) .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "targeted must be rejected: {body}"); + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "targeted must be rejected: {body}" + ); } #[tokio::test] @@ -147,13 +179,17 @@ async fn create_requires_positive_amount_and_claim_hash() { // Non-positive amount. let mut b = draft_body(); b["amount"] = json!(0); - let (status, _) = app.request("POST", "/api/v1/tips/social", Some(&token), Some(b)).await; + let (status, _) = app + .request("POST", "/api/v1/tips/social", Some(&token), Some(b)) + .await; assert_eq!(status, StatusCode::BAD_REQUEST); // Missing claim_key_hash on a public tip. let mut b = draft_body(); b.as_object_mut().unwrap().remove("claim_key_hash"); - let (status, _) = app.request("POST", "/api/v1/tips/social", Some(&token), Some(b)).await; + let (status, _) = app + .request("POST", "/api/v1/tips/social", Some(&token), Some(b)) + .await; assert_eq!(status, StatusCode::BAD_REQUEST); } @@ -166,7 +202,12 @@ async fn tips_disabled_returns_400() { }; let (_uid, token, _r) = app.mint_session().await; let (status, _) = app - .request("POST", "/api/v1/tips/social", Some(&token), Some(draft_body())) + .request( + "POST", + "/api/v1/tips/social", + Some(&token), + Some(draft_body()), + ) .await; assert_eq!(status, StatusCode::BAD_REQUEST, "tips off must 400"); } @@ -253,7 +294,10 @@ async fn attach_funding_conflicting_txid_is_validation_error() { .attach_funding_to_tip(tip_id, uid, "txid-DIFFERENT") .await .unwrap_err(); - assert!(matches!(err, AppError::ValidationError(_)), "expected ValidationError, got {err:?}"); + assert!( + matches!(err, AppError::ValidationError(_)), + "expected ValidationError, got {err:?}" + ); // Not-owned / unknown tip -> NotFound (404). let other = app.create_user().await; @@ -263,7 +307,10 @@ async fn attach_funding_conflicting_txid_is_validation_error() { .attach_funding_to_tip(tip_id, other, "txid-x") .await .unwrap_err(); - assert!(matches!(err, AppError::NotFound(_)), "expected NotFound, got {err:?}"); + assert!( + matches!(err, AppError::NotFound(_)), + "expected NotFound, got {err:?}" + ); } #[tokio::test] @@ -344,7 +391,10 @@ async fn mark_mismatch_transitions_to_funding_mismatch() { .mark_tip_funding_verified(tip_id, 10) .await .expect("verify after mismatch"); - assert!(verify_after.is_none(), "a funding_mismatch row must not become claimable"); + assert!( + verify_after.is_none(), + "a funding_mismatch row must not become claimable" + ); } #[tokio::test] @@ -385,7 +435,10 @@ async fn get_tips_pending_confirmation_filters() { .get_tips_pending_confirmation("xmr") .await .expect("xmr pending"); - assert!(xmr_pending.iter().any(|t| t.id == xmr_id), "funded xmr tip must be returned"); + assert!( + xmr_pending.iter().any(|t| t.id == xmr_id), + "funded xmr tip must be returned" + ); assert!( !xmr_pending.iter().any(|t| t.id == xmr_draft), "unfunded xmr draft must be excluded" @@ -477,45 +530,96 @@ async fn mark_tip_claiming_double_claim_guard() { // Fresh claimable tip: first claim locks it into 'claiming'. let id = make_pending(&app, sender, "fund-claim-1").await; - let first = app.state.db.mark_tip_claiming(id, claimant).await.expect("claim"); + let first = app + .state + .db + .mark_tip_claiming(id, claimant) + .await + .expect("claim"); assert!(first.is_some(), "first claim on a pending tip must succeed"); assert_eq!(first.unwrap().status, "claiming"); // Re-claim on a 'claiming' public tip is INTENTIONALLY allowed: the claim // state is a UX signal, not a lock — the reconciler resolves the on-chain // sweep-race winner. (Verbatim audit-T1 retry semantics.) - let reclaim = app.state.db.mark_tip_claiming(id, claimant).await.expect("reclaim"); - assert!(reclaim.is_some(), "re-claim on a claiming public tip is allowed"); + let reclaim = app + .state + .db + .mark_tip_claiming(id, claimant) + .await + .expect("reclaim"); + assert!( + reclaim.is_some(), + "re-claim on a claiming public tip is allowed" + ); // None on funding_mismatch (audit-T1 fund-loss guard). let mm = make_draft(&app, sender, "btc", 1_000_000, 0, None).await; - app.state.db.attach_funding_to_tip(mm, sender, "fund-mm").await.expect("attach"); - app.state.db.mark_tip_funding_mismatch(mm, 10).await.expect("mismatch").expect("row"); + app.state + .db + .attach_funding_to_tip(mm, sender, "fund-mm") + .await + .expect("attach"); + app.state + .db + .mark_tip_funding_mismatch(mm, 10) + .await + .expect("mismatch") + .expect("row"); assert!( - app.state.db.mark_tip_claiming(mm, claimant).await.expect("claim mm").is_none(), + app.state + .db + .mark_tip_claiming(mm, claimant) + .await + .expect("claim mm") + .is_none(), "a funding_mismatch tip must never be claimable" ); // None on an unverified (pending_confirmation) tip. let pc = make_draft(&app, sender, "btc", 100_000, 0, None).await; - app.state.db.attach_funding_to_tip(pc, sender, "fund-pc").await.expect("attach"); + app.state + .db + .attach_funding_to_tip(pc, sender, "fund-pc") + .await + .expect("attach"); assert!( - app.state.db.mark_tip_claiming(pc, claimant).await.expect("claim pc").is_none(), + app.state + .db + .mark_tip_claiming(pc, claimant) + .await + .expect("claim pc") + .is_none(), "an unverified tip must not be claimable" ); // None on a clawed_back tip. let cb = make_pending(&app, sender, "fund-cb").await; - app.state.db.clawback_social_tip(cb, sender).await.expect("clawback").expect("row"); + app.state + .db + .clawback_social_tip(cb, sender) + .await + .expect("clawback") + .expect("row"); assert!( - app.state.db.mark_tip_claiming(cb, claimant).await.expect("claim cb").is_none(), + app.state + .db + .mark_tip_claiming(cb, claimant) + .await + .expect("claim cb") + .is_none(), "a clawed_back tip must not be claimable" ); // None on an already-swept (settled) tip. let swept = make_claimed(&app, sender, claimant, "fund-swept").await; assert!( - app.state.db.mark_tip_claiming(swept, claimant).await.expect("claim swept").is_none(), + app.state + .db + .mark_tip_claiming(swept, claimant) + .await + .expect("claim swept") + .is_none(), "a settled tip must not be re-claimable" ); } @@ -531,12 +635,27 @@ async fn confirm_tip_sweep_first_recorder_wins() { let id = make_claiming(&app, sender, claimant, "fund-sweep").await; // First recorder writes txid-A; the row STAYS 'claiming' (record, not settle). - let r1 = app.state.db.confirm_tip_sweep(id, claimant, "txid-A").await.expect("record").expect("row"); + let r1 = app + .state + .db + .confirm_tip_sweep(id, claimant, "txid-A") + .await + .expect("record") + .expect("row"); assert_eq!(r1.sweep_txid.as_deref(), Some("txid-A")); - assert_eq!(r1.status, "claiming", "confirm_tip_sweep records, never settles"); + assert_eq!( + r1.status, "claiming", + "confirm_tip_sweep records, never settles" + ); // A DIFFERENT txid does not overwrite; fallthrough returns the live row (A). - let r2 = app.state.db.confirm_tip_sweep(id, claimant, "txid-B").await.expect("second").expect("live row"); + let r2 = app + .state + .db + .confirm_tip_sweep(id, claimant, "txid-B") + .await + .expect("second") + .expect("live row"); assert_eq!( r2.sweep_txid.as_deref(), Some("txid-A"), @@ -545,13 +664,24 @@ async fn confirm_tip_sweep_first_recorder_wins() { assert_eq!(r2.status, "claiming"); // Same txid again is an idempotent no-op returning A. - let r3 = app.state.db.confirm_tip_sweep(id, claimant, "txid-A").await.expect("idem").expect("row"); + let r3 = app + .state + .db + .confirm_tip_sweep(id, claimant, "txid-A") + .await + .expect("idem") + .expect("row"); assert_eq!(r3.sweep_txid.as_deref(), Some("txid-A")); // A never-claimed (pending) tip → None. let pending = make_pending(&app, sender, "fund-cs-pending").await; assert!( - app.state.db.confirm_tip_sweep(pending, claimant, "txid-x").await.expect("cs pending").is_none(), + app.state + .db + .confirm_tip_sweep(pending, claimant, "txid-x") + .await + .expect("cs pending") + .is_none(), "confirm-sweep on a non-claiming tip returns None" ); } @@ -585,7 +715,12 @@ async fn confirm_sweep_onchain_settles_claiming_only() { // Second settle is a no-op (one-way lock). assert!( - app.state.db.confirm_sweep_onchain(id, "swp-2", 501, None, None).await.expect("resettle").is_none(), + app.state + .db + .confirm_sweep_onchain(id, "swp-2", 501, None, None) + .await + .expect("resettle") + .is_none(), "an already-claimed row cannot be re-settled" ); @@ -593,11 +728,25 @@ async fn confirm_sweep_onchain_settles_claiming_only() { // entry into 'claimed'. let pending = make_pending(&app, sender, "fund-settle-pending").await; assert!( - app.state.db.confirm_sweep_onchain(pending, "swp", 1, None, None).await.expect("settle pending").is_none(), + app.state + .db + .confirm_sweep_onchain(pending, "swp", 1, None, None) + .await + .expect("settle pending") + .is_none(), "confirm_sweep_onchain requires status='claiming'" ); - let still = app.state.db.get_social_tip(pending).await.expect("get").expect("row"); - assert_eq!(still.status, "pending", "a pending tip must not be flipped to claimed"); + let still = app + .state + .db + .get_social_tip(pending) + .await + .expect("get") + .expect("row"); + assert_eq!( + still.status, "pending", + "a pending tip must not be flipped to claimed" + ); } /// (d) revert_sweep_on_reorg reverts 'claimed' -> 'claiming' ONLY, clearing the @@ -612,7 +761,13 @@ async fn revert_sweep_on_reorg_only_from_claimed() { let claimant = app.create_user().await; let id = make_claimed(&app, sender, claimant, "fund-reorg").await; - let reverted = app.state.db.revert_sweep_on_reorg(id).await.expect("revert").expect("reverted row"); + let reverted = app + .state + .db + .revert_sweep_on_reorg(id) + .await + .expect("revert") + .expect("reverted row"); assert_eq!(reverted.status, "claiming"); assert!(reverted.sweep_confirmed_at.is_none()); assert_eq!( @@ -624,14 +779,24 @@ async fn revert_sweep_on_reorg_only_from_claimed() { // Second revert is a no-op (now 'claiming', not 'claimed'). assert!( - app.state.db.revert_sweep_on_reorg(id).await.expect("re-revert").is_none(), + app.state + .db + .revert_sweep_on_reorg(id) + .await + .expect("re-revert") + .is_none(), "revert requires status='claimed'" ); // Revert on a plain pending tip → None. let pending = make_pending(&app, sender, "fund-reorg-pending").await; assert!( - app.state.db.revert_sweep_on_reorg(pending).await.expect("revert pending").is_none(), + app.state + .db + .revert_sweep_on_reorg(pending) + .await + .expect("revert pending") + .is_none(), "a pending tip cannot be reorg-reverted" ); } @@ -647,41 +812,105 @@ async fn clawback_status_matrix_and_settled_block() { // pending → clawable. let pending = make_pending(&app, sender, "cb-pending").await; - assert!(app.state.db.clawback_social_tip(pending, sender).await.expect("cb").is_some()); + assert!(app + .state + .db + .clawback_social_tip(pending, sender) + .await + .expect("cb") + .is_some()); // pending_confirmation → clawable. let pc = make_draft(&app, sender, "btc", 100_000, 0, None).await; - app.state.db.attach_funding_to_tip(pc, sender, "cb-pc").await.expect("attach"); - assert!(app.state.db.clawback_social_tip(pc, sender).await.expect("cb").is_some()); + app.state + .db + .attach_funding_to_tip(pc, sender, "cb-pc") + .await + .expect("attach"); + assert!(app + .state + .db + .clawback_social_tip(pc, sender) + .await + .expect("cb") + .is_some()); // claiming → clawable (sender racing a claimer). let claiming = make_claiming(&app, sender, claimant, "cb-claiming").await; - assert!(app.state.db.clawback_social_tip(claiming, sender).await.expect("cb").is_some()); + assert!(app + .state + .db + .clawback_social_tip(claiming, sender) + .await + .expect("cb") + .is_some()); // funding_mismatch → clawable. let mm = make_draft(&app, sender, "btc", 1_000_000, 0, None).await; - app.state.db.attach_funding_to_tip(mm, sender, "cb-mm").await.expect("attach"); - app.state.db.mark_tip_funding_mismatch(mm, 10).await.expect("mismatch").expect("row"); - assert!(app.state.db.clawback_social_tip(mm, sender).await.expect("cb").is_some()); + app.state + .db + .attach_funding_to_tip(mm, sender, "cb-mm") + .await + .expect("attach"); + app.state + .db + .mark_tip_funding_mismatch(mm, 10) + .await + .expect("mismatch") + .expect("row"); + assert!(app + .state + .db + .clawback_social_tip(mm, sender) + .await + .expect("cb") + .is_some()); // cancelled (GC'd but still-funded draft) → clawable. let cancelled = make_draft(&app, sender, "btc", 100_000, 0, None).await; - app.state.db.cancel_draft_social_tip(cancelled, sender).await.expect("cancel").expect("id"); - assert!(app.state.db.clawback_social_tip(cancelled, sender).await.expect("cb").is_some()); + app.state + .db + .cancel_draft_social_tip(cancelled, sender) + .await + .expect("cancel") + .expect("id"); + assert!(app + .state + .db + .clawback_social_tip(cancelled, sender) + .await + .expect("cb") + .is_some()); // Owner-scoped: a stranger cannot claw back; the real sender can. let owned = make_pending(&app, sender, "cb-owner").await; let stranger = app.create_user().await; assert!( - app.state.db.clawback_social_tip(owned, stranger).await.expect("cb stranger").is_none(), + app.state + .db + .clawback_social_tip(owned, stranger) + .await + .expect("cb stranger") + .is_none(), "only the sender can claw back" ); - assert!(app.state.db.clawback_social_tip(owned, sender).await.expect("cb owner").is_some()); + assert!(app + .state + .db + .clawback_social_tip(owned, sender) + .await + .expect("cb owner") + .is_some()); // BLOCKED once the sweep confirmed (sweep_confirmed_at IS NOT NULL). let swept = make_claimed(&app, sender, claimant, "cb-swept").await; assert!( - app.state.db.clawback_social_tip(swept, sender).await.expect("cb swept").is_none(), + app.state + .db + .clawback_social_tip(swept, sender) + .await + .expect("cb swept") + .is_none(), "a settled tip must not be clawed back" ); } @@ -698,14 +927,39 @@ async fn reconciler_poll_queries_select_the_right_rows() { let pending = make_pending(&app, sender, "poll-pending").await; let claimed = make_claimed(&app, sender, claimant, "poll-claimed").await; - let awaiting = app.state.db.get_tips_awaiting_sweep_confirmation(1_000_000).await.expect("awaiting"); - assert!(awaiting.iter().any(|t| t.id == claiming), "a claiming tip awaits sweep confirmation"); - assert!(!awaiting.iter().any(|t| t.id == pending), "a pending tip does not await sweep confirmation"); - assert!(!awaiting.iter().any(|t| t.id == claimed), "a settled tip does not await sweep confirmation"); + let awaiting = app + .state + .db + .get_tips_awaiting_sweep_confirmation(1_000_000) + .await + .expect("awaiting"); + assert!( + awaiting.iter().any(|t| t.id == claiming), + "a claiming tip awaits sweep confirmation" + ); + assert!( + !awaiting.iter().any(|t| t.id == pending), + "a pending tip does not await sweep confirmation" + ); + assert!( + !awaiting.iter().any(|t| t.id == claimed), + "a settled tip does not await sweep confirmation" + ); - let reorg = app.state.db.get_settled_tips_for_reorg_check(1_000_000).await.expect("reorg"); - assert!(reorg.iter().any(|t| t.id == claimed), "a settled tip with a height witness is reorg-checked"); - assert!(!reorg.iter().any(|t| t.id == claiming), "a claiming tip is not reorg-checked"); + let reorg = app + .state + .db + .get_settled_tips_for_reorg_check(1_000_000) + .await + .expect("reorg"); + assert!( + reorg.iter().any(|t| t.id == claimed), + "a settled tip with a height witness is reorg-checked" + ); + assert!( + !reorg.iter().any(|t| t.id == claiming), + "a claiming tip is not reorg-checked" + ); } // ── Stage 5: lifecycle + draft garbage-collection DB tests ──────────────────── @@ -737,13 +991,36 @@ async fn cancel_old_drafts_flips_stale_draft_only() { backdate_created_at(&app, stale, "8 days").await; let fresh = make_draft(&app, uid, "btc", 100_000, 0, None).await; - let cancelled = app.state.db.cancel_old_drafts().await.expect("cancel_old_drafts"); - assert!(cancelled.iter().any(|r| r.id == stale), "a > 7d draft must be cancelled"); - assert!(!cancelled.iter().any(|r| r.id == fresh), "a fresh draft must NOT be cancelled"); + let cancelled = app + .state + .db + .cancel_old_drafts() + .await + .expect("cancel_old_drafts"); + assert!( + cancelled.iter().any(|r| r.id == stale), + "a > 7d draft must be cancelled" + ); + assert!( + !cancelled.iter().any(|r| r.id == fresh), + "a fresh draft must NOT be cancelled" + ); - let stale_row = app.state.db.get_social_tip(stale).await.expect("get").expect("row"); + let stale_row = app + .state + .db + .get_social_tip(stale) + .await + .expect("get") + .expect("row"); assert_eq!(stale_row.status, "cancelled"); - let fresh_row = app.state.db.get_social_tip(fresh).await.expect("get").expect("row"); + let fresh_row = app + .state + .db + .get_social_tip(fresh) + .await + .expect("get") + .expect("row"); assert_eq!(fresh_row.status, "draft", "a fresh draft stays draft"); } @@ -755,11 +1032,19 @@ async fn cancel_stuck_pending_confirmation_flips_stale_only() { let uid = app.create_user().await; let stale = make_draft(&app, uid, "btc", 100_000, 0, None).await; - app.state.db.attach_funding_to_tip(stale, uid, "gc-stale-pc").await.expect("attach"); + app.state + .db + .attach_funding_to_tip(stale, uid, "gc-stale-pc") + .await + .expect("attach"); backdate_created_at(&app, stale, "8 days").await; let fresh = make_draft(&app, uid, "btc", 100_000, 0, None).await; - app.state.db.attach_funding_to_tip(fresh, uid, "gc-fresh-pc").await.expect("attach"); + app.state + .db + .attach_funding_to_tip(fresh, uid, "gc-fresh-pc") + .await + .expect("attach"); let cancelled = app .state @@ -776,9 +1061,21 @@ async fn cancel_stuck_pending_confirmation_flips_stale_only() { "a fresh pending_confirmation must NOT be cancelled" ); - let stale_row = app.state.db.get_social_tip(stale).await.expect("get").expect("row"); + let stale_row = app + .state + .db + .get_social_tip(stale) + .await + .expect("get") + .expect("row"); assert_eq!(stale_row.status, "cancelled"); - let fresh_row = app.state.db.get_social_tip(fresh).await.expect("get").expect("row"); + let fresh_row = app + .state + .db + .get_social_tip(fresh) + .await + .expect("get") + .expect("row"); assert_eq!(fresh_row.status, "pending_confirmation"); } @@ -798,14 +1095,37 @@ async fn log_stuck_claiming_returns_backdated_and_mutates_nothing() { .expect("backdate claimed_at"); let fresh = make_claiming(&app, sender, claimant, "gc-fresh-claiming").await; - let stuck = app.state.db.log_stuck_claiming().await.expect("log_stuck_claiming"); - assert!(stuck.iter().any(|r| r.id == stale), "a > 15m claiming row must be surfaced"); - assert!(!stuck.iter().any(|r| r.id == fresh), "a fresh claiming row must NOT be surfaced"); + let stuck = app + .state + .db + .log_stuck_claiming() + .await + .expect("log_stuck_claiming"); + assert!( + stuck.iter().any(|r| r.id == stale), + "a > 15m claiming row must be surfaced" + ); + assert!( + !stuck.iter().any(|r| r.id == fresh), + "a fresh claiming row must NOT be surfaced" + ); // READ-ONLY: the surfaced row is untouched — still 'claiming', no sweep confirm. - let row = app.state.db.get_social_tip(stale).await.expect("get").expect("row"); - assert_eq!(row.status, "claiming", "log_stuck_claiming must not mutate status"); - assert!(row.sweep_confirmed_at.is_none(), "log_stuck_claiming must not settle the sweep"); + let row = app + .state + .db + .get_social_tip(stale) + .await + .expect("get") + .expect("row"); + assert_eq!( + row.status, "claiming", + "log_stuck_claiming must not mutate status" + ); + assert!( + row.sweep_confirmed_at.is_none(), + "log_stuck_claiming must not settle the sweep" + ); } // ── Grin (voucher asset) tests ──────────────────────────────────────────────── @@ -865,7 +1185,13 @@ async fn grin_public_tip_create_returns_draft_and_share_url() { // Migration applied: the grin_commitment column round-trips, tip_address // mirrors it, and the grin funding threshold is 10. let uuid = tip_id.parse::().unwrap(); - let row = app.state.db.get_social_tip(uuid).await.expect("get").expect("row"); + let row = app + .state + .db + .get_social_tip(uuid) + .await + .expect("get") + .expect("row"); assert_eq!(row.asset, "grin"); assert_eq!(row.grin_commitment.as_deref(), Some(commit.as_str())); assert_eq!(row.tip_address.as_deref(), Some(commit.as_str())); @@ -874,7 +1200,9 @@ async fn grin_public_tip_create_returns_draft_and_share_url() { #[tokio::test] async fn grin_create_falls_back_to_tip_address_for_commitment() { - let Some(app) = grin_tips_app().await else { return }; + let Some(app) = grin_tips_app().await else { + return; + }; let (_uid, token, _refresh) = app.mint_session().await; let commit = grin_commit(); @@ -890,22 +1218,38 @@ async fn grin_create_falls_back_to_tip_address_for_commitment() { let (status, resp) = app .request("POST", "/api/v1/tips/social", Some(&token), Some(body)) .await; - assert_eq!(status, StatusCode::OK, "create grin (no commit field): {resp}"); - let uuid = resp["tip_id"].as_str().unwrap().parse::().unwrap(); - let row = app.state.db.get_social_tip(uuid).await.expect("get").expect("row"); + assert_eq!( + status, + StatusCode::OK, + "create grin (no commit field): {resp}" + ); + let uuid = resp["tip_id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + let row = app + .state + .db + .get_social_tip(uuid) + .await + .expect("get") + .expect("row"); assert_eq!(row.grin_commitment.as_deref(), Some(commit.as_str())); } #[tokio::test] async fn grin_create_rejects_junk_commitment() { - let Some(app) = grin_tips_app().await else { return }; + let Some(app) = grin_tips_app().await else { + return; + }; let (_uid, token, _refresh) = app.mint_session().await; // tip_address that isn't a 66-hex commitment -> clean 400. for bad in [ - "deadbeef".to_string(), // too short - "z".repeat(66), // right length, not hex - format!("09{}", "a1".repeat(33)), // 68 chars, too long + "deadbeef".to_string(), // too short + "z".repeat(66), // right length, not hex + format!("09{}", "a1".repeat(33)), // 68 chars, too long ] { let body = json!({ "asset": "grin", @@ -917,7 +1261,11 @@ async fn grin_create_rejects_junk_commitment() { let (status, resp) = app .request("POST", "/api/v1/tips/social", Some(&token), Some(body)) .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "junk grin address must 400: {resp}"); + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "junk grin address must 400: {resp}" + ); } // A valid tip_address but a junk explicit grin_commitment is also rejected @@ -933,7 +1281,11 @@ async fn grin_create_rejects_junk_commitment() { let (status, resp) = app .request("POST", "/api/v1/tips/social", Some(&token), Some(body)) .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "junk grin_commitment must 400: {resp}"); + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "junk grin_commitment must 400: {resp}" + ); } #[tokio::test] @@ -945,7 +1297,8 @@ async fn grin_create_rejected_when_chain_disabled() { c.features.chains.grin = false; c.tip_share_base = Some("https://tips.example".into()); }) - .await else { + .await + else { return; }; let (_uid, token, _refresh) = app.mint_session().await; @@ -961,7 +1314,11 @@ async fn grin_create_rejected_when_chain_disabled() { let (status, resp) = app .request("POST", "/api/v1/tips/social", Some(&token), Some(body)) .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "grin disabled must 400: {resp}"); + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "grin disabled must 400: {resp}" + ); } /// The grin voucher flows through the SAME draft -> attach-funding -> claiming @@ -971,7 +1328,9 @@ async fn grin_create_rejected_when_chain_disabled() { /// the grin worker arms depend on. #[tokio::test] async fn grin_tip_db_lifecycle_columns() { - let Some(app) = grin_tips_app().await else { return }; + let Some(app) = grin_tips_app().await else { + return; + }; let uid = app.create_user().await; let commit = grin_commit(); @@ -988,7 +1347,13 @@ async fn grin_tip_db_lifecycle_columns() { confirmations_required: 10, grin_commitment: Some(&commit), }; - let tip_id = app.state.db.create_draft_social_tip(new).await.expect("create draft").id; + let tip_id = app + .state + .db + .create_draft_social_tip(new) + .await + .expect("create draft") + .id; // attach-funding with the client's bookkeeping id (grin has no real txid). let r = app @@ -1001,7 +1366,11 @@ async fn grin_tip_db_lifecycle_columns() { assert_eq!(r.grin_commitment.as_deref(), Some(commit.as_str())); // Simulate the funding worker: 10 confs reached + amount auto-verified. - app.state.db.update_tip_confirmations(tip_id, 10).await.expect("confs"); + app.state + .db + .update_tip_confirmations(tip_id, 10) + .await + .expect("confs"); let verified = app .state .db @@ -1014,7 +1383,12 @@ async fn grin_tip_db_lifecycle_columns() { // Claim, then record the sweep's kernel excess as sweep_txid. let claimant = app.create_user().await; - app.state.db.mark_tip_claiming(tip_id, claimant).await.expect("claim").expect("row"); + app.state + .db + .mark_tip_claiming(tip_id, claimant) + .await + .expect("claim") + .expect("row"); let excess = format!("08{}", "bc".repeat(32)); // kernel excess (66 hex) let swept = app .state diff --git a/tools/secret-scan.sh b/tools/secret-scan.sh index 9d93b04..a5b3762 100755 --- a/tools/secret-scan.sh +++ b/tools/secret-scan.sh @@ -17,8 +17,12 @@ else mapfile -t FILES < <(find . -type f -not -path './.git/*' -not -path './target/*' -printf '%P\n') fi -# Secret shapes (not names): PEM/OpenSSH/PGP private-key blocks and seed phrases. -PATTERNS='(-----BEGIN ([A-Z ]+ )?PRIVATE KEY-----|-----BEGIN PGP PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY|\b(mnemonic|seed[_ ]?phrase)\b\s*[:=])' +# Secret shapes (not names), covering the classes this backend actually handles: +# - PEM/OpenSSH/PGP private-key blocks and seed-phrase markers +# - nsec1... Nostr SECRET keys (bech32) +# - a *_PEPPER / *_SALT (or admin key/secret) assigned a 32+ hex-char value, i.e. +# an `openssl rand -hex 32` peppered secret pasted in cleartext +PATTERNS='(-----BEGIN ([A-Z ]+ )?PRIVATE KEY-----|-----BEGIN PGP PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY|\b(mnemonic|seed[_ ]?phrase)\b\s*[:=]|nsec1[0-9a-z]{20,}|\b[A-Z_]*(PEPPER|SALT|INTEGRITY_SECRET)[A-Z_]*\s*=\s*[0-9a-fA-F]{32,})' fail=0 for f in "${FILES[@]}"; do