mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
+85
-1
@@ -13,6 +13,19 @@ SERVER_PORT=8080
|
|||||||
# so every real deployment must replace it.
|
# so every real deployment must replace it.
|
||||||
PUBLIC_API_URL=https://backend.example.org/api/v1
|
PUBLIC_API_URL=https://backend.example.org/api/v1
|
||||||
ENVIRONMENT=development
|
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 ──────────────────────────────────────────────────────────────
|
||||||
DATABASE_URL=postgres://user:password@127.0.0.1:5432/smirk
|
DATABASE_URL=postgres://user:password@127.0.0.1:5432/smirk
|
||||||
@@ -36,6 +49,10 @@ FEATURE_POW=false
|
|||||||
ALTCHA_HMAC_KEY=
|
ALTCHA_HMAC_KEY=
|
||||||
POW_REQUIRED=false
|
POW_REQUIRED=false
|
||||||
ALTCHA_COST=100000
|
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) ────
|
# ── Registration gates (composable; each advertised via /capabilities) ────
|
||||||
# Require an operator-minted invite code to create a NEW wallet. Mint codes with
|
# 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.
|
# enabled the two modes are identical.
|
||||||
REGISTRATION_GATE_MODE=all
|
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) ─────
|
# ── Bitcoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ─────
|
||||||
BTC_NETWORK=mainnet
|
BTC_NETWORK=mainnet
|
||||||
BTC_ELECTRUM_URL=
|
BTC_ELECTRUM_URL=
|
||||||
BTC_ELECTRUM_FALLBACKS=
|
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) ────
|
# ── Litecoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ────
|
||||||
LTC_NETWORK=mainnet
|
LTC_NETWORK=mainnet
|
||||||
@@ -142,8 +181,26 @@ WALLET_RESTORE_POW_MAX_BITS=24
|
|||||||
FEATURE_PRICES=true
|
FEATURE_PRICES=true
|
||||||
PRICES_PROVIDER=coingecko
|
PRICES_PROVIDER=coingecko
|
||||||
PRICES_FETCH_INTERVAL_SECS=300
|
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.
|
# Tips are parked for the conditional-transfer redesign; off until that lands.
|
||||||
FEATURE_TIPS=false
|
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) ────
|
# ── Nostr relay (optional messaging plane; see docs/operations/RELAY.md) ────
|
||||||
# Off by default. When on, the operator runs a relay (the packaged
|
# Off by default. When on, the operator runs a relay (the packaged
|
||||||
@@ -205,11 +262,21 @@ FEED_OWNER_NPUB=
|
|||||||
FEED_ALLOWLIST_NPUBS=
|
FEED_ALLOWLIST_NPUBS=
|
||||||
FEED_EXTRA_RELAYS=
|
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,
|
# 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.
|
# 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_ENABLED=false
|
||||||
ADMIN_BIND=127.0.0.1:8081
|
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
|
# 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
|
# 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
|
# 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_KEY_INTEGRITY_SECRET=
|
||||||
ADMIN_MAX_KEYS=8
|
ADMIN_MAX_KEYS=8
|
||||||
ADMIN_PENDING_KEY_TTL_DAYS=7
|
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 page (OFF by default; full + per-field tunable when on) ──
|
||||||
PUBLIC_LANDING_ENABLED=false
|
PUBLIC_LANDING_ENABLED=false
|
||||||
@@ -234,9 +312,15 @@ PUBLIC_STATS_ENABLED=false
|
|||||||
PUBLIC_STATS_CACHE_HOURS=24
|
PUBLIC_STATS_CACHE_HOURS=24
|
||||||
|
|
||||||
# ── Data retention / erasure ──────────────────────────────────────────────
|
# ── 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_ENABLED=false
|
||||||
ERASURE_PURGE_LOGIN_EVENTS=true
|
ERASURE_PURGE_LOGIN_EVENTS=true
|
||||||
ERASURE_EXPORT_PER_DAY=3
|
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.
|
# Log retention (auto-deletion); 0 = keep indefinitely.
|
||||||
RETENTION_LOGIN_EVENTS_DAYS=90
|
RETENTION_LOGIN_EVENTS_DAYS=90
|
||||||
RETENTION_AUDIT_DAYS=365
|
RETENTION_AUDIT_DAYS=365
|
||||||
|
|||||||
@@ -36,6 +36,26 @@ jobs:
|
|||||||
cargo run --quiet --bin gen_openapi > /tmp/openapi.json
|
cargo run --quiet --bin gen_openapi > /tmp/openapi.json
|
||||||
diff openapi.json /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:
|
rust:
|
||||||
name: fmt / clippy / build / test / doc-test
|
name: fmt / clippy / build / test / doc-test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
!.env.example
|
!.env.example
|
||||||
*.key
|
*.key
|
||||||
*.pem
|
*.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
|
*.p12
|
||||||
secrets/
|
secrets/
|
||||||
secret_store/
|
secret_store/
|
||||||
|
|||||||
Generated
+187
-33
@@ -14,7 +14,7 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cipher",
|
"cipher",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -77,14 +77,14 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e7e126d41c3e0a6c85db7ba216bce5e41d53c156191c67d993613685fcc9410"
|
checksum = "5e7e126d41c3e0a6c85db7ba216bce5e41d53c156191c67d993613685fcc9410"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
"pbkdf2",
|
"pbkdf2",
|
||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
@@ -110,6 +110,15 @@ version = "1.0.103"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
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]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.42"
|
version = "0.4.42"
|
||||||
@@ -346,6 +355,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "bs58"
|
name = "bs58"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -409,7 +427,7 @@ version = "0.4.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -436,6 +454,12 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -471,6 +495,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc"
|
name = "crc"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -539,6 +572,15 @@ dependencies = [
|
|||||||
"typenum",
|
"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]]
|
[[package]]
|
||||||
name = "ctr"
|
name = "ctr"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
@@ -555,9 +597,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"curve25519-dalek-derive",
|
"curve25519-dalek-derive",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"fiat-crypto",
|
"fiat-crypto",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -600,7 +642,7 @@ version = "0.7.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"pem-rfc7468",
|
"pem-rfc7468",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -617,12 +659,23 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -653,7 +706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der",
|
"der",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"rfc6979",
|
"rfc6979",
|
||||||
"signature",
|
"signature",
|
||||||
@@ -679,7 +732,7 @@ dependencies = [
|
|||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
"ed25519",
|
"ed25519",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -701,7 +754,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base16ct",
|
"base16ct",
|
||||||
"crypto-bigint",
|
"crypto-bigint",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"ff",
|
"ff",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"group",
|
"group",
|
||||||
@@ -1193,7 +1246,7 @@ version = "0.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1272,6 +1325,15 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
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]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "0.14.32"
|
version = "0.14.32"
|
||||||
@@ -1618,7 +1680,7 @@ dependencies = [
|
|||||||
"ecdsa",
|
"ecdsa",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"signature",
|
"signature",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1715,7 +1777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1730,6 +1792,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2029,7 +2101,7 @@ version = "0.12.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"hmac",
|
"hmac",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2134,7 +2206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"opaque-debug",
|
"opaque-debug",
|
||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
@@ -2571,8 +2643,8 @@ version = "0.9.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"num-bigint-dig",
|
"num-bigint-dig",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
@@ -2585,6 +2657,41 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hex"
|
name = "rustc-hex"
|
||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
@@ -2690,6 +2797,15 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
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]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
@@ -2843,8 +2959,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2854,8 +2970,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"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]]
|
[[package]]
|
||||||
@@ -2889,7 +3016,7 @@ version = "2.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2929,6 +3056,7 @@ version = "0.3.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"altcha",
|
"altcha",
|
||||||
|
"arc-swap",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
@@ -2952,11 +3080,12 @@ dependencies = [
|
|||||||
"rand",
|
"rand",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
|
"rust-embed",
|
||||||
"rustls 0.22.4",
|
"rustls 0.22.4",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"subtle",
|
"subtle",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -3083,7 +3212,7 @@ dependencies = [
|
|||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlformat",
|
"sqlformat",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -3123,7 +3252,7 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"sqlx-mysql",
|
"sqlx-mysql",
|
||||||
"sqlx-postgres",
|
"sqlx-postgres",
|
||||||
@@ -3147,7 +3276,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"either",
|
"either",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
@@ -3168,7 +3297,7 @@ dependencies = [
|
|||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
@@ -3209,7 +3338,7 @@ dependencies = [
|
|||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
@@ -3812,6 +3941,12 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -3857,7 +3992,7 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3993,6 +4128,16 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
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]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -4151,6 +4296,15 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
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]]
|
[[package]]
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|||||||
+4
-1
@@ -32,7 +32,9 @@ tower_governor = "0.4"
|
|||||||
governor = "0.6"
|
governor = "0.6"
|
||||||
|
|
||||||
# Database
|
# 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"
|
ipnetwork = "0.20"
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
@@ -119,6 +121,7 @@ tonic-build = "0.12"
|
|||||||
protoc-bin-vendored = "3"
|
protoc-bin-vendored = "3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
arc-swap = "1"
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
mockall = "0.12"
|
mockall = "0.12"
|
||||||
|
|
||||||
|
|||||||
+28
@@ -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`.
|
||||||
@@ -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
|
Tor or an SSH tunnel) — confidentiality by socket, never merged into the public
|
||||||
router or OpenAPI.
|
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
|
- **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
|
nonce (AUTHN) + a MAC-protected key allowlist (AUTHZ), composed so a route can't
|
||||||
run without both.
|
run without both.
|
||||||
|
|||||||
@@ -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).
|
||||||
+5
File diff suppressed because one or more lines are too long
+1
@@ -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)}
|
||||||
Vendored
+14
@@ -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>
|
||||||
@@ -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>
|
||||||
Generated
+1023
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}>,
|
||||||
|
};
|
||||||
@@ -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')!);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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
|
public interface. `ADMIN_BIND` defaults to loopback, and a non-loopback bind
|
||||||
requires an explicit `ADMIN_ALLOW_PUBLIC_BIND=true`.
|
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)
|
## 6. Registration policy (optional)
|
||||||
|
|
||||||
Gates are composable and advertised via `/capabilities`; returning wallets and
|
Gates are composable and advertised via `/capabilities`; returning wallets and
|
||||||
|
|||||||
@@ -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) |
|
| `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) |
|
| `author-allowlist` | only registered Smirk npubs may publish anything (no external inbound) |
|
||||||
| `open` | accept everything (the relay's resource caps still apply) |
|
| `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
|
`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
|
cross-ecosystem (non-registered-author) inbound events — spam friction without an
|
||||||
author allowlist. Only the `inbox-outbox` external-inbound branch is gated.
|
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
|
## Enable it
|
||||||
|
|
||||||
1. Set the relay env (see `.env.example` → *Nostr relay*):
|
1. Set the relay env (see `.env.example` → *Nostr relay*):
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ RELAY_WRITE_POLICY=author-allowlist
|
|||||||
RELAY_INBOUND_POW_BITS=0
|
RELAY_INBOUND_POW_BITS=0
|
||||||
RELAY_ADMISSION_BIND=127.0.0.1:8090
|
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.
|
# Chain endpoints — see ../../.env.example.
|
||||||
XMR_LWS_URL=
|
XMR_LWS_URL=
|
||||||
XMR_LWS_ADMIN_URL=
|
XMR_LWS_ADMIN_URL=
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ RELAY_WRITE_POLICY=inbox-outbox
|
|||||||
RELAY_INBOUND_POW_BITS=0
|
RELAY_INBOUND_POW_BITS=0
|
||||||
RELAY_ADMISSION_BIND=127.0.0.1:8090
|
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.
|
# Chain endpoints — point at your own nodes / LWS. See ../../.env.example.
|
||||||
XMR_LWS_URL=
|
XMR_LWS_URL=
|
||||||
XMR_LWS_ADMIN_URL=
|
XMR_LWS_ADMIN_URL=
|
||||||
|
|||||||
@@ -50,6 +50,19 @@ FEATURE_PRICES=true
|
|||||||
FEATURE_TIPS=false
|
FEATURE_TIPS=false
|
||||||
RELAY_ENABLED=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.
|
# Chain endpoints — see ../../.env.example.
|
||||||
XMR_LWS_URL=
|
XMR_LWS_URL=
|
||||||
XMR_LWS_ADMIN_URL=
|
XMR_LWS_ADMIN_URL=
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ FEATURE_PRICES=true
|
|||||||
FEATURE_TIPS=false
|
FEATURE_TIPS=false
|
||||||
ADMIN_ENABLED=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).
|
# Chain endpoints — see ../../.env.example (XMR/WOW LWS + daemon, GRIN_*, electrum).
|
||||||
XMR_LWS_URL=
|
XMR_LWS_URL=
|
||||||
XMR_LWS_ADMIN_URL=
|
XMR_LWS_ADMIN_URL=
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ RELAY_ENABLED=false
|
|||||||
ADMIN_ENABLED=false
|
ADMIN_ENABLED=false
|
||||||
ADMIN_KEY_INTEGRITY_SECRET=
|
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
|
# 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).
|
# and formats in ../../.env.example (XMR/WOW LWS + daemon, GRIN_*, BTC/LTC electrum).
|
||||||
XMR_LWS_URL=
|
XMR_LWS_URL=
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ RELAY_ENABLED=false
|
|||||||
# Admin HTTP plane off at launch; enable + bootstrap a key when you need it.
|
# Admin HTTP plane off at launch; enable + bootstrap a key when you need it.
|
||||||
ADMIN_ENABLED=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 — reuse your existing daemons / LWS. See ../../.env.example.
|
# Chain endpoints — reuse your existing daemons / LWS. See ../../.env.example.
|
||||||
XMR_LWS_URL=
|
XMR_LWS_URL=
|
||||||
XMR_LWS_ADMIN_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
@@ -3728,15 +3728,11 @@
|
|||||||
"minimum": 0
|
"minimum": 0
|
||||||
},
|
},
|
||||||
"locked_balance": {
|
"locked_balance": {
|
||||||
"type": "integer",
|
"type": "string"
|
||||||
"format": "int64",
|
|
||||||
"minimum": 0
|
|
||||||
},
|
},
|
||||||
"pending_balance": {
|
"pending_balance": {
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"format": "int64",
|
"description": "Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool\nrows (the monero-lws mempool feature); never negative."
|
||||||
"description": "Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool\nrows (the monero-lws mempool feature); never negative.",
|
|
||||||
"minimum": 0
|
|
||||||
},
|
},
|
||||||
"scanned_height": {
|
"scanned_height": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -3756,9 +3752,8 @@
|
|||||||
"minimum": 0
|
"minimum": 0
|
||||||
},
|
},
|
||||||
"total_received": {
|
"total_received": {
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"format": "int64",
|
"example": "12345000000000"
|
||||||
"minimum": 0
|
|
||||||
},
|
},
|
||||||
"transaction_count": {
|
"transaction_count": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -4626,9 +4621,7 @@
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"amount": {
|
"amount": {
|
||||||
"type": "integer",
|
"type": "string"
|
||||||
"format": "int64",
|
|
||||||
"minimum": 0
|
|
||||||
},
|
},
|
||||||
"key_image": {
|
"key_image": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -4742,15 +4735,11 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"total_received": {
|
"total_received": {
|
||||||
"type": "integer",
|
"type": "string"
|
||||||
"format": "int64",
|
|
||||||
"minimum": 0
|
|
||||||
},
|
},
|
||||||
"total_sent": {
|
"total_sent": {
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"format": "int64",
|
"description": "\"Possible\" sent — candidate spends, not authoritative."
|
||||||
"description": "\"Possible\" sent — candidate spends, not authoritative.",
|
|
||||||
"minimum": 0
|
|
||||||
},
|
},
|
||||||
"unlock_time": {
|
"unlock_time": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -4776,9 +4765,7 @@
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"amount": {
|
"amount": {
|
||||||
"type": "integer",
|
"type": "string"
|
||||||
"format": "int64",
|
|
||||||
"minimum": 0
|
|
||||||
},
|
},
|
||||||
"global_index": {
|
"global_index": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|||||||
+480
-16
@@ -66,14 +66,14 @@ fn admin_manager(state: &AppState) -> Result<&AdminSessionManager, AppError> {
|
|||||||
fn admin_verify_url(state: &AppState) -> String {
|
fn admin_verify_url(state: &AppState) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}/admin/auth/verify",
|
"{}/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
|
/// A stable per-instance id bound into the signed action, so a challenge signed
|
||||||
/// for this instance cannot be relayed to another.
|
/// for this instance cannot be relayed to another.
|
||||||
fn admin_instance_id(state: &AppState) -> String {
|
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 {
|
fn pubkey_prefix(pubkey: &str) -> String {
|
||||||
@@ -121,7 +121,7 @@ pub async fn admin_plane_guard(
|
|||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
// Compute the response on both paths, then apply the security headers once so
|
// Compute the response on both paths, then apply the security headers once so
|
||||||
// the 403 Host-reject carries them too.
|
// 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
|
next.run(req).await
|
||||||
} else {
|
} else {
|
||||||
(StatusCode::FORBIDDEN, "forbidden").into_response()
|
(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
|
// Live, uncached allowlist re-check (MAC re-verified inside): must be active
|
||||||
// AND activated (a pending key is not yet authorized for protected routes).
|
// 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
|
let key = state
|
||||||
.db
|
.db
|
||||||
.get_active_admin_key(&info.pubkey, secret)
|
.get_active_admin_key(&info.pubkey, secret)
|
||||||
@@ -279,7 +279,7 @@ pub async fn admin_verify(
|
|||||||
) -> Result<Json<AdminTokenResponse>, AppError> {
|
) -> Result<Json<AdminTokenResponse>, AppError> {
|
||||||
let mgr = admin_manager(&state)?;
|
let mgr = admin_manager(&state)?;
|
||||||
let ip = client_ip(&state, &headers, peer);
|
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
|
// 1. Prove the signature + bindings (purpose/nonce/descriptor/instance). The
|
||||||
// descriptor binds the verify URL with an EMPTY body (the proof rides in 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 pair = mgr.create_token_pair(&pubkey, session_id)?;
|
||||||
let refresh_hash = hash_refresh_token(
|
let refresh_hash = hash_refresh_token(
|
||||||
&pair.refresh_token,
|
&pair.refresh_token,
|
||||||
&state.config.secrets.refresh_token_pepper,
|
&state.cfg().secrets.refresh_token_pepper,
|
||||||
);
|
);
|
||||||
let audit = NewAdminAudit {
|
let audit = NewAdminAudit {
|
||||||
action: "admin_login".into(),
|
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
|
// Re-authorize against the live allowlist (closes the "8h refresh is the real
|
||||||
// blast radius" gap).
|
// blast radius" gap).
|
||||||
let secret = &state.config.admin.key_integrity_secret;
|
let secret = &state.cfg().admin.key_integrity_secret;
|
||||||
let key = state
|
let key = state
|
||||||
.db
|
.db
|
||||||
.get_active_admin_key(&pubkey, secret)
|
.get_active_admin_key(&pubkey, secret)
|
||||||
@@ -403,7 +403,7 @@ pub async fn admin_refresh(
|
|||||||
.ok_or_else(admin_auth_fail)?;
|
.ok_or_else(admin_auth_fail)?;
|
||||||
let presented = hash_refresh_token(
|
let presented = hash_refresh_token(
|
||||||
&req.refresh_token,
|
&req.refresh_token,
|
||||||
&state.config.secrets.refresh_token_pepper,
|
&state.cfg().secrets.refresh_token_pepper,
|
||||||
);
|
);
|
||||||
let hash_ok: bool = presented
|
let hash_ok: bool = presented
|
||||||
.as_bytes()
|
.as_bytes()
|
||||||
@@ -435,7 +435,7 @@ pub async fn admin_logout(
|
|||||||
) -> Result<Json<OkResponse>, AppError> {
|
) -> Result<Json<OkResponse>, AppError> {
|
||||||
let ctx = admin_guard(&state, &headers).await?;
|
let ctx = admin_guard(&state, &headers).await?;
|
||||||
state.db.revoke_admin_session(ctx.session_id).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
|
let _ = state
|
||||||
.db
|
.db
|
||||||
.record_admin_audit(
|
.record_admin_audit(
|
||||||
@@ -520,7 +520,7 @@ fn pending_key(state: &AppState, pubkey: String, label: Option<String>) -> NewAd
|
|||||||
scope: "admin".into(),
|
scope: "admin".into(),
|
||||||
created_by_kind: "admin".into(),
|
created_by_kind: "admin".into(),
|
||||||
activation_deadline: Some(
|
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();
|
let pubkey = req.pubkey.to_lowercase();
|
||||||
validate_admin_pubkey(&pubkey)?;
|
validate_admin_pubkey(&pubkey)?;
|
||||||
|
|
||||||
let secret = &state.config.admin.key_integrity_secret;
|
let secret = &state.cfg().admin.key_integrity_secret;
|
||||||
let audit = NewAdminAudit {
|
let audit = NewAdminAudit {
|
||||||
action: "admin_key_added".into(),
|
action: "admin_key_added".into(),
|
||||||
actor_kind: "admin".into(),
|
actor_kind: "admin".into(),
|
||||||
@@ -552,7 +552,7 @@ pub async fn admin_keys_add(
|
|||||||
pending_key(&state, pubkey, req.label),
|
pending_key(&state, pubkey, req.label),
|
||||||
&audit,
|
&audit,
|
||||||
secret,
|
secret,
|
||||||
state.config.admin.max_keys as i64,
|
state.cfg().admin.max_keys as i64,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
@@ -593,7 +593,7 @@ pub async fn admin_keys_revoke(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<OkResponse>, AppError> {
|
) -> Result<Json<OkResponse>, AppError> {
|
||||||
let ctx = admin_guard(&state, &headers).await?;
|
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 {
|
let audit = NewAdminAudit {
|
||||||
action: "admin_key_revoked".into(),
|
action: "admin_key_revoked".into(),
|
||||||
actor_kind: "admin".into(),
|
actor_kind: "admin".into(),
|
||||||
@@ -632,7 +632,7 @@ pub async fn admin_keys_rotate(
|
|||||||
let ctx = admin_guard(&state, &headers).await?;
|
let ctx = admin_guard(&state, &headers).await?;
|
||||||
let pubkey = req.pubkey.to_lowercase();
|
let pubkey = req.pubkey.to_lowercase();
|
||||||
validate_admin_pubkey(&pubkey)?;
|
validate_admin_pubkey(&pubkey)?;
|
||||||
let secret = &state.config.admin.key_integrity_secret;
|
let secret = &state.cfg().admin.key_integrity_secret;
|
||||||
let audit = NewAdminAudit {
|
let audit = NewAdminAudit {
|
||||||
action: "admin_key_rotated".into(),
|
action: "admin_key_rotated".into(),
|
||||||
actor_kind: "admin".into(),
|
actor_kind: "admin".into(),
|
||||||
@@ -679,7 +679,7 @@ pub async fn admin_features(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Json<AdminFeaturesResponse>, AppError> {
|
) -> Result<Json<AdminFeaturesResponse>, AppError> {
|
||||||
admin_guard(&state, &headers).await?;
|
admin_guard(&state, &headers).await?;
|
||||||
let cfg = &state.config;
|
let cfg = &state.cfg();
|
||||||
let effective = crate::api::capabilities::effective_capabilities(cfg);
|
let effective = crate::api::capabilities::effective_capabilities(cfg);
|
||||||
|
|
||||||
let mut downgrades = Vec::new();
|
let mut downgrades = Vec::new();
|
||||||
@@ -738,8 +738,392 @@ pub async fn admin_features(
|
|||||||
// ── router ───────────────────────────────────────────────────────────────────
|
// ── router ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Admin-plane routes. Mounted by [`crate::admin_router`] on the loopback socket.
|
/// 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>> {
|
pub fn routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
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/challenge", post(admin_challenge))
|
||||||
.route("/admin/auth/verify", post(admin_verify))
|
.route("/admin/auth/verify", post(admin_verify))
|
||||||
.route("/admin/auth/refresh", post(admin_refresh))
|
.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", post(admin_keys_add).get(admin_keys_list))
|
||||||
.route("/admin/keys/:id", delete(admin_keys_revoke))
|
.route("/admin/keys/:id", delete(admin_keys_revoke))
|
||||||
.route("/admin/keys/:id/rotate", post(admin_keys_rotate))
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn host_allowlist() {
|
fn host_allowlist() {
|
||||||
|
|||||||
+57
-33
@@ -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)`
|
/// 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.
|
/// 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()
|
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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,22 +178,18 @@ fn validate_username(username: &str) -> Result<(), AppError> {
|
|||||||
/// validation only requires `ALTCHA_HMAC_KEY` when the feature is enabled). When
|
/// 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.
|
/// disabled we never call `verify_payload`, so the empty key is never used.
|
||||||
fn pow_applies(state: &AppState, pubkey_hash_lc: &str) -> bool {
|
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 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
|
/// the event's `u` tag). Built from `config.identity.public_api_url`, never the
|
||||||
/// request Host. Fail closed when unset (Nostr identity is disabled).
|
/// request Host. Fail closed when unset (Nostr identity is disabled).
|
||||||
fn nip98_url(state: &AppState, path: &str) -> Result<String, AppError> {
|
fn nip98_url(state: &AppState, path: &str) -> Result<String, AppError> {
|
||||||
let base = state
|
let cfg = state.cfg();
|
||||||
.config
|
let base = cfg.identity.public_api_url.as_deref().ok_or_else(|| {
|
||||||
.identity
|
warn!("Nostr endpoint reached but PUBLIC_API_URL is unset; refusing");
|
||||||
.public_api_url
|
AppError::AuthError("Nostr authentication is not enabled".into())
|
||||||
.as_deref()
|
})?;
|
||||||
.ok_or_else(|| {
|
|
||||||
warn!("Nostr endpoint reached but PUBLIC_API_URL is unset; refusing");
|
|
||||||
AppError::AuthError("Nostr authentication is not enabled".into())
|
|
||||||
})?;
|
|
||||||
Ok(format!("{}{}", base.trim_end_matches('/'), path))
|
Ok(format!("{}{}", base.trim_end_matches('/'), path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +219,7 @@ async fn issue_session(
|
|||||||
|
|
||||||
let refresh_token_hash = hash_refresh_token(
|
let refresh_token_hash = hash_refresh_token(
|
||||||
&pair.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();
|
let expires_at = Utc::now() + state.sessions.refresh_token_expiry();
|
||||||
|
|
||||||
@@ -276,7 +280,7 @@ async fn upsert_all_keys(
|
|||||||
pub async fn pow_challenge(
|
pub async fn pow_challenge(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> Result<Json<altcha::Challenge>, AppError> {
|
) -> 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))
|
Ok(Json(challenge))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +544,10 @@ pub async fn extension_register(
|
|||||||
.db
|
.db
|
||||||
.create_user_consuming_gates(
|
.create_user_consuming_gates(
|
||||||
gates.invite_code_hash.as_deref(),
|
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 {
|
NewUser {
|
||||||
username: req.username.clone(),
|
username: req.username.clone(),
|
||||||
pubkey_hash: Some(pubkey_hash.clone()),
|
pubkey_hash: Some(pubkey_hash.clone()),
|
||||||
@@ -622,13 +629,13 @@ fn enforce_pow(
|
|||||||
.into(),
|
.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)");
|
info!(pow = "ok", "PoW solution accepted (new user)");
|
||||||
} else if let Some(solution) = solution {
|
} else if let Some(solution) = solution {
|
||||||
// Supplied but not required: verify anyway (clear error on malformed),
|
// Supplied but not required: verify anyway (clear error on malformed),
|
||||||
// but only if the feature is enabled so we never touch an empty key.
|
// but only if the feature is enabled so we never touch an empty key.
|
||||||
if state.config.pow.enabled {
|
if state.cfg().pow.enabled {
|
||||||
crate::core::pow::verify_payload(&state.config.pow, solution)?;
|
crate::core::pow::verify_payload(&state.cfg().pow, solution)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -699,9 +706,14 @@ struct GatedRegistration {
|
|||||||
/// Require an invite code and return its hash, erroring if the gate needs one but
|
/// 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).
|
/// none was supplied (the presence check + literal the old `enforce_invite` used).
|
||||||
fn require_invite_hash(invite_code: Option<&str>) -> Result<String, AppError> {
|
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(|| {
|
let code = invite_code
|
||||||
AppError::ValidationError("An invite code is required to register on this instance.".into())
|
.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))
|
Ok(crate::core::invite::hash_invite_code(code))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,15 +735,18 @@ async fn plan_gate_consume(
|
|||||||
.is_some_and(|s| !s.is_empty());
|
.is_some_and(|s| !s.is_empty());
|
||||||
|
|
||||||
match plan_gates(
|
match plan_gates(
|
||||||
state.config.registration.gate_mode,
|
state.cfg().registration.gate_mode,
|
||||||
state.config.registration.require_invite,
|
state.cfg().registration.require_invite,
|
||||||
state.config.registration.payment.require_payment,
|
state.cfg().registration.payment.require_payment,
|
||||||
has_invite,
|
has_invite,
|
||||||
has_payment,
|
has_payment,
|
||||||
) {
|
) {
|
||||||
GatePlan::Open => Ok(GatedRegistration { invite_code_hash: None, payment: None }),
|
GatePlan::Open => Ok(GatedRegistration {
|
||||||
|
invite_code_hash: None,
|
||||||
|
payment: None,
|
||||||
|
}),
|
||||||
GatePlan::All => {
|
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)?)
|
Some(require_invite_hash(invite_code)?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -776,7 +791,7 @@ async fn verify_payment_settled(
|
|||||||
pubkey_hash: &str,
|
pubkey_hash: &str,
|
||||||
payment_invoice_id: Option<&str>,
|
payment_invoice_id: Option<&str>,
|
||||||
) -> Result<Option<String>, AppError> {
|
) -> Result<Option<String>, AppError> {
|
||||||
if returning || !state.config.registration.payment.require_payment {
|
if returning || !state.cfg().registration.payment.require_payment {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Gate on but no provider built — config validation prevents this, so it is
|
// 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>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<PaymentInvoiceRequest>,
|
Json(req): Json<PaymentInvoiceRequest>,
|
||||||
) -> Result<Json<PaymentInvoiceResponse>, AppError> {
|
) -> Result<Json<PaymentInvoiceResponse>, AppError> {
|
||||||
let cfg = &state.config.registration.payment;
|
let cfg = &state.cfg().registration.payment;
|
||||||
if !cfg.require_payment {
|
if !cfg.require_payment {
|
||||||
return Err(AppError::ValidationError(
|
return Err(AppError::ValidationError(
|
||||||
"This instance does not require registration payment.".into(),
|
"This instance does not require registration payment.".into(),
|
||||||
@@ -1175,7 +1190,7 @@ pub async fn refresh_token(
|
|||||||
|
|
||||||
let token_hash = hash_refresh_token(
|
let token_hash = hash_refresh_token(
|
||||||
&req.refresh_token,
|
&req.refresh_token,
|
||||||
&state.config.secrets.refresh_token_pepper,
|
&state.cfg().secrets.refresh_token_pepper,
|
||||||
);
|
);
|
||||||
let session = state
|
let session = state
|
||||||
.db
|
.db
|
||||||
@@ -1256,7 +1271,7 @@ pub async fn logout(
|
|||||||
) -> Result<Json<LogoutResponse>, AppError> {
|
) -> Result<Json<LogoutResponse>, AppError> {
|
||||||
let token_hash = hash_refresh_token(
|
let token_hash = hash_refresh_token(
|
||||||
&req.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? {
|
if let Some(session) = state.db.get_session_by_token_hash(&token_hash).await? {
|
||||||
let _ = state.db.revoke_session(session.id).await;
|
let _ = state.db.revoke_session(session.id).await;
|
||||||
@@ -1711,7 +1726,10 @@ pub async fn nostr_register(
|
|||||||
.db
|
.db
|
||||||
.create_user_consuming_gates(
|
.create_user_consuming_gates(
|
||||||
gates.invite_code_hash.as_deref(),
|
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 {
|
NewUser {
|
||||||
username: req.username.clone(),
|
username: req.username.clone(),
|
||||||
pubkey_hash: None,
|
pubkey_hash: None,
|
||||||
@@ -1771,7 +1789,10 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/auth/logout", post(logout))
|
.route("/auth/logout", post(logout))
|
||||||
.route("/auth/me", get(get_me))
|
.route("/auth/me", get(get_me))
|
||||||
.route("/auth/nostr", post(nostr_login))
|
.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/register", post(nostr_register))
|
||||||
.route("/auth/nostr/link-challenge", get(nostr_link_challenge))
|
.route("/auth/nostr/link-challenge", get(nostr_link_challenge))
|
||||||
.route("/auth/nostr/link", post(nostr_link))
|
.route("/auth/nostr/link", post(nostr_link))
|
||||||
@@ -1832,7 +1853,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn any_mode_single_gate_is_just_that_gate() {
|
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, 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]
|
#[test]
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ fn tips_advertised(config: &Config) -> bool {
|
|||||||
)]
|
)]
|
||||||
#[instrument(skip(state))]
|
#[instrument(skip(state))]
|
||||||
pub async fn capabilities(State(state): State<Arc<AppState>>) -> Json<CapabilitiesResponse> {
|
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).
|
/// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth).
|
||||||
|
|||||||
+14
-14
@@ -41,7 +41,7 @@ const PURPOSE_EXPORT: &str = "erasure_export";
|
|||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
if state.config.retention.erasure_enabled {
|
if state.cfg().retention.erasure_enabled {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::NotFound("erasure is not enabled".into()))
|
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
|
/// The signed-action `u`-tag base for `path` (e.g. `/account/erasure`), from
|
||||||
/// `PUBLIC_API_URL` (which already includes the `/api/v1` prefix).
|
/// `PUBLIC_API_URL` (which already includes the `/api/v1` prefix).
|
||||||
fn erasure_url(state: &AppState, path: &str) -> Result<String, AppError> {
|
fn erasure_url(state: &AppState, path: &str) -> Result<String, AppError> {
|
||||||
let base = state
|
let cfg = state.cfg();
|
||||||
.config
|
let base = cfg
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -64,8 +64,8 @@ fn subject_hash(npub: &str) -> String {
|
|||||||
hex::encode(Sha256::digest(npub.as_bytes()))
|
hex::encode(Sha256::digest(npub.as_bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn integrity_secret(state: &AppState) -> &str {
|
fn integrity_secret(state: &AppState) -> String {
|
||||||
&state.config.admin.key_integrity_secret
|
state.cfg().admin.key_integrity_secret.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -191,7 +191,7 @@ pub async fn request_erasure(
|
|||||||
Json(body): Json<ProofRequest>,
|
Json(body): Json<ProofRequest>,
|
||||||
) -> Result<Json<ErasureRequestResponse>, AppError> {
|
) -> Result<Json<ErasureRequestResponse>, AppError> {
|
||||||
ensure_enabled(&state)?;
|
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?;
|
let (pubkey, user) = prove(&state, &body, PURPOSE_REQUEST, "/account/erasure", None).await?;
|
||||||
|
|
||||||
// Constant-shape across present/absent AND across repeated calls: a STABLE
|
// Constant-shape across present/absent AND across repeated calls: a STABLE
|
||||||
@@ -209,13 +209,13 @@ pub async fn request_erasure(
|
|||||||
.db
|
.db
|
||||||
.record_admin_audit(
|
.record_admin_audit(
|
||||||
&audit("account_erasure_requested", &req.id),
|
&audit("account_erasure_requested", &req.id),
|
||||||
integrity_secret(&state),
|
&integrity_secret(&state),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
info!(erasure_id = %req.id, "erasure requested");
|
info!(erasure_id = %req.id, "erasure requested");
|
||||||
req.id.to_string()
|
req.id.to_string()
|
||||||
}
|
}
|
||||||
None => synthetic_erasure_id(&pubkey, integrity_secret(&state)),
|
None => synthetic_erasure_id(&pubkey, &integrity_secret(&state)),
|
||||||
};
|
};
|
||||||
Ok(Json(ErasureRequestResponse {
|
Ok(Json(ErasureRequestResponse {
|
||||||
erasure_id,
|
erasure_id,
|
||||||
@@ -244,7 +244,7 @@ pub async fn confirm_erasure(
|
|||||||
.await?;
|
.await?;
|
||||||
let user = user.ok_or_else(|| AppError::NotFound("no such erasure request".into()))?;
|
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
|
let req = state
|
||||||
.db
|
.db
|
||||||
.confirm_erasure_request(id, user.id, grace)
|
.confirm_erasure_request(id, user.id, grace)
|
||||||
@@ -256,7 +256,7 @@ pub async fn confirm_erasure(
|
|||||||
.db
|
.db
|
||||||
.record_admin_audit(
|
.record_admin_audit(
|
||||||
&audit("account_erasure_confirmed", &req.id),
|
&audit("account_erasure_confirmed", &req.id),
|
||||||
integrity_secret(&state),
|
&integrity_secret(&state),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
info!(erasure_id = %req.id, "erasure confirmed");
|
info!(erasure_id = %req.id, "erasure confirmed");
|
||||||
@@ -295,7 +295,7 @@ pub async fn cancel_erasure(
|
|||||||
.db
|
.db
|
||||||
.record_admin_audit(
|
.record_admin_audit(
|
||||||
&audit("account_erasure_cancelled", &req.id),
|
&audit("account_erasure_cancelled", &req.id),
|
||||||
integrity_secret(&state),
|
&integrity_secret(&state),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
Ok(Json(StatusResponse {
|
Ok(Json(StatusResponse {
|
||||||
@@ -359,7 +359,7 @@ pub async fn export(
|
|||||||
details: None,
|
details: None,
|
||||||
ip_address: None,
|
ip_address: None,
|
||||||
},
|
},
|
||||||
integrity_secret(&state),
|
&integrity_secret(&state),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
Ok(Json(ExportResponse {
|
Ok(Json(ExportResponse {
|
||||||
@@ -388,11 +388,11 @@ fn audit(action: &str, erasure_id: &Uuid) -> NewAdminAudit {
|
|||||||
#[instrument(skip(state))]
|
#[instrument(skip(state))]
|
||||||
pub async fn run_erasure_sweep(state: &Arc<AppState>, batch: i64) -> Result<u64, AppError> {
|
pub async fn run_erasure_sweep(state: &Arc<AppState>, batch: i64) -> Result<u64, AppError> {
|
||||||
let due = state.db.due_erasure_requests(batch).await?;
|
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 secret = integrity_secret(state);
|
||||||
let mut done = 0;
|
let mut done = 0;
|
||||||
for req in due {
|
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,
|
Ok(true) => done += 1,
|
||||||
// false = another node/sweep already claimed it, or it was cancelled.
|
// false = another node/sweep already claimed it, or it was cancelled.
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
|
|||||||
+5
-9
@@ -92,23 +92,19 @@ pub fn build_server_info(config: &Config) -> ServerInfo {
|
|||||||
/// is off (matching an unmatched route — the `{error,code}` envelope would itself
|
/// is off (matching an unmatched route — the `{error,code}` envelope would itself
|
||||||
/// hint the route exists, so it is deliberately not used here).
|
/// hint the route exists, so it is deliberately not used here).
|
||||||
pub async fn server_info(State(state): State<Arc<AppState>>) -> Response {
|
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();
|
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.
|
/// `GET /` — minimal HTML rendered from the read model; bare `404` when off.
|
||||||
pub async fn root(State(state): State<Arc<AppState>>) -> Response {
|
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();
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
}
|
}
|
||||||
let title = state
|
let cfg = state.cfg();
|
||||||
.config
|
let title = cfg.landing.title.as_deref().unwrap_or("smirk-backend-core");
|
||||||
.landing
|
|
||||||
.title
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("smirk-backend-core");
|
|
||||||
let t = html_escape(title);
|
let t = html_escape(title);
|
||||||
let html = format!(
|
let html = format!(
|
||||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
|
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ pub fn client_ip(state: &AppState, headers: &HeaderMap, peer: SocketAddr) -> IpA
|
|||||||
let peer_ip = peer.ip();
|
let peer_ip = peer.ip();
|
||||||
|
|
||||||
let peer_is_trusted = state
|
let peer_is_trusted = state
|
||||||
.config
|
.cfg()
|
||||||
.trusted_proxies
|
.trusted_proxies
|
||||||
.iter()
|
.iter()
|
||||||
.any(|net| net.contains(peer_ip));
|
.any(|net| net.contains(peer_ip));
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ pub async fn well_known_nostr(
|
|||||||
let name = raw.to_lowercase();
|
let name = raw.to_lowercase();
|
||||||
if let Some(user) = state.db.get_user_by_username(&name).await? {
|
if let Some(user) = state.db.get_user_by_username(&name).await? {
|
||||||
if let Some(pubkey) = user.nostr_pubkey {
|
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);
|
names.insert(name, pubkey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -52,7 +52,7 @@ pub struct StatusResp {
|
|||||||
|
|
||||||
/// Reject when the premium tier is not enabled on this instance.
|
/// Reject when the premium tier is not enabled on this instance.
|
||||||
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
if state.config.premium.enabled {
|
if state.cfg().premium.enabled {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::ValidationError(
|
Err(AppError::ValidationError(
|
||||||
@@ -83,8 +83,8 @@ pub async fn invoice(
|
|||||||
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
||||||
ensure_enabled(&state)?;
|
ensure_enabled(&state)?;
|
||||||
|
|
||||||
let plan = state
|
let cfg = state.cfg();
|
||||||
.config
|
let plan = cfg
|
||||||
.premium
|
.premium
|
||||||
.plans
|
.plans
|
||||||
.iter()
|
.iter()
|
||||||
@@ -106,8 +106,8 @@ pub async fn invoice(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::Internal("payment provider not configured".into()))?;
|
.ok_or_else(|| AppError::Internal("payment provider not configured".into()))?;
|
||||||
|
|
||||||
let pay = &state.config.registration.payment;
|
let pay = &state.cfg().registration.payment;
|
||||||
let currency = state.config.premium.currency.clone();
|
let currency = state.cfg().premium.currency.clone();
|
||||||
let invoice = provider
|
let invoice = provider
|
||||||
.create_invoice(&InvoiceRequest {
|
.create_invoice(&InvoiceRequest {
|
||||||
amount: plan.amount.clone(),
|
amount: plan.amount.clone(),
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ pub struct PricesResponse {
|
|||||||
)]
|
)]
|
||||||
#[instrument(skip(state))]
|
#[instrument(skip(state))]
|
||||||
pub async fn prices(State(state): State<Arc<AppState>>) -> Result<Json<PricesResponse>, AppError> {
|
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(
|
return Err(AppError::NotFound(
|
||||||
"price feed is not enabled on this server".into(),
|
"price feed is not enabled on this server".into(),
|
||||||
));
|
));
|
||||||
|
|||||||
+14
-11
@@ -160,7 +160,7 @@ pub struct ClawbackTipResponse {
|
|||||||
|
|
||||||
/// Reject when tips are not enabled on this instance.
|
/// Reject when tips are not enabled on this instance.
|
||||||
fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> {
|
fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
if state.config.features.tips {
|
if state.cfg().features.tips {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::ValidationError(
|
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.
|
/// Whether `asset` is a tip-capable chain enabled on this instance.
|
||||||
fn supported_tip_asset(state: &AppState, asset: &str) -> bool {
|
fn supported_tip_asset(state: &AppState, asset: &str) -> bool {
|
||||||
let c = &state.config.features.chains;
|
let c = &state.cfg().features.chains;
|
||||||
match asset {
|
match asset {
|
||||||
"btc" => c.btc,
|
"btc" => c.btc,
|
||||||
"ltc" => c.ltc,
|
"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() {
|
let encrypted_key: Option<Vec<u8>> = match req.encrypted_key.as_deref() {
|
||||||
Some(h) if !h.is_empty() => {
|
Some(h) if !h.is_empty() => {
|
||||||
if h.len() > MAX_ENCRYPTED_KEY_HEX {
|
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(
|
Some(
|
||||||
hex::decode(h)
|
hex::decode(h)
|
||||||
@@ -382,7 +384,7 @@ pub async fn create_social_tip(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let share_url = state
|
let share_url = state
|
||||||
.config
|
.cfg()
|
||||||
.tip_share_base
|
.tip_share_base
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id));
|
.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)).
|
// DB length-overflow 500 (funding_txid is VARCHAR(128)).
|
||||||
let funding_txid = req.funding_txid.trim();
|
let funding_txid = req.funding_txid.trim();
|
||||||
if funding_txid.is_empty() {
|
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 {
|
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.
|
// The DB fn returns Ok(row) / NotFound / ValidationError; just surface it.
|
||||||
@@ -573,7 +579,7 @@ pub async fn attach_funding(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let share_url = state
|
let share_url = state
|
||||||
.config
|
.cfg()
|
||||||
.tip_share_base
|
.tip_share_base
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id));
|
.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/claimable", get(get_claimable_social_tips))
|
||||||
.route("/tips/social/:tip_id/public", get(get_public_social_tip))
|
.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/cancel", post(cancel_social_tip))
|
||||||
.route(
|
.route("/tips/social/:tip_id/attach-funding", post(attach_funding))
|
||||||
"/tips/social/:tip_id/attach-funding",
|
|
||||||
post(attach_funding),
|
|
||||||
)
|
|
||||||
.route("/tips/social/:tip_id/claim", post(claim_social_tip))
|
.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/confirm-sweep", post(confirm_sweep))
|
||||||
.route("/tips/social/:tip_id/clawback", post(clawback_social_tip))
|
.route("/tips/social/:tip_id/clawback", post(clawback_social_tip))
|
||||||
|
|||||||
@@ -202,8 +202,8 @@ pub async fn scan(
|
|||||||
// grin-wallet fallback, so it is enforced before either scan path is chosen.
|
// grin-wallet fallback, so it is enforced before either scan path is chosen.
|
||||||
if let Some(h) = req.start_height {
|
if let Some(h) = req.start_height {
|
||||||
let tip = grin_tip(&state).await?;
|
let tip = grin_tip(&state).await?;
|
||||||
state.config.restore.enforce("grin", h, tip)?;
|
state.cfg().restore.enforce("grin", h, tip)?;
|
||||||
state.config.restore.enforce_restore_pow(
|
state.cfg().restore.enforce_restore_pow(
|
||||||
"grin",
|
"grin",
|
||||||
&req.rewind_hash,
|
&req.rewind_hash,
|
||||||
h,
|
h,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const RELAY_TTL_DAYS: i64 = 7;
|
|||||||
|
|
||||||
/// 400 unless the relay feature is enabled on this server.
|
/// 400 unless the relay feature is enabled on this server.
|
||||||
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
if state.config.features.grin_relay {
|
if state.cfg().features.grin_relay {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::NotFound(
|
Err(AppError::NotFound(
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ pub struct ConfirmationsRequest {
|
|||||||
|
|
||||||
// ── response DTOs ─────────────────────────────────────────────────────────────
|
// ── 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
|
/// 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
|
/// 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
|
/// balance client-side: `total_received − sum(spent_outputs it verifies with the
|
||||||
@@ -129,10 +137,16 @@ pub struct ConfirmationsRequest {
|
|||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
pub struct LwsBalanceResponse {
|
pub struct LwsBalanceResponse {
|
||||||
pub asset: String,
|
pub asset: String,
|
||||||
|
#[serde(serialize_with = "ser_u64_str")]
|
||||||
|
#[schema(value_type = String, example = "12345000000000")]
|
||||||
pub total_received: u64,
|
pub total_received: u64,
|
||||||
|
#[serde(serialize_with = "ser_u64_str")]
|
||||||
|
#[schema(value_type = String)]
|
||||||
pub locked_balance: u64,
|
pub locked_balance: u64,
|
||||||
/// Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool
|
/// Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool
|
||||||
/// rows (the monero-lws mempool feature); never negative.
|
/// rows (the monero-lws mempool feature); never negative.
|
||||||
|
#[serde(serialize_with = "ser_u64_str")]
|
||||||
|
#[schema(value_type = String)]
|
||||||
pub pending_balance: u64,
|
pub pending_balance: u64,
|
||||||
pub start_height: u64,
|
pub start_height: u64,
|
||||||
pub scanned_height: u64,
|
pub scanned_height: u64,
|
||||||
@@ -146,6 +160,8 @@ pub struct LwsBalanceResponse {
|
|||||||
/// A candidate spent output (verify with the spend key before trusting).
|
/// A candidate spent output (verify with the spend key before trusting).
|
||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
pub struct SpentOutputDto {
|
pub struct SpentOutputDto {
|
||||||
|
#[serde(serialize_with = "ser_u64_str")]
|
||||||
|
#[schema(value_type = String)]
|
||||||
pub amount: u64,
|
pub amount: u64,
|
||||||
pub key_image: String,
|
pub key_image: String,
|
||||||
pub tx_pub_key: String,
|
pub tx_pub_key: String,
|
||||||
@@ -159,8 +175,12 @@ pub struct TxDto {
|
|||||||
pub hash: String,
|
pub hash: String,
|
||||||
pub height: u64,
|
pub height: u64,
|
||||||
pub timestamp: String,
|
pub timestamp: String,
|
||||||
|
#[serde(serialize_with = "ser_u64_str")]
|
||||||
|
#[schema(value_type = String)]
|
||||||
pub total_received: u64,
|
pub total_received: u64,
|
||||||
/// "Possible" sent — candidate spends, not authoritative.
|
/// "Possible" sent — candidate spends, not authoritative.
|
||||||
|
#[serde(serialize_with = "ser_u64_str")]
|
||||||
|
#[schema(value_type = String)]
|
||||||
pub total_sent: u64,
|
pub total_sent: u64,
|
||||||
pub mempool: bool,
|
pub mempool: bool,
|
||||||
pub unlock_time: u64,
|
pub unlock_time: u64,
|
||||||
@@ -177,6 +197,8 @@ pub struct LwsHistoryResponse {
|
|||||||
/// An unspent output for spend construction.
|
/// An unspent output for spend construction.
|
||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
pub struct UnspentOutputDto {
|
pub struct UnspentOutputDto {
|
||||||
|
#[serde(serialize_with = "ser_u64_str")]
|
||||||
|
#[schema(value_type = String)]
|
||||||
pub amount: u64,
|
pub amount: u64,
|
||||||
pub public_key: String,
|
pub public_key: String,
|
||||||
pub tx_pub_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
|
// Restore: gate the scan depth against this instance's policy (the
|
||||||
// backfill cost lands on our LWS). `None` (create) needs no check.
|
// backfill cost lands on our LWS). `None` (create) needs no check.
|
||||||
let tip = client.get_blockchain_height().await?;
|
let tip = client.get_blockchain_height().await?;
|
||||||
state.config.restore.enforce(&asset, h, tip)?;
|
state.cfg().restore.enforce(&asset, h, tip)?;
|
||||||
state.config.restore.enforce_restore_pow(
|
state.cfg().restore.enforce_restore_pow(
|
||||||
&asset,
|
&asset,
|
||||||
&req.address,
|
&req.address,
|
||||||
h,
|
h,
|
||||||
@@ -609,3 +631,52 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/wallet/lws/height", post(height))
|
.route("/wallet/lws/height", post(height))
|
||||||
.route("/wallet/lws/confirmations", post(confirmations))
|
.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
@@ -297,7 +297,7 @@ pub async fn website_verify(
|
|||||||
.create_token_pair(user.id, Platform::Web, session_id)?;
|
.create_token_pair(user.id, Platform::Web, session_id)?;
|
||||||
let refresh_token_hash = hash_refresh_token(
|
let refresh_token_hash = hash_refresh_token(
|
||||||
&pair.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();
|
let expires_at = Utc::now() + state.sessions.refresh_token_expiry();
|
||||||
state
|
state
|
||||||
|
|||||||
+160
-4
@@ -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
|
/// 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.
|
/// a local reverse proxy), so the https requirement is relaxed for it.
|
||||||
fn is_loopback_url(s: &str) -> bool {
|
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::Ipv4(ip)) => ip.is_loopback(),
|
||||||
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
|
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
|
||||||
Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
|
Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
|
||||||
@@ -171,6 +174,32 @@ pub struct Config {
|
|||||||
pub messaging: MessagingConfig,
|
pub messaging: MessagingConfig,
|
||||||
pub premium: PremiumConfig,
|
pub premium: PremiumConfig,
|
||||||
pub feed: FeedConfig,
|
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)]
|
#[derive(Clone)]
|
||||||
@@ -912,6 +941,12 @@ impl Config {
|
|||||||
allowlist_npubs: env_list("FEED_ALLOWLIST_NPUBS"),
|
allowlist_npubs: env_list("FEED_ALLOWLIST_NPUBS"),
|
||||||
extra_relays: env_list("FEED_EXTRA_RELAYS"),
|
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()?;
|
cfg.validate()?;
|
||||||
@@ -1020,7 +1055,10 @@ impl Config {
|
|||||||
// booting a feed that can never populate (which would still advertise
|
// booting a feed that can never populate (which would still advertise
|
||||||
// prices:true and silently serve nothing).
|
// prices:true and silently serve nothing).
|
||||||
if self.features.prices {
|
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!(
|
return Err(cfg_err(format!(
|
||||||
"PRICES_PROVIDER {:?} is not supported; supported: coingecko, kraken",
|
"PRICES_PROVIDER {:?} is not supported; supported: coingecko, kraken",
|
||||||
self.features.prices_provider
|
self.features.prices_provider
|
||||||
@@ -1268,7 +1306,8 @@ impl Config {
|
|||||||
if p.provider_url.trim().is_empty() {
|
if p.provider_url.trim().is_empty() {
|
||||||
return Err(cfg_err("PREMIUM_ENABLED needs PAYMENT_PROVIDER_URL"));
|
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(
|
return Err(cfg_err(
|
||||||
"PAYMENT_PROVIDER_URL must be https:// in production (loopback exempt)",
|
"PAYMENT_PROVIDER_URL must be https:// in production (loopback exempt)",
|
||||||
));
|
));
|
||||||
@@ -1502,6 +1541,9 @@ mod tests {
|
|||||||
allowlist_npubs: Vec::new(),
|
allowlist_npubs: Vec::new(),
|
||||||
extra_relays: 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]
|
#[test]
|
||||||
fn valid_config_passes() {
|
fn valid_config_passes() {
|
||||||
assert!(valid().validate().is_ok());
|
assert!(valid().validate().is_ok());
|
||||||
@@ -1589,7 +1742,10 @@ mod tests {
|
|||||||
c.features.tips = true;
|
c.features.tips = true;
|
||||||
c.features.chains.btc = true;
|
c.features.chains.btc = true;
|
||||||
c.tip_share_base = None;
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,28 @@
|
|||||||
//! single-statement claim (`UPDATE ... WHERE used_at IS NULL ... RETURNING`) so
|
//! single-statement claim (`UPDATE ... WHERE used_at IS NULL ... RETURNING`) so
|
||||||
//! two concurrent registrations presenting the same code can never both succeed.
|
//! two concurrent registrations presenting the same code can never both succeed.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::core::invite::{generate_invite_code, hash_invite_code};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::models::db::NewAdminAudit;
|
||||||
|
|
||||||
use super::Database;
|
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 {
|
impl Database {
|
||||||
/// Store a freshly-minted code by its `sha256` hash with an optional operator
|
/// 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
|
/// `label`. A duplicate hash (re-mint of the same raw code — astronomically
|
||||||
@@ -57,4 +73,47 @@ impl Database {
|
|||||||
.fetch_one(self.pool())
|
.fetch_one(self.pool())
|
||||||
.await?)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod erasure;
|
|||||||
mod grin_slatepacks;
|
mod grin_slatepacks;
|
||||||
mod invite_codes;
|
mod invite_codes;
|
||||||
mod login_events;
|
mod login_events;
|
||||||
|
mod operator_settings;
|
||||||
mod payment_invoices;
|
mod payment_invoices;
|
||||||
mod premium_invoices;
|
mod premium_invoices;
|
||||||
mod restore_attempts;
|
mod restore_attempts;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,11 +169,9 @@ impl Database {
|
|||||||
/// `pending_confirmation` so the funding verifier (not the caller) owns the
|
/// `pending_confirmation` so the funding verifier (not the caller) owns the
|
||||||
/// transition to `pending`.
|
/// transition to `pending`.
|
||||||
#[instrument(skip(self, new))]
|
#[instrument(skip(self, new))]
|
||||||
pub async fn create_social_tip(
|
pub async fn create_social_tip(&self, new: NewSocialTip<'_>) -> Result<SocialTipRow, AppError> {
|
||||||
&self,
|
self.insert_social_tip(new, TipStatus::PendingConfirmation)
|
||||||
new: NewSocialTip<'_>,
|
.await
|
||||||
) -> Result<SocialTipRow, AppError> {
|
|
||||||
self.insert_social_tip(new, TipStatus::PendingConfirmation).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All tips this user has sent, newest first (every status).
|
/// 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;
|
/// cleared the funding verifier and is a real tip the sender expects claimed;
|
||||||
/// it must NOT be auto-cancelled here.
|
/// it must NOT be auto-cancelled here.
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn cancel_stuck_pending_confirmation(
|
pub async fn cancel_stuck_pending_confirmation(&self) -> Result<Vec<GcCancelledTip>, AppError> {
|
||||||
&self,
|
|
||||||
) -> Result<Vec<GcCancelledTip>, AppError> {
|
|
||||||
let rows = sqlx::query_as::<_, GcCancelledTip>(
|
let rows = sqlx::query_as::<_, GcCancelledTip>(
|
||||||
"UPDATE social_tips \
|
"UPDATE social_tips \
|
||||||
SET status = 'cancelled', updated_at = NOW() \
|
SET status = 'cancelled', updated_at = NOW() \
|
||||||
|
|||||||
@@ -230,10 +230,7 @@ impl Database {
|
|||||||
/// registered on *this* backend. Used to look up a recipient's linked Nostr
|
/// 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.
|
/// pubkey so a bare-`grin1…`-addressed send can route over Nostr gift-wrap.
|
||||||
#[instrument(skip(self, address))]
|
#[instrument(skip(self, address))]
|
||||||
pub async fn find_user_by_grin_address(
|
pub async fn find_user_by_grin_address(&self, address: &str) -> Result<Option<User>, AppError> {
|
||||||
&self,
|
|
||||||
address: &str,
|
|
||||||
) -> Result<Option<User>, AppError> {
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {USER_COLS} FROM users u \
|
"SELECT {USER_COLS} FROM users u \
|
||||||
JOIN user_keys k ON k.user_id = u.id \
|
JOIN user_keys k ON k.user_id = u.id \
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ impl ElectrumClient {
|
|||||||
let use_primary = self
|
let use_primary = self
|
||||||
.primary
|
.primary
|
||||||
.as_ref()
|
.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());
|
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;
|
let count = self.primary_fail_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if count >= PRIMARY_FAIL_THRESHOLD {
|
if count >= PRIMARY_FAIL_THRESHOLD {
|
||||||
let until = now_ms.saturating_add(PRIMARY_COOLDOWN.as_millis() as u64);
|
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 client = ElectrumClient::new(UtxoNetwork::BitcoinMainnet, &cfg).unwrap();
|
||||||
let tip = client.get_tip_height().await;
|
let tip = client.get_tip_height().await;
|
||||||
assert!(tip.is_ok(), "lenient TLS must connect to {server}: {tip:?}");
|
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}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,9 +158,7 @@ impl GrinOutputInfo {
|
|||||||
/// bogus count. Mirrors [`ViewWalletOutputResult::confirmations`].
|
/// bogus count. Mirrors [`ViewWalletOutputResult::confirmations`].
|
||||||
pub fn confirmations(&self, tip_height: u64) -> u64 {
|
pub fn confirmations(&self, tip_height: u64) -> u64 {
|
||||||
match self.block_height {
|
match self.block_height {
|
||||||
Some(h) if h > 0 && h <= tip_height => {
|
Some(h) if h > 0 && h <= tip_height => tip_height.saturating_sub(h).saturating_add(1),
|
||||||
tip_height.saturating_sub(h).saturating_add(1)
|
|
||||||
}
|
|
||||||
_ => 0,
|
_ => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,10 @@ impl GrinLwsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn node_err_status(&self, label: &str, status: reqwest::StatusCode) -> AppError {
|
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()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -242,8 +242,7 @@ impl LwsClient {
|
|||||||
Some(_) => Ok(()),
|
Some(_) => Ok(()),
|
||||||
None => {
|
None => {
|
||||||
self.admin_add_account(address, view_key).await?;
|
self.admin_add_account(address, view_key).await?;
|
||||||
let current =
|
let current = self.account_scan_height(address).await?.unwrap_or(u64::MAX);
|
||||||
self.account_scan_height(address).await?.unwrap_or(u64::MAX);
|
|
||||||
if start_height < current {
|
if start_height < current {
|
||||||
// The account is now added at the chain tip. If this backfill
|
// The account is now added at the chain tip. If this backfill
|
||||||
// rescan fails, the account is stranded at the tip: it reads a
|
// rescan fails, the account is stranded at the tip: it reads a
|
||||||
|
|||||||
+11
-2
@@ -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).
|
/// Nonlogs markets — WOW/GRIN, which Kraken doesn't list (priced via BTC/USDT).
|
||||||
const NONLOGS_URL: &str = "https://api.nonlogs.io/api/markets";
|
const NONLOGS_URL: &str = "https://api.nonlogs.io/api/markets";
|
||||||
/// Our majors → Kraken USD pair. WOW/GRIN come from nonlogs instead.
|
/// 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.
|
/// Lower bound on the refresh interval, to stay within free-tier rate limits.
|
||||||
const MIN_INTERVAL_SECS: u64 = 60;
|
const MIN_INTERVAL_SECS: u64 = 60;
|
||||||
/// Upper bound on the price response body. The real payload is a handful of
|
/// Upper bound on the price response body. The real payload is a handful of
|
||||||
@@ -146,7 +150,12 @@ impl PriceClient {
|
|||||||
.collect();
|
.collect();
|
||||||
if !pairs.is_empty() {
|
if !pairs.is_empty() {
|
||||||
let joined = pairs.iter().map(|(_, p)| *p).collect::<Vec<_>>().join(",");
|
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() {
|
if resp.status().is_success() {
|
||||||
let body = read_capped(resp, MAX_PRICE_BODY_BYTES).await?;
|
let body = read_capped(resp, MAX_PRICE_BODY_BYTES).await?;
|
||||||
|
|||||||
+21
-21
@@ -145,6 +145,27 @@ struct NostrRelayProvider {
|
|||||||
write_allowlist: HashSet<String>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::allowlist_entry_to_hex;
|
use super::allowlist_entry_to_hex;
|
||||||
@@ -175,24 +196,3 @@ mod tests {
|
|||||||
assert_eq!(allowlist_entry_to_hex("deadbeef"), None); // too short
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -91,13 +91,12 @@ impl Authorization for AdmissionService {
|
|||||||
// Premium membership — only the `premium-post` policy consults it, so skip
|
// Premium membership — only the `premium-post` policy consults it, so skip
|
||||||
// the (cheap, indexed) lookup entirely under the other policies (and when
|
// the (cheap, indexed) lookup entirely under the other policies (and when
|
||||||
// the author is already write-allowlisted).
|
// the author is already write-allowlisted).
|
||||||
let author_premium = if !author_allowlisted
|
let author_premium =
|
||||||
&& self.relay.write_policy() == WritePolicy::PremiumPost
|
if !author_allowlisted && self.relay.write_policy() == WritePolicy::PremiumPost {
|
||||||
{
|
self.db.is_premium_npub(&author_hex).await.unwrap_or(false)
|
||||||
self.db.is_premium_npub(&author_hex).await.unwrap_or(false)
|
} else {
|
||||||
} else {
|
false
|
||||||
false
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// Recipient registration (only when the author isn't already registered /
|
// Recipient registration (only when the author isn't already registered /
|
||||||
// allowlisted): collect up to MAX_P_TAGS distinct `p` tags and resolve them
|
// allowlisted): collect up to MAX_P_TAGS distinct `p` tags and resolve them
|
||||||
|
|||||||
@@ -219,8 +219,26 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn author_allowlist_gates_on_author() {
|
fn author_allowlist_gates_on_author() {
|
||||||
let m = meta(A, 1, FF);
|
let m = meta(A, 1, FF);
|
||||||
assert!(decide(&m, WritePolicy::AuthorAllowlist, 0, true, false, false, false).is_permit());
|
assert!(decide(
|
||||||
assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, false, false, false, false).is_permit());
|
&m,
|
||||||
|
WritePolicy::AuthorAllowlist,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
.is_permit());
|
||||||
|
assert!(!decide(
|
||||||
|
&m,
|
||||||
|
WritePolicy::AuthorAllowlist,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
.is_permit());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+24
-2
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod config_overlay;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
@@ -55,7 +56,13 @@ const NORMAL_BURST: u32 = 60;
|
|||||||
/// Shared application state injected into handlers via `State<Arc<AppState>>`.
|
/// Shared application state injected into handlers via `State<Arc<AppState>>`.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
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 db: Database,
|
||||||
pub sessions: SessionManager,
|
pub sessions: SessionManager,
|
||||||
/// Per-chain data-source clients (present only for enabled chains).
|
/// 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
|
/// Admin token minter/verifier. `Some` only when the admin surface is
|
||||||
/// enabled; the admin plane and guard refuse all requests otherwise.
|
/// enabled; the admin plane and guard refuse all requests otherwise.
|
||||||
pub admin_sessions: Option<AdminSessionManager>,
|
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
|
/// 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
|
/// the exact same wiring: `/health` and the NIP-05 directory at the root, the
|
||||||
/// authenticated wallet/identity API nested under `/api/v1`.
|
/// authenticated wallet/identity API nested under `/api/v1`.
|
||||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
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
|
// Per-IP rate limiting (PeerIpKeyExtractor reads ConnectInfo — `main` serves
|
||||||
// with `into_make_service_with_connect_info`; the test harness injects it).
|
// with `into_make_service_with_connect_info`; the test harness injects it).
|
||||||
|
|||||||
+70
-20
@@ -19,21 +19,42 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
let _ = dotenvy::dotenv();
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
// Fail-closed: aborts on a weak/missing/inconsistent secret.
|
// Fail-closed: aborts on a weak/missing/inconsistent secret. This is the env-only
|
||||||
let config = Config::from_env()?;
|
// truth; the effective config below layers the validated DB overlay on top.
|
||||||
tracing::info!(environment = %config.environment, "configuration loaded");
|
let config_base = Config::from_env()?;
|
||||||
|
tracing::info!(environment = %config_base.environment, "configuration loaded");
|
||||||
|
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(20)
|
.max_connections(20)
|
||||||
.connect(&config.database_url)
|
.connect(&config_base.database_url)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
|
|
||||||
let db = Database::new(
|
let db = Database::new(
|
||||||
pool,
|
pool,
|
||||||
config.secrets.seed_fingerprint_pepper.clone(),
|
config_base.secrets.seed_fingerprint_pepper.clone(),
|
||||||
config.secrets.ip_salt.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 sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours);
|
||||||
let chains = ChainClients::from_config(&config)?;
|
let chains = ChainClients::from_config(&config)?;
|
||||||
// Payment processor for the pay-to-register gate (None unless it's enabled).
|
// 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
|
.enabled
|
||||||
.then(|| AdminSessionManager::new(&config.admin.jwt_secret));
|
.then(|| AdminSessionManager::new(&config.admin.jwt_secret));
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
config,
|
config: Arc::new(arc_swap::ArcSwap::from_pointee(config)),
|
||||||
|
config_base: Arc::new(config_base),
|
||||||
db,
|
db,
|
||||||
sessions,
|
sessions,
|
||||||
chains,
|
chains,
|
||||||
@@ -96,6 +118,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
web_challenges: Arc::default(),
|
web_challenges: Arc::default(),
|
||||||
prices: prices_cache,
|
prices: prices_cache,
|
||||||
admin_sessions,
|
admin_sessions,
|
||||||
|
shutdown: Arc::new(tokio::sync::Notify::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Periodic GC of expired website-auth challenges. Bounds the single-node
|
// 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
|
// 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
|
// UPDATE guard; this sweep flips past-TTL rows to Expired so the table stays
|
||||||
// bounded and the lifecycle reflects reality.
|
// bounded and the lifecycle reflects reality.
|
||||||
if state.config.features.grin_relay {
|
if state.cfg().features.grin_relay {
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600));
|
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
|
// Background erasure execution: confirmed requests past their grace window
|
||||||
// are deleted (per-table policy + cascade + hash-chained audit, one tx each).
|
// 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();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600));
|
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
|
// fetch the configured feeds and replace the snapshot; a failure logs and
|
||||||
// keeps the last good values rather than blanking them. The first interval
|
// keeps the last good values rather than blanking them. The first interval
|
||||||
// tick fires immediately, so prices populate at startup.
|
// tick fires immediately, so prices populate at startup.
|
||||||
if state.config.features.prices {
|
if state.cfg().features.prices {
|
||||||
let f = &state.config.features;
|
let f = &state.cfg().features;
|
||||||
match prices::PriceClient::new(&f.prices_provider, &f.prices_currency, &f.prices_assets) {
|
match prices::PriceClient::new(&f.prices_provider, &f.prices_currency, &f.prices_assets) {
|
||||||
Ok(client) if !client.is_empty() => {
|
Ok(client) if !client.is_empty() => {
|
||||||
let cache = state.prices.clone();
|
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
|
// not middleware ordering). The fail-closed non-loopback bind guard and the
|
||||||
// browser/Host hardening land with the admin-posture subsystem; for now bind
|
// 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.
|
// to the configured (loopback-default) address and warn if it isn't local.
|
||||||
if state.config.admin.enabled {
|
if state.cfg().admin.enabled {
|
||||||
let admin_addr = state.config.admin.bind.clone();
|
let admin_addr = state.cfg().admin.bind.clone();
|
||||||
// Fail-closed bind guard: refuse a non-loopback admin bind unless the
|
// Fail-closed bind guard: refuse a non-loopback admin bind unless the
|
||||||
// operator explicitly opts in (confidentiality is by socket). Resolve the
|
// operator explicitly opts in (confidentiality is by socket). Resolve the
|
||||||
// address (so "localhost" works) and require EVERY resolved addr to be
|
// 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,
|
Err(_) => false,
|
||||||
};
|
};
|
||||||
if !is_loopback && !state.config.admin.allow_public_bind {
|
if !is_loopback && !state.cfg().admin.allow_public_bind {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"ADMIN_BIND {admin_addr} is not loopback; set ADMIN_ALLOW_PUBLIC_BIND=true to override \
|
"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)"
|
(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;
|
use smirk_backend_core::infra::relay::WritePolicy;
|
||||||
if relay.write_policy() != WritePolicy::Open {
|
if relay.write_policy() != WritePolicy::Open {
|
||||||
let addr: std::net::SocketAddr = state
|
let addr: std::net::SocketAddr = state
|
||||||
.config
|
.cfg()
|
||||||
.messaging
|
.messaging
|
||||||
.relay
|
.relay
|
||||||
.admission_bind
|
.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
|
// 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)
|
// single pass is caught + backed off so the loop survives (visible in logs)
|
||||||
// rather than silently dying and stranding every pending tip.
|
// rather than silently dying and stranding every pending tip.
|
||||||
if state.config.features.tips {
|
if state.cfg().features.tips {
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
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
|
// 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
|
// settling anything that confirmed while we were down) then every 60s. Same
|
||||||
// catch_unwind guard as the money-in worker.
|
// catch_unwind guard as the money-in worker.
|
||||||
if state.config.features.tips {
|
if state.cfg().features.tips {
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
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
|
// 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
|
// 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.
|
// 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;
|
use futures::FutureExt;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
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
|
// Public-tips draft GC: hourly, cancel `draft` rows abandoned mid-flow (> 7
|
||||||
// days, never funded-attached) and warn per cancelled row. Same catch_unwind
|
// days, never funded-attached) and warn per cancelled row. Same catch_unwind
|
||||||
// guard as the money-in/out workers.
|
// guard as the money-in/out workers.
|
||||||
if state.config.features.tips {
|
if state.cfg().features.tips {
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
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`
|
// Public-tips lifecycle GC: every 5 min, cancel stuck `pending_confirmation`
|
||||||
// rows (> 7 days) and warn-only-scan stuck `claiming` rows (> 15 min). Same
|
// rows (> 7 days) and warn-only-scan stuck `claiming` rows (> 15 min). Same
|
||||||
// catch_unwind guard.
|
// catch_unwind guard.
|
||||||
if state.config.features.tips {
|
if state.cfg().features.tips {
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
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 app = build_router(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
@@ -381,6 +405,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
listener,
|
listener,
|
||||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
)
|
)
|
||||||
|
.with_graceful_shutdown(shutdown_signal(shutdown))
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -307,6 +307,19 @@ pub struct ServerConfig {
|
|||||||
pub integrity_mac: String,
|
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.
|
/// A row in the tamper-evident privileged-action audit chain.
|
||||||
#[derive(Debug, Clone, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
pub struct AdminAuditLog {
|
pub struct AdminAuditLog {
|
||||||
|
|||||||
@@ -126,7 +126,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn wire_strings_match_check_constraint() {
|
fn wire_strings_match_check_constraint() {
|
||||||
assert_eq!(TipStatus::Draft.as_str(), "draft");
|
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::Pending.as_str(), "pending");
|
||||||
assert_eq!(TipStatus::Claiming.as_str(), "claiming");
|
assert_eq!(TipStatus::Claiming.as_str(), "claiming");
|
||||||
assert_eq!(TipStatus::Claimed.as_str(), "claimed");
|
assert_eq!(TipStatus::Claimed.as_str(), "claimed");
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ async fn check_asset_confirmations(state: &AppState, asset: &str) -> Result<(),
|
|||||||
debug!(asset, "no tips awaiting confirmation");
|
debug!(asset, "no tips awaiting confirmation");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
info!(asset, count = tips.len(), "checking tip funding confirmations");
|
info!(
|
||||||
|
asset,
|
||||||
|
count = tips.len(),
|
||||||
|
"checking tip funding confirmations"
|
||||||
|
);
|
||||||
|
|
||||||
for tip in &tips {
|
for tip in &tips {
|
||||||
let funding_txid = match tip.funding_txid.as_deref() {
|
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");
|
debug!("no grin tips awaiting confirmation");
|
||||||
return Ok(());
|
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
|
// 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
|
// (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 {
|
match verify_funding_amount(state, tip).await {
|
||||||
FundingVerification::Verified { observed } => {
|
FundingVerification::Verified { observed } => {
|
||||||
let observed_i64 = i64::try_from(observed).unwrap_or(i64::MAX);
|
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)) => {
|
Ok(Some(_updated)) => {
|
||||||
info!(
|
info!(
|
||||||
tip_id = %tip.id, asset = %tip.asset,
|
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 /
|
// Public tips deliver by share URL: no recipient DM /
|
||||||
// announcement side-effect to fire here.
|
// announcement side-effect to fire here.
|
||||||
}
|
}
|
||||||
Ok(None) => debug!(tip_id = %tip.id, "verified: row no longer pending_confirmation — skipping"),
|
Ok(None) => {
|
||||||
Err(e) => warn!(tip_id = %tip.id, error = %e, "mark_tip_funding_verified DB error"),
|
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 } => {
|
FundingVerification::Short { observed } => {
|
||||||
let observed_i64 = i64::try_from(observed).unwrap_or(i64::MAX);
|
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!(
|
Ok(Some(_updated)) => warn!(
|
||||||
tip_id = %tip.id, asset = %tip.asset,
|
tip_id = %tip.id, asset = %tip.asset,
|
||||||
declared = tip.amount, observed = observed_i64,
|
declared = tip.amount, observed = observed_i64,
|
||||||
"tip funding mismatch — sender funded LESS than declared"
|
"tip funding mismatch — sender funded LESS than declared"
|
||||||
),
|
),
|
||||||
Ok(None) => debug!(tip_id = %tip.id, "mismatch: row no longer pending_confirmation — skipping"),
|
Ok(None) => {
|
||||||
Err(e) => warn!(tip_id = %tip.id, error = %e, "mark_tip_funding_mismatch DB error"),
|
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 => {
|
FundingVerification::Unfunded => {
|
||||||
@@ -560,7 +583,13 @@ mod tests {
|
|||||||
&resp(vec![tx(1_000, 0, 100, false), tx(0, 1_000, 110, false)]),
|
&resp(vec![tx(1_000, 0, 100, false), tx(0, 1_000, 110, false)]),
|
||||||
1_000,
|
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]
|
#[test]
|
||||||
|
|||||||
+4
-1
@@ -50,7 +50,10 @@ pub async fn run_tip_draft_gc_cycle(state: Arc<AppState>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !cancelled.is_empty() {
|
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"),
|
Err(e) => warn!(error = %e, "tip-draft GC cycle failed"),
|
||||||
|
|||||||
@@ -142,10 +142,7 @@ async fn acquire_advisory_lock(
|
|||||||
/// Release the advisory lock on the SAME connection it was acquired on, then
|
/// 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
|
/// drop the connection back to the pool. Best-effort — the lock auto-releases
|
||||||
/// when the connection closes.
|
/// when the connection closes.
|
||||||
async fn release_advisory_lock(
|
async fn release_advisory_lock(mut conn: sqlx::pool::PoolConnection<sqlx::Postgres>, key: i64) {
|
||||||
mut conn: sqlx::pool::PoolConnection<sqlx::Postgres>,
|
|
||||||
key: i64,
|
|
||||||
) {
|
|
||||||
if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)")
|
if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)")
|
||||||
.bind(key)
|
.bind(key)
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
@@ -172,7 +169,8 @@ pub async fn run_sweep_reconcile_cycle(state: Arc<AppState>) {
|
|||||||
|
|
||||||
#[instrument(skip(state))]
|
#[instrument(skip(state))]
|
||||||
async fn run_confirm_cycle(state: &AppState) -> Result<(), AppError> {
|
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");
|
debug!("sweep confirm cycle: advisory lock held by another instance — skipping");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -184,7 +182,10 @@ async fn run_confirm_cycle(state: &AppState) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn confirm_cycle_inner(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() {
|
if tips.is_empty() {
|
||||||
debug!("no tips awaiting sweep confirmation");
|
debug!("no tips awaiting sweep confirmation");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -257,7 +258,13 @@ async fn process_awaiting_tip(state: &AppState, tip: &SocialTipRow) -> Result<()
|
|||||||
let settled = if txid_matches {
|
let settled = if txid_matches {
|
||||||
state
|
state
|
||||||
.db
|
.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?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -267,7 +274,12 @@ async fn process_awaiting_tip(state: &AppState, tip: &SocialTipRow) -> Result<()
|
|||||||
);
|
);
|
||||||
state
|
state
|
||||||
.db
|
.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?
|
.await?
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -349,8 +361,9 @@ async fn probe_grin_spends(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::NodeError("grin client disabled".into()))?;
|
.ok_or_else(|| AppError::NodeError("grin client disabled".into()))?;
|
||||||
|
|
||||||
let commit = grin_commitment(tip)
|
let commit = grin_commitment(tip).ok_or_else(|| {
|
||||||
.ok_or_else(|| AppError::ValidationError("grin commitment missing for sweep probe".into()))?;
|
AppError::ValidationError("grin commitment missing for sweep probe".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
let tip_height = grin.get_height().await? as i64;
|
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")))?;
|
.ok_or_else(|| AppError::NodeError(format!("{asset} Electrum client disabled")))?;
|
||||||
|
|
||||||
let address = tip
|
let address = tip.tip_address.as_deref().ok_or_else(|| {
|
||||||
.tip_address
|
AppError::ValidationError("tip_address missing for Electrum probe".into())
|
||||||
.as_deref()
|
})?;
|
||||||
.ok_or_else(|| AppError::ValidationError("tip_address missing for Electrum probe".into()))?;
|
|
||||||
|
|
||||||
let history = electrum.get_history(address).await?;
|
let history = electrum.get_history(address).await?;
|
||||||
// Chain height comes from ELECTRUM, not a local node (the backend runs none).
|
// 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> {
|
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() {
|
if tips.is_empty() {
|
||||||
debug!("no settled tips to reorg-check");
|
debug!("no settled tips to reorg-check");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -687,10 +702,22 @@ mod tests {
|
|||||||
// Funding tx: tip address RECEIVES; recv != 0 → not a spend.
|
// Funding tx: tip address RECEIVES; recv != 0 → not a spend.
|
||||||
let tx = VerboseTransaction {
|
let tx = VerboseTransaction {
|
||||||
txid: "fund".into(),
|
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![
|
vout: vec![
|
||||||
VerboseVout { value: 0.001, n: 0, script_pub_key: spk("ltc1qtip") },
|
VerboseVout {
|
||||||
VerboseVout { value: 0.296, n: 1, script_pub_key: spk("ltc1qalice") },
|
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());
|
assert!(!is_verbose_tx_spend_of(&tx, "ltc1qtip").unwrap());
|
||||||
@@ -701,8 +728,16 @@ mod tests {
|
|||||||
// Sweep: tip address absent from outputs (recv == 0) → spend.
|
// Sweep: tip address absent from outputs (recv == 0) → spend.
|
||||||
let tx = VerboseTransaction {
|
let tx = VerboseTransaction {
|
||||||
txid: "sweep".into(),
|
txid: "sweep".into(),
|
||||||
vin: vec![VerboseVin { txid: Some("fund".into()), vout: Some(0), prevout: None }],
|
vin: vec![VerboseVin {
|
||||||
vout: vec![VerboseVout { value: 0.00099, n: 0, script_pub_key: spk("ltc1qbob") }],
|
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());
|
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).
|
// recv != 0 → not a spend (guards the funding-with-change case).
|
||||||
let tx = VerboseTransaction {
|
let tx = VerboseTransaction {
|
||||||
txid: "pay".into(),
|
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![
|
vout: vec![
|
||||||
VerboseVout { value: 0.5, n: 0, script_pub_key: spk("ltc1qtip") },
|
VerboseVout {
|
||||||
VerboseVout { value: 0.3, n: 1, script_pub_key: spk("ltc1qpayer") },
|
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());
|
assert!(!is_verbose_tx_spend_of(&tx, "ltc1qtip").unwrap());
|
||||||
|
|||||||
+1
-1
@@ -92,7 +92,7 @@ async fn admin_login_guard_refresh_logout_flow() {
|
|||||||
|
|
||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let admin = app.admin_router();
|
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.
|
// Seed a PENDING admin key for our signer.
|
||||||
let sk = random_signer();
|
let sk = random_signer();
|
||||||
|
|||||||
+12
-9
@@ -89,7 +89,8 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option<TestApp> {
|
|||||||
.enabled
|
.enabled
|
||||||
.then(|| AdminSessionManager::new(&config.admin.jwt_secret));
|
.then(|| AdminSessionManager::new(&config.admin.jwt_secret));
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
config,
|
config: Arc::new(arc_swap::ArcSwap::from_pointee(config.clone())),
|
||||||
|
config_base: Arc::new(config),
|
||||||
db,
|
db,
|
||||||
sessions,
|
sessions,
|
||||||
chains,
|
chains,
|
||||||
@@ -98,6 +99,7 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option<TestApp> {
|
|||||||
web_challenges: Arc::default(),
|
web_challenges: Arc::default(),
|
||||||
prices,
|
prices,
|
||||||
admin_sessions,
|
admin_sessions,
|
||||||
|
shutdown: Arc::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
Some(TestApp {
|
Some(TestApp {
|
||||||
@@ -195,13 +197,14 @@ impl TestApp {
|
|||||||
extra_headers: &[(&str, &str)],
|
extra_headers: &[(&str, &str)],
|
||||||
body: Option<Value>,
|
body: Option<Value>,
|
||||||
) -> (StatusCode, axum::http::HeaderMap, Value) {
|
) -> (StatusCode, axum::http::HeaderMap, Value) {
|
||||||
let mut builder = Request::builder()
|
let mut builder =
|
||||||
.method(method)
|
Request::builder()
|
||||||
.uri(uri)
|
.method(method)
|
||||||
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
|
.uri(uri)
|
||||||
[127, 0, 0, 1],
|
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
|
||||||
0,
|
[127, 0, 0, 1],
|
||||||
))));
|
0,
|
||||||
|
))));
|
||||||
for (k, v) in extra_headers {
|
for (k, v) in extra_headers {
|
||||||
builder = builder.header(*k, *v);
|
builder = builder.header(*k, *v);
|
||||||
}
|
}
|
||||||
@@ -258,7 +261,7 @@ impl TestApp {
|
|||||||
.expect("mint token pair");
|
.expect("mint token pair");
|
||||||
let hash = hash_refresh_token(
|
let hash = hash_refresh_token(
|
||||||
&pair.refresh_token,
|
&pair.refresh_token,
|
||||||
&self.state.config.secrets.refresh_token_pepper,
|
&self.state.cfg().secrets.refresh_token_pepper,
|
||||||
);
|
);
|
||||||
self.state
|
self.state
|
||||||
.db
|
.db
|
||||||
|
|||||||
+2
-2
@@ -89,7 +89,7 @@ async fn erasure_request_confirm_execute_flow() {
|
|||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
.config
|
.cfg()
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.clone()
|
.clone()
|
||||||
@@ -221,7 +221,7 @@ async fn erasure_request_confirm_execute_flow() {
|
|||||||
assert!(body["erasure_id"].as_str().is_some());
|
assert!(body["erasure_id"].as_str().is_some());
|
||||||
assert_eq!(body["status"], "pending");
|
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:
|
// [HIGH regression] a request CANCELLED after confirmation is NOT executed:
|
||||||
// execute claims `WHERE status='confirmed'`, so a cancelled row is skipped.
|
// execute claims `WHERE status='confirmed'`, so a cancelled row is skipped.
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ async fn link_challenge_then_link_resolves_and_rejects_replay_and_wrong_user() {
|
|||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
.config
|
.cfg()
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
+14
-5
@@ -88,7 +88,7 @@ async fn register_creates_user_by_npub_and_is_idempotent() {
|
|||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
.config
|
.cfg()
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.clone()
|
.clone()
|
||||||
@@ -120,7 +120,7 @@ async fn register_replays_are_rejected() {
|
|||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
.config
|
.cfg()
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.clone()
|
.clone()
|
||||||
@@ -138,14 +138,23 @@ async fn register_replays_are_rejected() {
|
|||||||
"keys": [{ "asset": "btc", "public_key": format!("btcpub-{}", &pk[..16]) }],
|
"keys": [{ "asset": "btc", "public_key": format!("btcpub-{}", &pk[..16]) }],
|
||||||
});
|
});
|
||||||
let (s1, _) = app
|
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;
|
.await;
|
||||||
assert_eq!(s1, StatusCode::OK);
|
assert_eq!(s1, StatusCode::OK);
|
||||||
// Same nonce again -> consumed -> rejected (single-use replay guard).
|
// Same nonce again -> consumed -> rejected (single-use replay guard).
|
||||||
let (s2, _) = app
|
let (s2, _) = app
|
||||||
.request("POST", "/api/v1/auth/nostr/register", None, Some(body))
|
.request("POST", "/api/v1/auth/nostr/register", None, Some(body))
|
||||||
.await;
|
.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]
|
#[tokio::test]
|
||||||
@@ -160,7 +169,7 @@ async fn register_refuses_to_bind_npub_onto_existing_seed_fingerprint_row_withou
|
|||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
.config
|
.cfg()
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ async fn happy_path_consumes_tokens_creates_user_and_persists_keys() {
|
|||||||
let db = &app.state.db;
|
let db = &app.state.db;
|
||||||
|
|
||||||
let hash = hash_invite_code(&generate_invite_code());
|
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 pkh = format!("pay-pkh-{}", Uuid::new_v4());
|
||||||
let invoice = format!("inv-{}", 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.
|
// A fresh, claimable invite.
|
||||||
let hash = hash_invite_code(&generate_invite_code());
|
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.
|
// Register a NEW wallet that collides on the taken username.
|
||||||
let err = db
|
let err = db
|
||||||
.create_user_consuming_gates(Some(&hash), None, new_user(Some(&taken)), &keys())
|
.create_user_consuming_gates(Some(&hash), None, new_user(Some(&taken)), &keys())
|
||||||
.await
|
.await
|
||||||
.expect_err("a colliding username must fail the registration");
|
.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.
|
// The invite must NOT have been burned — the tx rolled back.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -99,7 +106,9 @@ async fn failed_payment_consume_does_not_burn_the_invite() {
|
|||||||
|
|
||||||
// A fresh, claimable invite.
|
// A fresh, claimable invite.
|
||||||
let hash = hash_invite_code(&generate_invite_code());
|
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
|
// A payment invoice that is ALREADY spent (simulates losing the consume race
|
||||||
// in `all` mode after the invite was claimed).
|
// in `all` mode after the invite was claimed).
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async fn is_registered_npub_membership() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admission_inbox_outbox_end_to_end() {
|
async fn admission_inbox_outbox_end_to_end() {
|
||||||
let app = require_app!();
|
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 author = hex64("b1"); // registered author (outbox)
|
||||||
let recipient = hex64("b2"); // registered recipient (inbox)
|
let recipient = hex64("b2"); // registered recipient (inbox)
|
||||||
|
|||||||
+462
-88
@@ -42,7 +42,12 @@ async fn public_tip_draft_create_get_cancel_flow() {
|
|||||||
|
|
||||||
// 1. Create a draft (no funding_txid) -> draft + share_url.
|
// 1. Create a draft (no funding_txid) -> draft + share_url.
|
||||||
let (status, body) = app
|
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;
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK, "create: {body}");
|
assert_eq!(status, StatusCode::OK, "create: {body}");
|
||||||
assert_eq!(body["status"], "draft");
|
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.
|
// 2. Public read (UNAUTH) surfaces it and is not yet claimable.
|
||||||
let (status, pub_body) = app
|
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;
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK, "get_public: {pub_body}");
|
assert_eq!(status, StatusCode::OK, "get_public: {pub_body}");
|
||||||
assert_eq!(pub_body["is_public"], true);
|
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}.
|
// 4. Cancel the draft (owner) -> {ok:true}.
|
||||||
let (status, cancel_body) = app
|
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;
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK, "cancel: {cancel_body}");
|
assert_eq!(status, StatusCode::OK, "cancel: {cancel_body}");
|
||||||
assert_eq!(cancel_body["ok"], true);
|
assert_eq!(cancel_body["ok"], true);
|
||||||
|
|
||||||
// 5. Cancelling again -> 404 (no longer a draft).
|
// 5. Cancelling again -> 404 (no longer a draft).
|
||||||
let (status, _) = app
|
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;
|
.await;
|
||||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
@@ -89,7 +109,10 @@ async fn public_tip_draft_create_get_cancel_flow() {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK, "sent: {sent}");
|
assert_eq!(status, StatusCode::OK, "sent: {sent}");
|
||||||
let tips = sent["tips"].as_array().expect("tips array");
|
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["status"], "cancelled");
|
||||||
assert_eq!(found["is_public"], true);
|
assert_eq!(found["is_public"], true);
|
||||||
}
|
}
|
||||||
@@ -113,7 +136,12 @@ async fn db_create_draft_roundtrips_all_columns() {
|
|||||||
grin_commitment: None,
|
grin_commitment: None,
|
||||||
};
|
};
|
||||||
// Exercises the full 32-column FromRow decode on RETURNING.
|
// 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.status, "draft");
|
||||||
assert_eq!(row.amount, 100_000);
|
assert_eq!(row.amount, 100_000);
|
||||||
assert!(row.is_public);
|
assert!(row.is_public);
|
||||||
@@ -136,7 +164,11 @@ async fn targeted_tip_is_rejected() {
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.await;
|
.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]
|
#[tokio::test]
|
||||||
@@ -147,13 +179,17 @@ async fn create_requires_positive_amount_and_claim_hash() {
|
|||||||
// Non-positive amount.
|
// Non-positive amount.
|
||||||
let mut b = draft_body();
|
let mut b = draft_body();
|
||||||
b["amount"] = json!(0);
|
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);
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
// Missing claim_key_hash on a public tip.
|
// Missing claim_key_hash on a public tip.
|
||||||
let mut b = draft_body();
|
let mut b = draft_body();
|
||||||
b.as_object_mut().unwrap().remove("claim_key_hash");
|
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);
|
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 (_uid, token, _r) = app.mint_session().await;
|
||||||
let (status, _) = app
|
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;
|
.await;
|
||||||
assert_eq!(status, StatusCode::BAD_REQUEST, "tips off must 400");
|
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")
|
.attach_funding_to_tip(tip_id, uid, "txid-DIFFERENT")
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.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).
|
// Not-owned / unknown tip -> NotFound (404).
|
||||||
let other = app.create_user().await;
|
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")
|
.attach_funding_to_tip(tip_id, other, "txid-x")
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, AppError::NotFound(_)), "expected NotFound, got {err:?}");
|
assert!(
|
||||||
|
matches!(err, AppError::NotFound(_)),
|
||||||
|
"expected NotFound, got {err:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -344,7 +391,10 @@ async fn mark_mismatch_transitions_to_funding_mismatch() {
|
|||||||
.mark_tip_funding_verified(tip_id, 10)
|
.mark_tip_funding_verified(tip_id, 10)
|
||||||
.await
|
.await
|
||||||
.expect("verify after mismatch");
|
.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]
|
#[tokio::test]
|
||||||
@@ -385,7 +435,10 @@ async fn get_tips_pending_confirmation_filters() {
|
|||||||
.get_tips_pending_confirmation("xmr")
|
.get_tips_pending_confirmation("xmr")
|
||||||
.await
|
.await
|
||||||
.expect("xmr pending");
|
.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!(
|
assert!(
|
||||||
!xmr_pending.iter().any(|t| t.id == xmr_draft),
|
!xmr_pending.iter().any(|t| t.id == xmr_draft),
|
||||||
"unfunded xmr draft must be excluded"
|
"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'.
|
// Fresh claimable tip: first claim locks it into 'claiming'.
|
||||||
let id = make_pending(&app, sender, "fund-claim-1").await;
|
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!(first.is_some(), "first claim on a pending tip must succeed");
|
||||||
assert_eq!(first.unwrap().status, "claiming");
|
assert_eq!(first.unwrap().status, "claiming");
|
||||||
|
|
||||||
// Re-claim on a 'claiming' public tip is INTENTIONALLY allowed: the claim
|
// 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
|
// state is a UX signal, not a lock — the reconciler resolves the on-chain
|
||||||
// sweep-race winner. (Verbatim audit-T1 retry semantics.)
|
// sweep-race winner. (Verbatim audit-T1 retry semantics.)
|
||||||
let reclaim = app.state.db.mark_tip_claiming(id, claimant).await.expect("reclaim");
|
let reclaim = app
|
||||||
assert!(reclaim.is_some(), "re-claim on a claiming public tip is allowed");
|
.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).
|
// None on funding_mismatch (audit-T1 fund-loss guard).
|
||||||
let mm = make_draft(&app, sender, "btc", 1_000_000, 0, None).await;
|
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
|
||||||
app.state.db.mark_tip_funding_mismatch(mm, 10).await.expect("mismatch").expect("row");
|
.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!(
|
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"
|
"a funding_mismatch tip must never be claimable"
|
||||||
);
|
);
|
||||||
|
|
||||||
// None on an unverified (pending_confirmation) tip.
|
// None on an unverified (pending_confirmation) tip.
|
||||||
let pc = make_draft(&app, sender, "btc", 100_000, 0, None).await;
|
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!(
|
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"
|
"an unverified tip must not be claimable"
|
||||||
);
|
);
|
||||||
|
|
||||||
// None on a clawed_back tip.
|
// None on a clawed_back tip.
|
||||||
let cb = make_pending(&app, sender, "fund-cb").await;
|
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!(
|
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"
|
"a clawed_back tip must not be claimable"
|
||||||
);
|
);
|
||||||
|
|
||||||
// None on an already-swept (settled) tip.
|
// None on an already-swept (settled) tip.
|
||||||
let swept = make_claimed(&app, sender, claimant, "fund-swept").await;
|
let swept = make_claimed(&app, sender, claimant, "fund-swept").await;
|
||||||
assert!(
|
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"
|
"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;
|
let id = make_claiming(&app, sender, claimant, "fund-sweep").await;
|
||||||
|
|
||||||
// First recorder writes txid-A; the row STAYS 'claiming' (record, not settle).
|
// 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.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).
|
// 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!(
|
assert_eq!(
|
||||||
r2.sweep_txid.as_deref(),
|
r2.sweep_txid.as_deref(),
|
||||||
Some("txid-A"),
|
Some("txid-A"),
|
||||||
@@ -545,13 +664,24 @@ async fn confirm_tip_sweep_first_recorder_wins() {
|
|||||||
assert_eq!(r2.status, "claiming");
|
assert_eq!(r2.status, "claiming");
|
||||||
|
|
||||||
// Same txid again is an idempotent no-op returning A.
|
// 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"));
|
assert_eq!(r3.sweep_txid.as_deref(), Some("txid-A"));
|
||||||
|
|
||||||
// A never-claimed (pending) tip → None.
|
// A never-claimed (pending) tip → None.
|
||||||
let pending = make_pending(&app, sender, "fund-cs-pending").await;
|
let pending = make_pending(&app, sender, "fund-cs-pending").await;
|
||||||
assert!(
|
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"
|
"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).
|
// Second settle is a no-op (one-way lock).
|
||||||
assert!(
|
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"
|
"an already-claimed row cannot be re-settled"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -593,11 +728,25 @@ async fn confirm_sweep_onchain_settles_claiming_only() {
|
|||||||
// entry into 'claimed'.
|
// entry into 'claimed'.
|
||||||
let pending = make_pending(&app, sender, "fund-settle-pending").await;
|
let pending = make_pending(&app, sender, "fund-settle-pending").await;
|
||||||
assert!(
|
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'"
|
"confirm_sweep_onchain requires status='claiming'"
|
||||||
);
|
);
|
||||||
let still = app.state.db.get_social_tip(pending).await.expect("get").expect("row");
|
let still = app
|
||||||
assert_eq!(still.status, "pending", "a pending tip must not be flipped to claimed");
|
.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
|
/// (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 claimant = app.create_user().await;
|
||||||
|
|
||||||
let id = make_claimed(&app, sender, claimant, "fund-reorg").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_eq!(reverted.status, "claiming");
|
||||||
assert!(reverted.sweep_confirmed_at.is_none());
|
assert!(reverted.sweep_confirmed_at.is_none());
|
||||||
assert_eq!(
|
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').
|
// Second revert is a no-op (now 'claiming', not 'claimed').
|
||||||
assert!(
|
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 requires status='claimed'"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Revert on a plain pending tip → None.
|
// Revert on a plain pending tip → None.
|
||||||
let pending = make_pending(&app, sender, "fund-reorg-pending").await;
|
let pending = make_pending(&app, sender, "fund-reorg-pending").await;
|
||||||
assert!(
|
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"
|
"a pending tip cannot be reorg-reverted"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -647,41 +812,105 @@ async fn clawback_status_matrix_and_settled_block() {
|
|||||||
|
|
||||||
// pending → clawable.
|
// pending → clawable.
|
||||||
let pending = make_pending(&app, sender, "cb-pending").await;
|
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.
|
// pending_confirmation → clawable.
|
||||||
let pc = make_draft(&app, sender, "btc", 100_000, 0, None).await;
|
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");
|
app.state
|
||||||
assert!(app.state.db.clawback_social_tip(pc, sender).await.expect("cb").is_some());
|
.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).
|
// claiming → clawable (sender racing a claimer).
|
||||||
let claiming = make_claiming(&app, sender, claimant, "cb-claiming").await;
|
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.
|
// funding_mismatch → clawable.
|
||||||
let mm = make_draft(&app, sender, "btc", 1_000_000, 0, None).await;
|
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
|
||||||
app.state.db.mark_tip_funding_mismatch(mm, 10).await.expect("mismatch").expect("row");
|
.db
|
||||||
assert!(app.state.db.clawback_social_tip(mm, sender).await.expect("cb").is_some());
|
.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.
|
// cancelled (GC'd but still-funded draft) → clawable.
|
||||||
let cancelled = make_draft(&app, sender, "btc", 100_000, 0, None).await;
|
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");
|
app.state
|
||||||
assert!(app.state.db.clawback_social_tip(cancelled, sender).await.expect("cb").is_some());
|
.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.
|
// Owner-scoped: a stranger cannot claw back; the real sender can.
|
||||||
let owned = make_pending(&app, sender, "cb-owner").await;
|
let owned = make_pending(&app, sender, "cb-owner").await;
|
||||||
let stranger = app.create_user().await;
|
let stranger = app.create_user().await;
|
||||||
assert!(
|
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"
|
"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).
|
// BLOCKED once the sweep confirmed (sweep_confirmed_at IS NOT NULL).
|
||||||
let swept = make_claimed(&app, sender, claimant, "cb-swept").await;
|
let swept = make_claimed(&app, sender, claimant, "cb-swept").await;
|
||||||
assert!(
|
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"
|
"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 pending = make_pending(&app, sender, "poll-pending").await;
|
||||||
let claimed = make_claimed(&app, sender, claimant, "poll-claimed").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");
|
let awaiting = app
|
||||||
assert!(awaiting.iter().any(|t| t.id == claiming), "a claiming tip awaits sweep confirmation");
|
.state
|
||||||
assert!(!awaiting.iter().any(|t| t.id == pending), "a pending tip does not await sweep confirmation");
|
.db
|
||||||
assert!(!awaiting.iter().any(|t| t.id == claimed), "a settled tip does not await sweep confirmation");
|
.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");
|
let reorg = app
|
||||||
assert!(reorg.iter().any(|t| t.id == claimed), "a settled tip with a height witness is reorg-checked");
|
.state
|
||||||
assert!(!reorg.iter().any(|t| t.id == claiming), "a claiming tip is not reorg-checked");
|
.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 ────────────────────
|
// ── 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;
|
backdate_created_at(&app, stale, "8 days").await;
|
||||||
let fresh = make_draft(&app, uid, "btc", 100_000, 0, None).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");
|
let cancelled = app
|
||||||
assert!(cancelled.iter().any(|r| r.id == stale), "a > 7d draft must be cancelled");
|
.state
|
||||||
assert!(!cancelled.iter().any(|r| r.id == fresh), "a fresh draft must NOT be cancelled");
|
.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");
|
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");
|
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 uid = app.create_user().await;
|
||||||
|
|
||||||
let stale = make_draft(&app, uid, "btc", 100_000, 0, None).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;
|
backdate_created_at(&app, stale, "8 days").await;
|
||||||
|
|
||||||
let fresh = make_draft(&app, uid, "btc", 100_000, 0, None).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
|
let cancelled = app
|
||||||
.state
|
.state
|
||||||
@@ -776,9 +1061,21 @@ async fn cancel_stuck_pending_confirmation_flips_stale_only() {
|
|||||||
"a fresh pending_confirmation must NOT be cancelled"
|
"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");
|
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");
|
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");
|
.expect("backdate claimed_at");
|
||||||
let fresh = make_claiming(&app, sender, claimant, "gc-fresh-claiming").await;
|
let fresh = make_claiming(&app, sender, claimant, "gc-fresh-claiming").await;
|
||||||
|
|
||||||
let stuck = app.state.db.log_stuck_claiming().await.expect("log_stuck_claiming");
|
let stuck = app
|
||||||
assert!(stuck.iter().any(|r| r.id == stale), "a > 15m claiming row must be surfaced");
|
.state
|
||||||
assert!(!stuck.iter().any(|r| r.id == fresh), "a fresh claiming row must NOT be surfaced");
|
.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.
|
// 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");
|
let row = app
|
||||||
assert_eq!(row.status, "claiming", "log_stuck_claiming must not mutate status");
|
.state
|
||||||
assert!(row.sweep_confirmed_at.is_none(), "log_stuck_claiming must not settle the sweep");
|
.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 ────────────────────────────────────────────────
|
// ── 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
|
// Migration applied: the grin_commitment column round-trips, tip_address
|
||||||
// mirrors it, and the grin funding threshold is 10.
|
// mirrors it, and the grin funding threshold is 10.
|
||||||
let uuid = tip_id.parse::<uuid::Uuid>().unwrap();
|
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.asset, "grin");
|
||||||
assert_eq!(row.grin_commitment.as_deref(), Some(commit.as_str()));
|
assert_eq!(row.grin_commitment.as_deref(), Some(commit.as_str()));
|
||||||
assert_eq!(row.tip_address.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]
|
#[tokio::test]
|
||||||
async fn grin_create_falls_back_to_tip_address_for_commitment() {
|
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 (_uid, token, _refresh) = app.mint_session().await;
|
||||||
let commit = grin_commit();
|
let commit = grin_commit();
|
||||||
|
|
||||||
@@ -890,22 +1218,38 @@ async fn grin_create_falls_back_to_tip_address_for_commitment() {
|
|||||||
let (status, resp) = app
|
let (status, resp) = app
|
||||||
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK, "create grin (no commit field): {resp}");
|
assert_eq!(
|
||||||
let uuid = resp["tip_id"].as_str().unwrap().parse::<uuid::Uuid>().unwrap();
|
status,
|
||||||
let row = app.state.db.get_social_tip(uuid).await.expect("get").expect("row");
|
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()));
|
assert_eq!(row.grin_commitment.as_deref(), Some(commit.as_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn grin_create_rejects_junk_commitment() {
|
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;
|
let (_uid, token, _refresh) = app.mint_session().await;
|
||||||
|
|
||||||
// tip_address that isn't a 66-hex commitment -> clean 400.
|
// tip_address that isn't a 66-hex commitment -> clean 400.
|
||||||
for bad in [
|
for bad in [
|
||||||
"deadbeef".to_string(), // too short
|
"deadbeef".to_string(), // too short
|
||||||
"z".repeat(66), // right length, not hex
|
"z".repeat(66), // right length, not hex
|
||||||
format!("09{}", "a1".repeat(33)), // 68 chars, too long
|
format!("09{}", "a1".repeat(33)), // 68 chars, too long
|
||||||
] {
|
] {
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"asset": "grin",
|
"asset": "grin",
|
||||||
@@ -917,7 +1261,11 @@ async fn grin_create_rejects_junk_commitment() {
|
|||||||
let (status, resp) = app
|
let (status, resp) = app
|
||||||
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
||||||
.await;
|
.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
|
// 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
|
let (status, resp) = app
|
||||||
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
||||||
.await;
|
.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]
|
#[tokio::test]
|
||||||
@@ -945,7 +1297,8 @@ async fn grin_create_rejected_when_chain_disabled() {
|
|||||||
c.features.chains.grin = false;
|
c.features.chains.grin = false;
|
||||||
c.tip_share_base = Some("https://tips.example".into());
|
c.tip_share_base = Some("https://tips.example".into());
|
||||||
})
|
})
|
||||||
.await else {
|
.await
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let (_uid, token, _refresh) = app.mint_session().await;
|
let (_uid, token, _refresh) = app.mint_session().await;
|
||||||
@@ -961,7 +1314,11 @@ async fn grin_create_rejected_when_chain_disabled() {
|
|||||||
let (status, resp) = app
|
let (status, resp) = app
|
||||||
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
||||||
.await;
|
.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
|
/// 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.
|
/// the grin worker arms depend on.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn grin_tip_db_lifecycle_columns() {
|
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 uid = app.create_user().await;
|
||||||
let commit = grin_commit();
|
let commit = grin_commit();
|
||||||
|
|
||||||
@@ -988,7 +1347,13 @@ async fn grin_tip_db_lifecycle_columns() {
|
|||||||
confirmations_required: 10,
|
confirmations_required: 10,
|
||||||
grin_commitment: Some(&commit),
|
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).
|
// attach-funding with the client's bookkeeping id (grin has no real txid).
|
||||||
let r = app
|
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()));
|
assert_eq!(r.grin_commitment.as_deref(), Some(commit.as_str()));
|
||||||
|
|
||||||
// Simulate the funding worker: 10 confs reached + amount auto-verified.
|
// 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
|
let verified = app
|
||||||
.state
|
.state
|
||||||
.db
|
.db
|
||||||
@@ -1014,7 +1383,12 @@ async fn grin_tip_db_lifecycle_columns() {
|
|||||||
|
|
||||||
// Claim, then record the sweep's kernel excess as sweep_txid.
|
// Claim, then record the sweep's kernel excess as sweep_txid.
|
||||||
let claimant = app.create_user().await;
|
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 excess = format!("08{}", "bc".repeat(32)); // kernel excess (66 hex)
|
||||||
let swept = app
|
let swept = app
|
||||||
.state
|
.state
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ else
|
|||||||
mapfile -t FILES < <(find . -type f -not -path './.git/*' -not -path './target/*' -printf '%P\n')
|
mapfile -t FILES < <(find . -type f -not -path './.git/*' -not -path './target/*' -printf '%P\n')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Secret shapes (not names): PEM/OpenSSH/PGP private-key blocks and seed phrases.
|
# Secret shapes (not names), covering the classes this backend actually handles:
|
||||||
PATTERNS='(-----BEGIN ([A-Z ]+ )?PRIVATE KEY-----|-----BEGIN PGP PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY|\b(mnemonic|seed[_ ]?phrase)\b\s*[:=])'
|
# - 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
|
fail=0
|
||||||
for f in "${FILES[@]}"; do
|
for f in "${FILES[@]}"; do
|
||||||
|
|||||||
Reference in New Issue
Block a user