Add DATABASE_POOL_SIZE / DATABASE_MAX_OVERFLOW / DATABASE_POOL_TIMEOUT,
consumed like every other typed env var through the pydantic Settings
(constrained Fields, defaults 5/10/30 matching SQLAlchemy's own baseline
so leaving them unset is behaviour-neutral). An out-of-range or
non-integer value fails validation and refuses to boot — the same
fail-loud behaviour a malformed DATABASE_URL already has — rather than
silently starting up misconfigured. create_db_engine sizes the pool from
these and logs the effective values at startup so they can be confirmed
from the boot output during an incident. In-memory SQLite (StaticPool,
which rejects the pool kwargs) is detected and built without them.
pool_pre_ping is deliberately not exposed: the default backend is a local
SQLite file with no network peer to drop idle connections, so it would add
a SELECT 1 per checkout for no benefit — and it detects dead connections,
not the live-but-wedged ones behind the exhaustion this series addresses.
These knobs are infrastructure the node needs before it can open a DB
session, so they can never be sourced from the DB (chicken-and-egg). A new
ENV_ONLY_FIELDS set keeps them out of the persisted settings blob and
stops a DB value from shadowing env in both SettingsService.initialize and
.update, so env stays authoritative.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fetch_all_balances shared a single AsyncSession across the tasks it ran
with asyncio.gather. AsyncSession is not safe for concurrent use: the
overlapping queries raised "concurrent operations are not permitted" and
left connections wedged until the QueuePool was exhausted, after which
admin/API endpoints returned 500/502 until a container restart.
Read all outstanding user liabilities up front in one short-lived session
with a single grouped query (balances_by_mint_and_unit), then run the
per-mint balance checks concurrently with no session in scope. A failure
reading liabilities now degrades gracefully — the page still reports the
known wallet custody and blanks only the unknowable user/owner split,
tagging each mint with the error — instead of 500-ing the whole page.
periodic_payout reads each liability fresh, immediately before the payout
decision, rather than from a single pre-loop snapshot: the per-mint round
trip is slow, and a user top-up during the cycle would otherwise let a
later mint/unit act on a stale-low liability and over-send funds owed to
users (related to the payout-safety concern in issue #611).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- periodic_payout: fetch liability per mint/unit right before computing
available balance, so a concurrent top-up can only shrink the payout
- refund sweep: atomically claim each refund before redeeming; release the
claim on failure so retries still happen and concurrent sweeps cannot
misreport a sweep as client-collected
- check_invoice_payment: catch BaseException so task cancellation after a
successful mint still emits the reconciliation alert
- tests: DB-guard race test where both mints succeed (exactly one credit);
pool_size=1 test proving the fee payout releases its connection during
the external send
The add-secrets migration was amended in place across the review rounds
(notably the nsec_state column), so its revision id no longer maps to a
single schema step and any DB that ran an intermediate form would not
re-migrate. Recreate it under a fresh id (fc4fa29630d2) chained onto the
current head so the migration is one clean, unambiguous step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test_get_balance mocked Wallet.with_db but not the module-level _wallets
cache, so a real wallet cached by an earlier unmocked path (e.g. an
admin-withdraw amount-rejection test) could shadow the mock and fail the
assertion depending on collection order. Reset the cache for the test, as
the sibling wallet tests already do.
Rebasing onto main picked up the fee-payout-checkpoint migration
(d7e8f9a0b1c2), which forked from the same parent as the secrets
migration. Re-point secrets onto it so alembic has a single head.
The admin password is generated and logged on first start and the nsec is
set from the admin UI; ADMIN_PASSWORD/NSEC in .env are only a legacy seed.
Update the README, quickstart, configuration, and deployment docs to match,
and drop the unused ADMIN_KEY environment variable.
Several compose/.env examples mounted /app/data but left DATABASE_URL at the
relative default, so the database — and the master key file generated beside it —
landed off the persisted volume and would be lost on a container recreate. Point
DATABASE_URL inside /app/data in those examples and list routstr_secret.key in
the persistence table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generated-password bootstrap now sets an admin password on every fresh node, so
the /admin/api/setup success path is unreachable — it 409s ("already set") on
any booted node. No caller exists across the admin UI, routstr-cli, routstr-sdk,
routstrd, or routstr-chat, so the endpoint (and its SetupRequest model) are dead
surface. First-run is now: the generated password is printed once at boot, log
in via /admin, and change it from the dashboard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bootstrap-correctness fixes on the encrypted Secret store:
- Track nsec ownership with an explicit nsec_state (legacy | encrypted |
cleared) instead of a nsec_managed bool. The bool could not tell "never
migrated" apart from "intentionally cleared" — both leave encrypted_nsec
empty — so a cleared identity could be resurrected on a fresh process from a
stale legacy NSEC (env or old settings blob) and re-derive its npub. Bootstrap
now branches purely on the state: encrypted decrypts (a missing ciphertext is
a fail-fast inconsistency, never a silent fall-through to legacy), cleared
actively empties the live nsec and npub, and legacy imports the plaintext
once.
- Claim a generated admin password atomically. When no password exists, the
generated one is written via a conditional UPDATE (WHERE admin_password_hash
IS NULL) and only the worker that wins the update (rowcount 1) prints it. A
racing worker on a shared database adopts the winner's hash and stays silent,
so the operator never sees a second password that was never stored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes to how a node provisions its own master key when the operator
sets no ROUTSTR_SECRET_KEY:
- Publish the key atomically. It is written to a same-directory temp file,
fsynced, then os.link-ed into place and the directory fsynced. os.link
publishes the complete file in one step, so a crash mid-write can no longer
strand an empty key at the final path that a later boot would read as corrupt
and then fail to decrypt every secret under. os.link also refuses to clobber,
so a racing worker that generated first keeps ownership and the loser adopts
its key.
- Tighten loose permissions on read. A key file that is group/other-readable is
repaired to 0600 rather than trusted, keeping an upgrading node booting.
- Stop printing the key value. The one-time notice names the file to back up and
shouts the backup imperative, but no longer echoes the key itself, which would
leak it into captured stdout / aggregated container logs; the durable 0600 file
is the recovery path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The secret-key docs still said ROUTSTR_SECRET_KEY was required and that
the node would not start without it. Update the README, .env.example, and
the provider docs (configuration, quickstart, deployment) to the current
behaviour: the key is optional; when unset the node generates one beside
the database, so it persists on the same volume as the data, and prints a
one-time back-it-up notice; set it explicitly to manage the key yourself.
Switch the generation and reset snippets to `uv run python`, and add the
containerised reset variant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A node with a legacy plaintext nsec but no ROUTSTR_SECRET_KEY refused to
boot: bootstrap_secrets raised and vault.encrypt required the env key.
That turned encryption at rest into a hard breaking change on auto-upgrade.
Encryption stays mandatory — the nsec is never persisted in plaintext —
but key custody becomes flexible. When no ROUTSTR_SECRET_KEY is set,
encrypt() generates a Fernet key, writes it owner-only (0600) to a key
file, and prints a one-time back-it-up notice, so an upgrading node keeps
running. The read path stays strict: decrypt()/get_fernet() never mint a
key (a fresh key could not match existing ciphertext) and fail fast with
the generation command when none is configured. A malformed env key still
fails fast rather than silently self-provisioning a different key.
The key file defaults beside the SQLite database (ROUTSTR_SECRET_KEY_FILE
overrides), so it rides whatever volume already persists the data instead
of a working-directory path a container recreate would drop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
initialize() only filled npub when it was empty, so an existing node
with a stale NSEC still lingering in its env/blob kept that value's
derived npub even after the vault took ownership of a different nsec.
The node then held the vault's private key but announced the old env
key's public key — a split identity that anything reading settings.npub
would broadcast.
npub is a pure derivation of nsec and is never configured on its own, so
derive it from the live (vault) nsec and override rather than only fill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two ways a stale legacy NSEC could override or resurrect an nsec the
vault already owns (issue #553):
- initialize() re-applied env/blob values onto live settings after
bootstrap had decrypted the authoritative nsec, so a stale NSEC left
in .env would clobber it on restart (e.g. after rotating the key in
the admin UI). _apply_to_live_settings now never re-applies secret
fields; bootstrap_secrets is their only writer.
- An empty encrypted_nsec could not distinguish "never migrated" from
"intentionally cleared", so clearing the identity via the admin API
and restarting re-imported the old NSEC from env/blob. Record vault
ownership in a new secrets.nsec_managed column (set on legacy import
and on every set_nsec write); bootstrap skips the legacy import once
the vault owns the nsec, so a cleared identity stays cleared.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The add-secrets migration branched off b5e7c9d1f3a2, but the add-slug
migration c6d7e8f9a0b1 has since landed on that same parent, leaving two
Alembic heads. `alembic upgrade head` then refuses to run and the node
fails to boot. Repoint down_revision onto the current head so the chain
is linear again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review hardening items on the secret-storage path:
- vault.verify_password caps N/r/p at the parameters this module emits, so a
tampered or corrupt stored hash can't force an unbounded scrypt work factor
(memory grows with N*r) and turn a login into an OOM/DoS.
- bootstrap prints the generated first-run admin password to stdout instead of
the logger, so it reaches the operator once without being persisted into the
on-disk log files.
- admin_login reads the password hash while the DB session is open rather than
off a detached ORM instance after the context exits.
- drop a stray debug print of the request payload in upsert_provider_model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the nsec lives only in the encrypted Secret store (env carries no NSEC) and
the settings blob holds no npub, bootstrap_secrets decrypted the nsec and derived
the npub into memory, but SettingsService.initialize then re-derived settings
from the npub-less blob and overwrote the live npub back to empty — leaving a
private key with no matching public key, so the node silently stopped announcing
a usable Nostr identity.
Derive npub from the live nsec during initialize when the merged settings carry
none, so the public key stays consistent with the identity and is persisted to
the blob.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Encryption of the Nostr identity at rest is mandatory. When a legacy nsec is
present (env or blob) but no ROUTSTR_SECRET_KEY is set, bootstrap previously
fell into vault.encrypt and surfaced its generic "key not set" error. Raise an
explicit, nsec-contextual error first so the boot failure is intentional and
actionable — it names the missing key and prints the generation command —
rather than relying on vault throwing incidentally. No secret is dropped: the
node refuses to start until the operator sets the key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
upstream_api_key was added to SECRET_FIELDS, so it was stripped from every
blob write — but unlike nsec, nothing migrates it into encrypted storage. A
node carrying it only in the DB blob would load it into memory once, rewrite
the blob without it, and lose it on the next restart, breaking upstream auth.
It is node-scoped config that really belongs on a provider, not a vault
secret, and it has no encrypted home yet. Remove it from SECRET_FIELDS so it
stays in the blob exactly as before; redaction on read and ignore-on-write in
the admin settings endpoint are unchanged. Encrypting it is follow-up work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>