Provider scoping (items 1/2/6):
- Key visibility maps on (model_id.lower(), upstream_provider_id), matching
refresh_model_maps, so a disable/override row on one provider never leaks
onto another provider's model, and matching is case-insensitive.
Data safety (items 3/5):
- Degraded OpenRouter fetches (network error, 429, non-200, bad payload)
return None (unknown) instead of []; a provider whose path set is unknown
keeps its previously persisted rows instead of being wiped.
- Endpoint payload parsing moved fully inside try, with a list guard, so
endpoints:null or non-list shapes are swallowed as documented.
- refresh with an empty live upstream list is a no-op; the unfiltered
DELETE in the prune path is gone (prune now keys off enabled DB rows).
Hot path (items 4/12/14):
- Persist uses chunked bulk INSERTs (one statement per 500 rows) instead of
per-row ORM adds; redundant ix_model_paths_model_id index dropped.
- Read routes filter in SQL instead of materializing the whole table, and
output ordering is deterministic (public id + path), independent of rowid.
- Visibility no longer rebuilds fully priced Model objects per override row;
it reads id/forwarded_model_id/canonical_slug straight off ModelRow.
Path/id contract (items 7/8/9/11):
- discovery_path_for_subprovider/discovery_base_paths hooks on
BaseUpstreamProvider, overridden by OpenRouterUpstreamProvider, mirror
_apply_provider_field so discovery and response stamping cannot drift
(openrouter:OpenRouter now correctly maps to unknown).
- openrouter_author_slug falls back to a slash-containing forwarded_model_id,
so admin-created alias rows are discoverable.
- public_model_id splits on the first slash, same as get_base_model_id, so
discovery ids can be sent to chat completions verbatim.
Lifecycle (items 10/13):
- ENABLE_MODEL_PATHS_REFRESH kill switch; interval and flag re-read every
loop iteration, and the task idles (not exits) while disabled.
- First 429 latches and aborts the remaining fan-out for the cycle; a
per-cycle cache dedupes fetches across providers sharing a base URL.
- refresh_model_maps prunes paths of disabled/deleted providers so admin
mutations take effect immediately; rows carry updated_at and both
endpoints expose it.
Tests (item 15) rewritten through the public refresh entry point with
transport-level httpx.MockTransport fakes, FK enforcement on, and coverage
for the periodic loop. Migration re-chained onto 9c4d8e2f1a6b.
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>
Explain that ROUTSTR_SECRET_KEY is now mandatory (with the generation command)
and describe the first-run flow where a temporary admin password is logged once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Provide an offline recovery command that sets a new admin password directly in
the Secret store, for operators locked out of the admin UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an admin endpoint to set, rotate and clear the nsec, authenticate against
the stored password hash, and redact secret values (nsec shown as [REDACTED])
in settings responses. Wire the admin UI to the new endpoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Persist and load admin password and nsec from the encrypted Secret store on
boot: generate a temporary admin password on first run (logged once), encrypt
a provided nsec, and fail fast if a stored nsec cannot be decrypted with the
current key. Stop clobbering live secret settings with empty env values.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a singleton Secret model holding the admin password hash and the
Fernet-encrypted nsec, with a hand-written migration for the secrets table.
Add suite-wide pytest config pinning a valid ROUTSTR_SECRET_KEY.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Encrypt/decrypt secrets at rest with Fernet keyed by ROUTSTR_SECRET_KEY,
and hash/verify admin passwords with scrypt. Self-contained helpers with
no consumers yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>