Files
jwinterm a455c60ed0 docs: operator console guide + fix example recipes + document all env vars
- New docs/operations/CONSOLE.md: reaching /admin, NIP-98 login, the
  defaults->env->DB precedence model, per-section runtime-safe vs
  restart-required table, expected_versions concurrency, restart_apply_mode
  (with the systemd Restart=always requirement for auto), clearing a DB
  override. Linked from README + OPERATOR_SETUP.
- All six example recipes gained an explicit chain-enablement block
  (FEATURE_XMR/WOW on, BTC/LTC/GRIN off) so they no longer hard-fail
  Config::validate in production (default-on chains with empty sources).
- Documented all 16 previously-undocumented env vars in .env.example
  (RESTART_APPLY_MODE, TOR_ADMIN_ONION, DEPLOYMENT_MODE, TIP_SHARE_BASE_URL,
  ...); RELAY.md gained the premium-post policy + RELAY_WRITE_ALLOWLIST_NPUBS;
  ADMIN_PUBKEYS noted as reserved/inert.
2026-07-19 15:52:07 -04:00

12 KiB

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:

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 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:

    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.