Merge operator config layer + console + pre-ship audit fixes

Operator Console (embedded SPA at /admin, NIP-07 login) on a validated
defaults->env->DB settings-overlay layer (GET/PUT /admin/config, invites,
keys), plus the v0.3.0 pre-ship audit remediations: config hot-swap
desync fix, XMR/WOW amount precision (u64>2^53 as strings), operator docs,
example-recipe + env-var fixes, and scanner hardening.
This commit is contained in:
jwinterm
2026-07-20 06:59:24 -04:00
76 changed files with 4893 additions and 382 deletions
+85 -1
View File
@@ -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. <hash>.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
+20
View File
@@ -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
+4
View File
@@ -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/
Generated
+187 -33
View File
@@ -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"
+4 -1
View File
@@ -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"
+28
View File
@@ -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`.
+4
View File
@@ -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.
+6
View File
@@ -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).
File diff suppressed because one or more lines are too long
+1
View File
@@ -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)}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Smirk Operator Console</title>
<script type="module" crossorigin src="/admin/assets/index-7a1tzG-S.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-uKG4VNAS.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Smirk Operator Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1023
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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"
}
}
+82
View File
@@ -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<unknown> {
const headers: Record<string, string> = {};
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<ChallengeResp>,
verify: (admin_token: string, challenge: string) =>
req('POST', '/admin/auth/verify', { admin_token, challenge }) as Promise<TokenResp>,
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<string, number>) =>
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[];
}>,
};
+400
View File
@@ -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<string | null>(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 (
<div style={{ maxWidth: 420, margin: '12vh auto', padding: 24, textAlign: 'center' }}>
<div style={{ fontSize: 40 }}>🐸</div>
<h1>Smirk Operator Console</h1>
<p style={{ color: 'var(--smirk-fg-muted)' }}>
Sign in with an admin Nostr key on your instance's allowlist.
</p>
<button class="primary" style={{ padding: '12px 20px', fontSize: 16 }} disabled={busy} onClick={signIn}>
{busy ? 'Signing in…' : 'Sign in with Smirk'}
</button>
{err && <p style={{ color: 'var(--smirk-danger)', marginTop: 16 }}>{err}</p>}
</div>
);
}
// ── shared ────────────────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: unknown }) {
return (
<div
style={{
background: 'var(--smirk-bg-elevated)',
border: '1px solid var(--smirk-border)',
borderRadius: 'var(--smirk-radius)',
padding: 16,
marginBottom: 16,
}}
>
<h3 style={{ marginTop: 0 }}>{title}</h3>
{children}
</div>
);
}
function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
const [data, setData] = useState<T | null>(null);
const [err, setErr] = useState<string | null>(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 (
<Section title="Instance features">
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
<pre style={{ overflow: 'auto', fontSize: 12 }}>{JSON.stringify(data, null, 2)}</pre>
</Section>
);
}
// ── admin keys ────────────────────────────────────────────────────────────────
function KeysTab() {
const { data, err, reload } = useAsync(() => api.keys());
const [pubkey, setPubkey] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(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 (
<Section title="Admin keys">
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<tbody>
{(data ?? []).map((k: KeyRow) => (
<tr style={{ borderTop: '1px solid var(--smirk-border)' }}>
<td style={{ fontFamily: 'monospace', padding: '6px 4px' }}>{k.pubkey.slice(0, 16)}…</td>
<td style={{ padding: '6px 4px' }}>{k.status}</td>
<td style={{ padding: '6px 4px', textAlign: 'right' }}>
{k.status !== 'revoked' && (
<button class="danger" onClick={() => revoke(k.id)}>
Revoke
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input
style={{ flex: 1 }}
placeholder="new admin pubkey (64 hex)"
value={pubkey}
onInput={(e) => setPubkey((e.target as HTMLInputElement).value)}
/>
<button class="primary" disabled={busy || pubkey.trim().length !== 64} onClick={add}>
Add key
</button>
</div>
{msg && <p style={{ color: 'var(--smirk-danger)' }}>{msg}</p>}
</Section>
);
}
// ── invites ───────────────────────────────────────────────────────────────────
function InvitesTab() {
const { data, err, reload } = useAsync(() => api.invites());
const [count, setCount] = useState(5);
const [label, setLabel] = useState('');
const [minted, setMinted] = useState<string[] | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(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 (
<Section title={`Invite codes${data ? ` — ${data.unused_count} unused` : ''}`}>
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<input
type="number"
min={1}
max={1000}
style={{ width: 80 }}
value={count}
onInput={(e) => setCount(Number((e.target as HTMLInputElement).value))}
/>
<input
style={{ flex: 1 }}
placeholder="label (optional)"
value={label}
onInput={(e) => setLabel((e.target as HTMLInputElement).value)}
/>
<button class="primary" disabled={busy || count < 1} onClick={mint}>
Mint
</button>
</div>
{msg && <p style={{ color: 'var(--smirk-danger)' }}>{msg}</p>}
{minted && (
<div style={{ background: 'var(--smirk-bg-sunken)', padding: 12, borderRadius: 8, marginBottom: 12 }}>
<b>New codes — copy now, they are shown once:</b>
<pre style={{ margin: '6px 0 0', whiteSpace: 'pre-wrap' }}>{minted.join('\n')}</pre>
</div>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<tbody>
{(data?.invites ?? []).map((i: InviteView) => (
<tr style={{ borderTop: '1px solid var(--smirk-border)' }}>
<td style={{ fontFamily: 'monospace', padding: '6px 4px' }}>{i.code_prefix}…</td>
<td style={{ padding: '6px 4px' }}>{i.label ?? ''}</td>
<td style={{ padding: '6px 4px', color: 'var(--smirk-fg-muted)' }}>{i.created_at.slice(0, 10)}</td>
<td style={{ padding: '6px 4px' }}>{i.status}</td>
</tr>
))}
</tbody>
</table>
</Section>
);
}
// ── config ────────────────────────────────────────────────────────────────────
type Overlay = Record<string, Record<string, unknown>>;
interface ConfigResp {
effective: Overlay;
overlay: Overlay;
versions: Record<string, number>;
runtime_class: Record<string, string>;
restart_pending: boolean;
}
function ConfigTab() {
const { data, err, reload } = useAsync(() => api.config() as Promise<ConfigResp>);
const [edit, setEdit] = useState<Overlay>({});
const [msg, setMsg] = useState<string | null>(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 <Section title="Config">{<p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}</Section>;
if (!data) return <Section title="Config">Loading…</Section>;
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<string, unknown>;
const cur = (edit[section] ?? {}) as Record<string, unknown>;
const patch: Record<string, unknown> = {};
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 (
<div>
{data.restart_pending && (
<div
style={{
background: 'rgba(245,158,11,0.12)',
border: '1px solid var(--smirk-warn)',
borderRadius: 8,
padding: 10,
marginBottom: 16,
}}
>
⚠ A saved change needs a restart to take effect (systemctl restart, or auto mode).
</div>
)}
{msg && <p style={{ color: 'var(--smirk-accent)' }}>{msg}</p>}
{Object.keys(edit)
.sort()
.map((section) => (
<Section title={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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
<label style={{ flex: 1, minWidth: 0 }}>
{field}{' '}
<span style={{ fontSize: 11, color: 'var(--smirk-fg-muted)' }}>
· {cls === 'restart-required' ? 'restart' : 'live'}
{overridden ? ' · db' : ''}
</span>
</label>
{typeof value === 'boolean' ? (
<input
type="checkbox"
checked={value}
onChange={(e) => setField(section, field, (e.target as HTMLInputElement).checked)}
/>
) : (
<input
style={{ width: 220 }}
value={String(value ?? '')}
onInput={(e) => {
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);
}
}}
/>
)}
</div>
);
})}
<button class="primary" style={{ marginTop: 8 }} onClick={() => save(section)}>
Save {section}
</button>
</Section>
))}
</div>
);
}
// ── shell ─────────────────────────────────────────────────────────────────────
const TABS = ['Status', 'Keys', 'Invites', 'Config'] as const;
type Tab = (typeof TABS)[number];
function Console({ onLogout }: { onLogout: () => void }) {
const [tab, setTab] = useState<Tab>('Status');
return (
<div style={{ maxWidth: 760, margin: '0 auto', padding: 20 }}>
<header style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, flex: 1 }}>🐸 Smirk Operator Console</h2>
<button
onClick={() => {
api.logout().catch(() => {});
setToken(null);
onLogout();
}}
>
Sign out
</button>
</header>
<nav style={{ display: 'flex', gap: 6, marginBottom: 16 }}>
{TABS.map((t) => (
<button class={t === tab ? 'primary' : ''} onClick={() => setTab(t)}>
{t}
</button>
))}
</nav>
{tab === 'Status' && <StatusTab />}
{tab === 'Keys' && <KeysTab />}
{tab === 'Invites' && <InvitesTab />}
{tab === 'Config' && <ConfigTab />}
</div>
);
}
function App() {
const [authed, setAuthed] = useState(false);
return authed ? <Console onLogout={() => setAuthed(false)} /> : <Login onDone={() => setAuthed(true)} />;
}
render(<App />, document.getElementById('app')!);
+60
View File
@@ -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<string> {
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<string>;
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 <base64(event)>` token the backend's `admin_token` field expects.
*/
export async function buildAdminToken(c: Challenge, signer: Nip07): Promise<string> {
// 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;
}
+50
View File
@@ -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); }
+15
View File
@@ -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"]
}
+18
View File
@@ -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',
},
});
+219
View File
@@ -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=<openssl rand -hex 32>
ADMIN_KEY_INTEGRITY_SECRET=<openssl rand -hex 32> # 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=<hash>.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://<hash>.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
<x-only-hex>` 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.
+13
View File
@@ -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=<hash>.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
+18
View File
@@ -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*):
+13
View File
@@ -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=
+13
View File
@@ -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=
+13
View File
@@ -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=
+13
View File
@@ -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=
+13
View File
@@ -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=
+13
View File
@@ -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=
@@ -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
);
+10 -23
View File
@@ -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",
+480 -16
View File
@@ -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<Json<AdminTokenResponse>, 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<Json<OkResponse>, 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<String>) -> 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<Uuid>,
) -> Result<Json<OkResponse>, 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<Json<AdminFeaturesResponse>, 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<String, i64>,
/// "section.field" -> runtime-safe | restart-required.
runtime_class: std::collections::HashMap<String, crate::config_overlay::RuntimeClass>,
/// 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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<AdminConfigResponse>, 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<String, i64>,
}
#[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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<PutConfigRequest>,
) -> Result<Json<PutConfigResponse>, 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<String> = 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<u32>,
#[serde(default)]
label: Option<String>,
}
#[derive(serde::Serialize)]
pub struct MintInvitesResponse {
/// RAW single-use codes — shown ONCE (only the hash is stored, unrecoverable).
codes: Vec<String>,
}
#[instrument(skip(state, headers, req))]
pub async fn admin_invites_mint(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<MintInvitesRequest>,
) -> Result<Json<MintInvitesResponse>, 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<String>,
created_at: String,
/// "unused" | "used" | "expired".
status: String,
}
#[derive(serde::Serialize)]
pub struct InvitesListResponse {
invites: Vec<InviteView>,
unused_count: i64,
}
#[instrument(skip(state, headers))]
pub async fn admin_invites_list(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<InvitesListResponse>, 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<String>) -> 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<Arc<AppState>> {
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<Arc<AppState>> {
.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/<hash>.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/<real built asset> -> 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() {
+53 -29
View File
@@ -133,9 +133,17 @@ fn parse_asset(asset: &str) -> Result<AssetType, AppError> {
/// 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<Vec<(AssetType, String, Option<String>)>, AppError> {
fn parse_keys(
keys: &[AssetPublicKey],
) -> Result<Vec<(AssetType, String, Option<String>)>, 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,19 +178,15 @@ 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<String, AppError> {
let base = state
.config
.identity
.public_api_url
.as_deref()
.ok_or_else(|| {
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())
})?;
@@ -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<Arc<AppState>>,
) -> Result<Json<altcha::Challenge>, 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,8 +706,13 @@ 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<String, AppError> {
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<Option<String>, 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<Arc<AppState>>,
Json(req): Json<PaymentInvoiceRequest>,
) -> Result<Json<PaymentInvoiceResponse>, 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<Json<LogoutResponse>, 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<Arc<AppState>> {
.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]
+1 -1
View File
@@ -354,7 +354,7 @@ fn tips_advertised(config: &Config) -> bool {
)]
#[instrument(skip(state))]
pub async fn capabilities(State(state): State<Arc<AppState>>) -> Json<CapabilitiesResponse> {
Json(effective_capabilities(&state.config))
Json(effective_capabilities(&state.cfg()))
}
/// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth).
+14 -14
View File
@@ -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<String, AppError> {
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<ProofRequest>,
) -> Result<Json<ErasureRequestResponse>, 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<AppState>, batch: i64) -> Result<u64, AppError> {
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) => {}
+5 -9
View File
@@ -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<Arc<AppState>>) -> 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<Arc<AppState>>) -> 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!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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);
}
}
+5 -5
View File
@@ -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(),
+1 -1
View File
@@ -37,7 +37,7 @@ pub struct PricesResponse {
)]
#[instrument(skip(state))]
pub async fn prices(State(state): State<Arc<AppState>>) -> Result<Json<PricesResponse>, AppError> {
if !state.config.features.prices {
if !state.cfg().features.prices {
return Err(AppError::NotFound(
"price feed is not enabled on this server".into(),
));
+14 -11
View File
@@ -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<Vec<u8>> = 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<Arc<AppState>> {
.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))
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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(
+73 -2
View File
@@ -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<S: serde::Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
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<Arc<AppState>> {
.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");
}
}
+1 -1
View File
@@ -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
+160 -4
View File
@@ -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]
+627
View File
@@ -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<T>` 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<LandingOverlay>,
pub retention: Option<RetentionOverlay>,
pub restore: Option<RestoreOverlay>,
pub console: Option<ConsoleOverlay>,
pub registration: Option<RegistrationOverlay>,
pub pow: Option<PowOverlay>,
pub features: Option<FeaturesOverlay>,
pub relay: Option<RelayOverlay>,
pub premium: Option<PremiumOverlay>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct LandingOverlay {
pub enabled: Option<bool>,
/// Empty string clears the title (env `None`); any other value sets it.
pub title: Option<String>,
pub expose_version: Option<bool>,
pub expose_chains: Option<bool>,
pub expose_price_feed: Option<bool>,
pub expose_uptime: Option<bool>,
pub stats_enabled: Option<bool>,
pub stats_cache_hours: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RetentionOverlay {
pub login_events_days: Option<u64>,
pub audit_days: Option<u64>,
/// Gates a boot-spawned sweep worker -> RESTART-REQUIRED (see `runtime_class`).
pub erasure_enabled: Option<bool>,
pub purge_login_events: Option<bool>,
pub export_per_day: Option<u32>,
pub grace_period_hours: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RestoreOverlay {
/// One of `create-only` | `bounded` | `unlimited`.
pub policy: Option<String>,
pub max_depth_days: Option<u32>,
pub pow_free_days: Option<u32>,
pub pow_days_per_bit: Option<u32>,
pub pow_max_bits: Option<u32>,
}
#[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<String>,
}
/// 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<bool>,
/// `all` (every enabled gate) | `any` (one-of). PoW is orthogonal.
pub gate_mode: Option<String>,
pub require_payment: Option<bool>,
/// Registration price as a decimal string (no float math).
pub payment_amount: Option<String>,
pub payment_currency: Option<String>,
pub payment_confirmations: Option<u32>,
pub payment_expires_minutes: Option<u32>,
}
/// 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<bool>,
pub required: Option<bool>,
pub cost: Option<u64>,
}
/// 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<bool>,
pub prices_provider: Option<String>,
pub prices_interval_secs: Option<u64>,
pub prices_currency: Option<String>,
pub tips: Option<bool>,
pub nostr_identity: Option<bool>,
pub grin_relay: Option<bool>,
pub chain_btc: Option<bool>,
pub chain_ltc: Option<bool>,
pub chain_xmr: Option<bool>,
pub chain_wow: Option<bool>,
pub chain_grin: Option<bool>,
}
/// 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<bool>,
/// `bundled` | `external`.
pub mode: Option<String>,
/// Public `ws(s)://` URL clients connect to + we advertise.
pub advertised_url: Option<String>,
/// `inbox-outbox` | `author-allowlist` | `open` | `premium-post`.
pub write_policy: Option<String>,
pub inbound_pow_bits: Option<u8>,
pub max_event_bytes: Option<usize>,
pub retention_days: Option<u32>,
}
/// 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<bool>,
pub currency: Option<String>,
}
/// 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<Config, AppError> {
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"));
}
}
+59
View File
@@ -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<Utc>,
pub label: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub used_at: Option<DateTime<Utc>>,
pub used_by: Option<Uuid>,
}
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<Vec<InviteCodeRow>, 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<Vec<String>, 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)
}
}
+1
View File
@@ -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;
+189
View File
@@ -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<String> = keys
.into_iter()
.map(|k| format!("{}:{}", Value::String(k.clone()), canonical_json(&map[k])))
.collect();
format!("{{{}}}", inner.join(","))
}
Value::Array(arr) => {
let inner: Vec<String> = 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<SettingsOverlay, AppError> {
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::<SettingsOverlay>(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<Uuid>,
expected_version: Option<i64>,
audit: &NewAdminAudit,
secret: &str,
) -> Result<i64, AppError> {
let mut tx = self.pool().begin().await?;
let current: Option<i64> = 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<std::collections::HashMap<String, i64>, 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);
}
}
+4 -8
View File
@@ -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<SocialTipRow, AppError> {
self.insert_social_tip(new, TipStatus::PendingConfirmation).await
pub async fn create_social_tip(&self, new: NewSocialTip<'_>) -> Result<SocialTipRow, AppError> {
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<Vec<GcCancelledTip>, AppError> {
pub async fn cancel_stuck_pending_confirmation(&self) -> Result<Vec<GcCancelledTip>, AppError> {
let rows = sqlx::query_as::<_, GcCancelledTip>(
"UPDATE social_tips \
SET status = 'cancelled', updated_at = NOW() \
+1 -4
View File
@@ -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<Option<User>, AppError> {
pub async fn find_user_by_grin_address(&self, address: &str) -> Result<Option<User>, AppError> {
let sql = format!(
"SELECT {USER_COLS} FROM users u \
JOIN user_keys k ON k.user_id = u.id \
+7 -3
View File
@@ -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}"
);
}
}
+1 -3
View File
@@ -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,
}
}
+4 -1
View File
@@ -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()
))
}
}
+1 -2
View File
@@ -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
+11 -2
View File
@@ -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::<Vec<_>>().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?;
+21 -21
View File
@@ -145,6 +145,27 @@ struct NostrRelayProvider {
write_allowlist: HashSet<String>,
}
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)
}
}
+2 -3
View File
@@ -91,9 +91,8 @@ 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
{
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
+20 -2
View File
@@ -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]
+24 -2
View File
@@ -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<Arc<AppState>>`.
#[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<Config>` here (lock-free).
/// Read it via [`AppState::cfg`] (`state.cfg().field`), never a stale copy.
pub config: Arc<arc_swap::ArcSwap<Config>>,
/// 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<Config>,
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<AdminSessionManager>,
/// 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<tokio::sync::Notify>,
}
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<std::sync::Arc<Config>> {
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<AppState>) -> 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).
+70 -20
View File
@@ -19,21 +19,42 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
.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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
}
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
});
}
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<dyn std::error::Error>> {
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.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<tokio::sync::Notify>) {
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");
}
+13
View File
@@ -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<Uuid>,
pub updated_at: DateTime<Utc>,
pub integrity_mac: String,
}
/// A row in the tamper-evident privileged-action audit chain.
#[derive(Debug, Clone, FromRow)]
pub struct AdminAuditLog {
+4 -1
View File
@@ -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");
+38 -9
View File
@@ -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]
+4 -1
View File
@@ -50,7 +50,10 @@ pub async fn run_tip_draft_gc_cycle(state: Arc<AppState>) {
);
}
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"),
+70 -23
View File
@@ -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<sqlx::Postgres>,
key: i64,
) {
async fn release_advisory_lock(mut conn: sqlx::pool::PoolConnection<sqlx::Postgres>, 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<AppState>) {
#[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());
+1 -1
View File
@@ -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();
+6 -3
View File
@@ -89,7 +89,8 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option<TestApp> {
.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<TestApp> {
web_challenges: Arc::default(),
prices,
admin_sessions,
shutdown: Arc::default(),
});
Some(TestApp {
@@ -195,7 +197,8 @@ impl TestApp {
extra_headers: &[(&str, &str)],
body: Option<Value>,
) -> (StatusCode, axum::http::HeaderMap, Value) {
let mut builder = Request::builder()
let mut builder =
Request::builder()
.method(method)
.uri(uri)
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
@@ -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
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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()
+14 -5
View File
@@ -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()
+13 -4
View File
@@ -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).
+1 -1
View File
@@ -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)
+459 -85
View File
@@ -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::<uuid::Uuid>().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,15 +1218,31 @@ 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::<uuid::Uuid>().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::<uuid::Uuid>()
.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.
@@ -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
+6 -2
View File
@@ -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