fetcherVersion=2 is deprecated and scheduled for removal in nixpkgs
26.11. Migrate to v3 and regenerate the pnpm-deps hash. Supersedes the
prior v2 hash regen (@5ff4e41) — v3 is the durable form and stops the
per-nixpkgs-bump FOD drift the v2 fetcher was causing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRPdZWqJHDt8vLmm1qUyd7
The pnpm v2 FOD hash drifted against the current nixpkgs pin (the
fetchPnpmDeps fetcherVersion=2 output format changed), breaking every
downstream build with a hash mismatch. Update to the current value.
fetcherVersion=2 is deprecated (removal in 26.11); migrating to v3 is a
separate follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRPdZWqJHDt8vLmm1qUyd7
get_public_key returns the signer pubkey without routing through
pubkeyAllowed(), unlike every other NIP-46 method. #26 flagged this as an
unaudited/ungated disclosure through the ACL seam and asked us to decide
deliberately between gating it and documenting the exception.
Verified against the live clients: gating it would BREAK production.
lnbits' _ensure_policy (remote_bunker.py DEFAULT_POLICY_RULES +
DEFAULT_POLICY_METHODS_NO_KIND) grants only sign_event(kinds) + the four
nip04/44 crypto methods — no get_public_key rule — and the client calls
get_public_key as a spec-mandated, hardcoded post-connect session step
(nip46_bunker_client.py connect()). Routing it through checkIfPubkeyAllowed
would return `undefined`, dropping that call onto the admin-approval path
and stalling session establishment → "signer unavailable" (the #41 outage
class).
So the correct resolution is #26's option A (accept + document): the pubkey
isn't secret, NIP-46 mandates it ungated during session setup, and the
clients carry no grant for it. Make the exception explicit and load-bearing
in the code so a future refactor doesn't "helpfully" gate it and reintroduce
the outage. No behavior change; tsc-clean; test:nip46 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
requestPermission's 10s approval timeout called resolve(undefined) but never
clearPending, and a single admin's response cleared only its own id. So every
timed-out approval, and (with multiple admin npubs) the non-responding admins'
entries, leaked permanently in transport.pending — each retaining its closure
over remotePubkey/keyName/method/the serialized sign_event payload, so growth
tracked signing traffic. Scope: the admin-DM auth path (only when no web baseUrl
is set).
- Collect every issued request id; a single `finish()` resolves once and clears
ALL of them, on both timeout and the first response.
- A `settled` latch makes late/duplicate responses no-ops — which also stops a
late 'always' approval from running allowAllRequestsFromKey after the request
already resolved (review AD-2).
- Guard nip19.decode so a malformed admin npub skips that admin instead of
throwing through the loop.
- Defense-in-depth: bound transport.pending at 1000 (evict oldest) so any other
un-cleared path can't grow it without limit.
tsc at baseline; daemon bundles; admin + nip46 suites green.
Refs: transport review AD-1/CS-2/AD-2; #42
Folds three medium findings from the transport review into the RelayPool:
- RP-4: connectOnce used the static Relay.connect(), which silently DROPS a
{timeout} option in nostr-tools 2.20.0, so a black-holed TCP connect (SYN
accepted, never upgraded) stalled that relay's loop for the OS socket timeout
(minutes) with no retry. Now constructs the Relay and calls the instance
.connect({ timeout: 5000 }), which honours the timeout → prompt reject + backoff.
- RP-2: connectOnce didn't re-check `stopped` after the await. If stop() ran
while a connect was in flight, the resolved socket re-armed subscriptions on a
relay we meant to abandon, leaked the socket, and hung connectLoop (its promise
never resolved because onclose never fired). Now drops the socket cleanly and
resolves if stopped mid-connect.
- RP-1: no cross-relay event dedup — a kind:24133 request published to N relays
(the normal NIP-46 pattern), or re-delivered after a reconnect, drove the
daemon handler + recordSigning N times, making rate caps bind ~N× tighter
(fails closed, not open). Added a bounded (4000-id, ≈LRU) pool-wide seen-set;
onevent fires at most once per event id. Closes the CS-4 replay vector too.
Test: tests/relay-pool.test.ts asserts a duplicate event id is delivered once.
relay 3 / nip46 1 / admin 2 green; daemon bundles; tsc at baseline.
Refs: transport review RP-1/RP-2/RP-4, CS-4; #42
Folds the review's CS-3 finding into the boot-DM fix:
- notifyAdminsOfNewConnection wraps the publish loop in try/finally so a throw
mid-loop can't leak the throwaway pool's reconnect loops + sockets for the
process lifetime.
- The constructor's fire-and-forget call now .catch()es (both the notify and the
config() it chains off), so a boot-DM failure can't surface as an unhandled
rejection — process-terminating under Node defaults.
- dmUser guards its whole body: nip19.decode/nip04.encrypt ran *before* the old
publish-only try, so a malformed admin npub (passes startsWith, fails the
bech32 checksum) threw past the caller. Now best-effort, never throws.
Refs: #48, review CS-3
The boot-time admin notification (notifyAdminsOfNewConnection) spun up a
throwaway RelayPool to two external public relays, slept a fixed 2500ms, then
published. Those relays are often slow to connect, so on the aio-demo deploy the
publish failed on boot ("relay not connected: wss://blastr.f7z.xyz; …") — caught
and logged, non-fatal, but noisy every boot.
Poll pool.connectedCount() > 0 up to an 8s cap instead: send as soon as a relay
is up, and still best-effort — fall through and let dmUser log the failure if
none connect in time.
Refs: #48, #45
Final NDK removal (follow-up to #44/#45). The standalone CLI (src/client.ts)
was the last NDK user — a NIP-46 *client* (NDKNip46Signer). Ported to a small
nostr-tools client so the dependency can leave the repo completely.
- src/nip46-client.ts — a minimal NIP-46 client over the RelayPool, the inverse
of the daemon's Nip46Transport: send requests to a remote signer, match
responses by id, surface auth_url prompts; nip44 envelope. Methods: connect /
signEvent / createAccount.
- src/client.ts — rebuilt on it. nip05/npub/bare-domain resolution via
nostr-tools nip05.queryProfile + nip19; local client key stored as hex;
publish via the pool. Dropped the now-unused websocket-polyfill import.
- package.json + pnpm-lock.yaml — `@nostr-dev-kit/ndk` removed (and its orphaned
transitive deps; lockfile reconciled with `pnpm install --lockfile-only`, no
version-format change).
`grep -r @nostr-dev-kit/ndk src` is now empty; all three bundles (index, daemon,
client) build with 0 NDK references. Tests: lifecycle 7 / relay 2 / nip46 1 /
admin 2 green; tsc at the pre-existing baseline.
Refs: #44, #45, #43, #42, #41
Follow-up to #43, which swapped the relay transport off NDK. NDK no longer
touched the relay/reconnect path but lingered in non-transport helpers; this
removes it from the daemon completely. The daemon and main-entry bundles now
contain zero `@nostr-dev-kit/ndk` references.
Ported to nostr-tools:
- run.ts getKeys — `NDKPrivateKeySigner(nsec).user().npub` -> getPublicKey +
nip19.npubEncode (via secretKeyBytes).
- admin/commands/create_new_key.ts, create_account.ts — key generation
(generate / import / existing-npub) -> generateSecretKey / getPublicKey /
nip19; private-key hex via Buffer.
- lib/profile.ts (setupSkeletonProfile) — kind:0/3/10002 publish via a throwaway
RelayPool + finalizeEvent; NDKUserProfile -> local SkeletonProfile type.
- admin/index.ts notifyAdminsOfNewConnection + utils/dm-user.ts — the one-shot
boot DM (kind:4 nip04) via a throwaway RelayPool.
- commands/start.ts nip89announcement — kind:31990 NIP-89 handler via finalizeEvent
+ RelayPool, nip05 check via nostr-tools nip05.queryProfile. (Dropped the
fetch-existing-d-tag step: this code always uses the default d="24133", so a
re-publish replaces the prior addressable event — same effect.)
- config/index.ts — default-admin-key generation -> generateSecretKey + hex.
- acl/index.ts, authorize.ts — type-only imports (NostrEvent/NIP46Method/Hexpubkey)
-> nostr-tools Event / local nip46 types / string.
Also fixes a latent bug #43 surfaced here: authorize.ts's requestAuthorization
called `param.rawEvent()`, but since #43 the signer passes a plain event object
(no .rawEvent), so a sign_event approval would have recorded "[object Object]".
The nostr-tools Event type caught it; now it JSON.stringifies the object.
`@nostr-dev-kit/ndk` stays in package.json ONLY for the standalone CLI
(src/client.ts, a NIP-46 *client* — the inverse component), per #44's carve-out.
Porting the CLI to drop the dependency entirely can be a small follow-up.
Tests: lifecycle 7 / relay 2 / nip46 1 / admin 2 green; daemon + main bundles
NDK-free (0 refs); tsc at the pre-existing baseline (3 unrelated authorize.ts /
web/authorize.ts errors).
Refs: #44, #43, #42, #41
Third increment of the NDK -> nostr-tools transport swap. The admin interface's
runtime RPC now runs on the RelayPool transport, so the admin channel — like the
signer channel — re-subscribes on every relay reconnect and can't go silently
deaf after a flap (#41).
- nip46/transport.ts is now a full RPC: besides serving inbound requests it
routes inbound RESPONSES to one-shot handlers (the pending map) and can
sendRequest() — needed for the interactive approval flow (bunker -> operator
"acl" request). start() takes the kinds to listen on; sendResponse() takes the
response kind. The signer backend is unaffected (still one kind, response-only).
- admin/index.ts: rebuilt on RelayPool + Nip46Transport instead of NDK + NDKNostrRpc
+ attachIndefiniteReconnect. `rpc` is a small adapter the command handlers keep
calling; it resolves each request's envelope scheme (nip04/nip44) by id and
publishes on the admin channel (24134). requestPermission/Response use
transport.sendRequest + nip19 instead of NDKNostrRpc + NDKUser. The
connectedRelays()-only watchdog is replaced by a session-liveness one on
pool.healthy() (connected AND subscribed) — the check the old one couldn't make
(#20/#41).
- admin/types.ts: AdminRpcRequest / AdminRpc replace NDKRpcRequest / NDKNostrRpc.
The ~16 command + validation handlers swap the import only (they use
req.{id,pubkey,method,params,event.kind}); no logic change.
- admin/kinds.ts: plain numeric kinds (24133/24134), no NDKKind type dep.
- relay-reconnect.ts deleted — its job (reconnect) now lives in the pool, and its
blind spot (no resubscribe) is exactly what #41 was.
Still on NDK (not transport, addressed separately): the one-shot boot DM
(notifyAdminsOnBoot, throwaway NDK over public relays), key generation in
create_new_key/create_account, the getKeys npub helper, and the standalone CLI
client (src/client.ts).
Tests (tests/admin-transport.test.ts): a request on 24133 is answered on 24134
and survives a relay flap; the sendRequest + response-routing approval flow round-
trips. lifecycle 7 / relay 2 / nip46 1 / admin 2 all green; daemon bundles clean;
zero new type errors.
Refs: #42, #41, #20, #7
Second increment of the NDK -> nostr-tools transport swap. The daemon's backend
signing path no longer uses NDK at all.
- nip46/transport.ts: the NIP-46 RPC wire layer over the RelayPool, replacing
NDKNostrRpc. Same crypto + framing so existing clients (lnbits, the spire) are
unaffected byte-for-byte: adaptive nip04/nip44 envelope (nip04 iff content has
`?iv=`, fallback to the other), verify the kind:24133 signature, JSON
`{id,method,params}` in / `{id,result,error}` out, signed as the held key and
`#p`-tagged to the client.
- backend/index.ts: the Backend is rebuilt on that transport instead of
`extends NDKNip46Backend`. The dispatch + response strings match NDK's
strategies exactly (connect->ack, ping->pong, get_public_key, sign_event->
signed event JSON, nip04/44 encrypt/decrypt, reject->error/"Not authorized").
The ACL hook (pubkeyAllowed -> permitCallback) is unchanged; the ACL only
reads `.kind` off the sign_event payload, so a plain parsed event suffices.
- backend/token-store.ts: the prisma-backed connection-token redemption
(validateToken/applyToken) split out of the Backend and injected, so the
protocol layer has no database dependency and is unit-testable. Logic
unchanged (#24/#25 live-lifecycle semantics preserved).
- run.ts: the daemon now drives a RelayPool (heartbeat on) for the backend
transport instead of an NDK instance + attachIndefiniteReconnect; startKey
wires the prisma applyToken.
- relay-pool.ts: publish() now retries across a reconnect window — a publish
that lands mid-flap rejects ("relay connection errored"), so we wait for the
pool to recover and retry (relays dedupe by id; clients match by request id).
Test (tests/nip46-backend.test.ts): a real nostr-tools NIP-46 client drives
connect/ping/get_public_key/sign_event/nip44_encrypt through a mock relay,
asserts each response, FLAPS the relay, and asserts the backend still answers —
then checks the deny path returns "Not authorized". Green. lifecycle + relay
suites unchanged.
The admin interface (NDKRpc) + the getKeys listing still use NDK; that's the
next increment. NDK remains a dependency until then.
Refs: #42, #41, #25, #24, #21, #9
First increment of the NDK -> nostr-tools transport swap (#42), the root fix
for #41 (bunker goes silently deaf after a relay flap).
NDK does not replay subscriptions on reconnect: a NDKRelaySubscription registers
`relay.once("ready", execute)` and never re-arms, so after a flap the socket
reconnects but the kind:24133 REQ is never re-sent. We chased that through
#4/#7/#20/#21 without closing it because it is structural in NDK.
`RelayPool` (src/daemon/lib/relay-pool.ts) owns the connect loop, modelled on
lightning.pub's RelayConnection and signet's relay-pool (both nostr-tools, both
bind resubscribe to reconnect). Every (re)connect re-subscribes the whole
registry, so subscription liveness can't drift from socket liveness. It also
exposes `healthy()` (connected AND registry subscribed on the wire) — the
session-liveness signal the old connectedRelays()-only watchdog couldn't make,
which is what let #20's reconnect mask the deaf state.
We disable nostr-tools' own `enableReconnect`: its auto-resubscribe is
version-fragile right now (regressed in 2.23.0 fb7de7f; the 455124e fix is
unreleased as of 2026-06-26), so the resubscribe is OUR code, not a function of
which nostr-tools version is installed.
Regression test (tests/relay-pool.test.ts + tests/helpers/mock-relay.ts): an
in-process mock relay flaps mid-session (down + back up on the same port) and we
assert a subsequent inbound kind:24133 is still delivered — the exact #41
scenario, and the test that was missing every prior round. Green; existing
lifecycle suite unchanged.
Next increments on this branch: port Backend (NIP-46) + AdminInterface (RPC)
onto the pool, wire run.ts, retire relay-reconnect.ts + the connection-only
watchdog.
Refs: #42, #41, #21, #20, #9
`create_new_key` unconditionally generated a fresh keypair and let
`saveEncrypted` overwrite `config.keys[keyName]` on disk. So calling it
with a name that already exists SILENTLY DESTROYED the in-use signing
key: the old encrypted nsec was overwritten, surviving only in the
running process's in-memory Backend until the next restart, then gone.
This breaks the contract callers already rely on. spirekeeper's
`pair_spire` re-pairs a machine through the same `spire-<id>` keyName and
documents the assumption verbatim — "create_new_key is idempotent —
returns the existing key if the name is taken" (pairing.py). It wasn't:
a re-pair rotated the machine's spire identity and orphaned everything
bound to the old key (tokens, beacons, wallet routing), unrecoverably.
Guard at the top of the command: if a key with this name already exists,
recover and return it instead of generating + overwriting.
- no `_nsec`: decrypt the existing entry with the supplied passphrase
and return its npub (the idempotent re-pair path).
- explicit `_nsec`, or an unrecognized/!iv/!data entry, or a passphrase
that doesn't decrypt the existing key: throw rather than overwrite —
destroying a key must never be a silent side effect of "create".
Functionally verified on the dev bunker: a repeat `/pair` for an
already-paired machine now returns the existing spire pubkey with the
on-disk key entry unchanged, where before it minted a new identity and
clobbered the old blob.
A request that finds no live token grant exited `checkIfPubkeyAllowed`
at `undefined` regardless of *why* — whether the binding never existed
or had simply lapsed (expired / token-revoked). `undefined` routes the
caller into the admin-prompt path, which for an unattended client (an
ATM spire) means the request hangs until a BunkerTimeoutError.
The Sintra smoke proved the divergence directly: a KeyUser-level revoke
exits at step 2 with `false` and the spire sees a clean BunkerRejected
("Pairing Required"), but a TTL expiry fell through to `undefined` and
the spire saw a BunkerTimeout ("Signer Unreachable") — same operator
intent ("this pairing is over"), two different, one-broken outcomes.
Classify the no-live-grant case before returning: if a token bound to
this KeyUser *would* have granted the request (its policy carries a
matching rule; for `connect`, any bound token) but is now expired or
token-revoked, return `false` so the client re-pairs immediately. Only
a genuinely never-granted (method/kind) request stays `undefined` so an
admin can still approve new permission out-of-band.
Usage-cap exhaustion is left at `undefined` deliberately: a windowed
cap is a temporary rate-limit that refills as the window rolls, not a
permanent lapse, so it must not be reclassed as the re-pair signal. A
dedicated rate-limit reply is a separate follow-up.
Tests: the #24 expired-token and token-revoke guards now assert `false`;
added connect-lapse, and two distinction cases proving a never-granted
method (live token, or a method the lapsed token never covered) stays
`undefined`.
9 cases: under/at limit, signings outside the window excluded, uncapped,
lifetime (null window) all-time count, kind-specific counting, both
stacked-cap directions (hourly binds vs daily binds), and the
record->count->deny loop via recordSigning. 22 integration + 7 unit green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the lifecycle family from #25 — usage caps were the third
sibling (after expiry #24 and revoke), written-but-never-enforced.
Model:
- PolicyRule gains windowSeconds; drops the never-enforced mutable
currentUsageCount. A cap = (maxUsageCount, windowSeconds): at most N
signings of this (method,kind) per rolling window. windowSeconds NULL
= lifetime; maxUsageCount NULL = uncapped.
- New SigningLog: durable append-only record of allowed signings — the
source of truth caps count against (derive-don't-count; no counter to
drift).
Enforcement (checkIfPubkeyAllowed step 4): among the live token's
matching rules, every capped rule must have remaining budget in its
window (COUNT(SigningLog) < maxUsageCount), counted live. Stacked caps
all bind — 20/hr AND 200/day enforced together. recordSigning() writes
a SigningLog row from the permit callback when a consequential request
(sign_event / encrypt / decrypt) is allowed.
Retune live: new update_policy_rule admin RPC patches maxUsageCount/
windowSeconds/method/kind in place; takes effect next request, no
re-pairing (a payoff of the #27 Option D design). get_policies now
returns each rule's id + window_seconds so callers can target it.
Retention/pruning of SigningLog is a follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the gap flagged in #27 review: the wiring that actually closes
#24 (step-4 Token join filtered by liveWhere) was untested — only the
pure predicate was. Now covered end-to-end against a throwaway SQLite DB
+ the real prisma client.
Harness (no new dependency; pnpm add is blocked by the nix node_modules
hoist pattern):
- tests/register-ts.cjs: ts-node (transpile-only) + a CommonJS resolver
that maps the app's '.js' ESM-style specifiers to their '.ts' sources.
- node:test temp DB via 'prisma db push'; a before() guard refuses to run
unless DATABASE_URL points at tests/.tmp/ (never truncates a real DB).
- npm run test:integration / test:all.
13 cases incl. the #24 regression guard (expired token -> denied),
revoke, connect-off-live-token, override expiry/revoke ignored,
deny-beats-grant, kind mismatch, no-KeyUser.
Also: acl/index.ts NDK import -> 'import type' (NostrEvent/NIP46Method are
type-only) so the ACL module no longer pulls ESM-only NDK at runtime —
required for the CommonJS test import, and a correct cleanup besides.
Requires the prisma engine env (CI/nix ok; devShell pending #30).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captures the deploy hazard found during #27 rollout (cfaun): the
nsecbunkerd<->LNbits pairing is split across both systems, so a full
nsecbunker.db wipe orphans LNbits's signer_config and forces an
identity-changing re-provision. Documents the targeted
'DELETE FROM SigningCondition' procedure, the keys-live-in-json fact,
and the migrate-on-boot no-op (#31).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #27 review finding #3: step 3a queried SigningCondition method='*'
and the docstring attributed it to rejectAllRequestsFromKey — but that
function writes method=null (never '*') and has zero callers, so the
'reject all' branch could never match. Subject-level reject is already
KeyUser.revokedAt (step 2, via the revoke_user admin command).
Drop the dead step-3a branch and the orphaned rejectAllRequestsFromKey
so the code matches reality. Per-(method,kind) denies (step 3, written
by add_signing_condition) are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the lifecycle predicate into lib/acl/lifecycle.ts (re-exported from
the ACL module) so it can be unit-tested without a database. Adds Node
built-in test-runner coverage for the boundary conditions that define
the fix: past expiry -> dead, expiry == now -> dead (exclusive), revoke
beats a future expiry, and liveWhere kept in lockstep with grantIsLive.
Runner is node:test via ts-node (no new dependency; pnpm add is blocked
by the nix-built node_modules hoist pattern). 'npm test' -> 7 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bug (#24): applyToken photocopied a token's policy rules into
SigningCondition rows at redeem; checkIfPubkeyAllowed matched those rows
(step 3) and short-circuited before the live Token join (step 4), so an
expired or revoked token kept signing forever — the copy carried no
lifecycle. Same cause re-shipped by upstream Signet (see docs survey).
Option D fix:
- grantIsLive(grant, now): the single 'valid right now?' predicate
(revokedAt null AND not past expiresAt), used identically at redeem
(Backend.validateToken) and sign (checkIfPubkeyAllowed). Redeem and
sign can no longer disagree.
- Backend.applyToken records ONLY the KeyUser<-Token binding; it no
longer materializes SigningCondition rows. Token policy is evaluated
live every request.
- checkIfPubkeyAllowed step 4 filters tokens through liveWhere(now)
(revoke + expiry) and grants connect off a live bound token; the
manual-override layer (step 3) now honors SigningCondition
expiresAt/revokedAt too (denials beat grants).
Closes the materialization-drift family: a new lifecycle rule is one
more predicate, never a forgotten photocopy. Token-revoke sibling
(spirekeeper#22) falls out of the same seam. Usage caps deferred (no
durable signing log exists yet to count) — follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Additive, non-breaking schema prep for the Option D live-evaluation ACL:
- Request gains keyUserId (FK) + @@index([keyUserId, method]) so token
usage caps can be derived live by COUNTing allowed Requests, replacing
the never-enforced mutable PolicyRule.currentUsageCount (derive-don't-count,
per lnbits/nostr_bunker prior art).
- SigningCondition gains createdAt/expiresAt/revokedAt so the manual-override
layer carries its own lifecycle and runs through the same grantIsLive(now)
predicate as token grants (D1: two typed sources, one shared rule).
No behavior change yet; the ACL hot path and applyToken de-materialization
follow in subsequent commits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surveys Signet, Amber, FROSTR, promenade, NDK/rust-nostr/nak against
actual source; records the decision to keep our fork and treat Signet
as a parts donor (NIP-46 wire boundary keeps the signer substitutable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Backend.start()` calls `this.ndk.subscribe(filter, opts)` to listen for
NIP-46 events targeted at each unlocked key's pubkey (kind:24133 with
`#p=[localUser.pubkey]`). Pre-fix this subscription opts didn't pin a
relay set, so NDK 3.x's outbox routing kicked in: it looked up the
`localUser.pubkey`'s NIP-65 relay list (kind:10002) to decide where to
send the REQ. Newly-provisioned bunker keys have no kind:10002 published
yet, so NDK's subscription manager queued the REQ waiting for a relay
list that would never arrive — the subscription never landed on the
wire.
The user-visible symptom: every NIP-46 RPC from lnbits to a freshly-
provisioned key (`connect`, `get_public_key`, `sign_event`, the
`nip04_*` / `nip44_*` family) was published into the relay, the relay
tried to route, found no subscribed peer matching `["p", new_key_pubkey]`,
and emitted "Filter didn't match". The lnbits-side RPC then timed out
at 15s, breaking eager merchant provisioning (aiolabs/lnbits#46) and
satmachineadmin's per-cassette `nip44_decrypt` polling.
Reproduced + diagnosed by patching the lnbits `nostrrelay` extension's
`_handle_request` to log incoming REQ filters: only the admin
subscription (`{kinds:[24133,24134], #p:[bunker_admin_pubkey]}` from
`AdminInterface.connect()`) appeared on the wire. The per-key Backend
filters from `Backend.start()` did not.
Fix: pass `relayUrls: this.ndk.explicitRelayUrls` in the subscription
opts. `relayUrls` was added in NDK 2.13.0 as the supported way to bypass
outbox routing per subscription; the relay set built from these URLs
matches what the rest of the daemon uses (admin RPC channel + every
per-key Backend channel), so events flow through the same connection
the admin interface already established.
Verified on the regtest dev stack with bunker enabled, fresh signup
provisioning a new key + immediately publishing a kind:30017 stall via
NIP-46 sign_event:
POST /auth/register → HTTP 200 in 1.1s
stalls.event_id = 8a2eb20b929… (populated by bunker signature)
relay sees: nostr event: [30017, <new-key-pubkey>, '{...store...}']
Pre-fix this same flow timed out at `NsecBunkerTimeoutError: no NIP-46
response for 'sign_event' within 15.0s`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NDK 3.x's per-relay connectivity machine gives up after ~3 fast-fail
(ECONNREFUSED) cycles. Three sub-second failures look identical, so
`isFlapping()` (std-dev < 1s) returns true and the relay transitions
to FLAPPING; NDKPool's `handleFlapping` then reschedules with doubling
backoff (5s → 10s → 20s → 40s → 80s …). For nsecbunkerd, "disconnected
for 80+s after every lnbits restart" is the failure mode users hit on
the regtest dev stack: bunker container boots before lnbits's
nostrrelay extension is accepting WebSockets → ECONNREFUSED storm →
NDK flagged FLAPPING → bunker stays silently deaf until manual restart.
Symptom is particularly hostile because:
- `relay:connect` fires optimistically; the immediate ECONNREFUSED
follow-up doesn't propagate to user-facing logs.
- `NSEC_BUNKER_DISABLE_WATCHDOG=1` (the dev-stack default) skips the
exit-and-restart safety net.
- Manual `docker compose restart nsecbunker` is the only recovery.
Fix: attach a small supervisor (`attachIndefiniteReconnect`) to both
NDK instances (daemon's backend NDK in run.ts, AdminInterface's admin
NDK in admin/index.ts). On `relay:disconnect` or `flapping`, schedule
a manual `relay.connect()` with a SHORT capped delay (1s → 2s → 4s →
8s → 10s, capped at 10s instead of NDK's unbounded doubling). Successful
connect resets the attempt counter so a future disconnect storm starts
fresh.
Coexists cleanly with the relay-connection watchdog (admin/index.ts:500):
- Brief disconnects (e.g. lnbits restart): supervisor recovers within
seconds, watchdog never fires.
- Persistent disconnects (relay truly down): supervisor keeps trying
every ≤10s; if it can't recover within 60s, watchdog still exits and
the process supervisor restarts the bunker. So the watchdog becomes
a long-tail safety net; this supervisor handles the common case.
Operators with `NSEC_BUNKER_DISABLE_WATCHDOG=1` set as a workaround for
this bug can re-enable the watchdog once this lands.
Trade-off: we may hammer a permanently-down relay every 10s. Acceptable
because the bunker's primary relay is typically on the same host or LAN
(loopback or docker-internal); TCP RSTs are cheap. Public-relay setups
can layer external supervision on top.
Verified on regtest dev stack (cold-boot race): bunker logs
🔁 admin: scheduling reconnect to ws://lnbits:5001/nostrrelay/test/ in 1000ms (attempt 1, overriding NDK give-up)
🔁 backend: scheduling reconnect to ws://lnbits:5001/nostrrelay/test/ in 1000ms (attempt 1, overriding NDK give-up)
on each disconnect, where pre-fix the bunker stayed silently deaf.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required to keep the nix package buildable: nixpkgs unstable no longer
ships prisma-engines 5.x — the unsuffixed `prisma-engines` attr now
aliases 7.x (no libquery_engine.node), and the only versioned attrs are
`prisma-engines_6` (6.19.3) and `prisma-engines_7`. Bump both
`@prisma/client` and `prisma` to ^6.19.0 so the client matches the only
engine we can pin to.
Also:
- package.nix takes `prisma-engines_6` directly. flake.nix passes
`pkgs.prisma-engines_6 or pkgs.prisma-engines` so the package still
builds on nixos-25.05 (where prisma-engines is 6.7.0 unsuffixed).
- Drop PRISMA_INTROSPECTION_ENGINE_BINARY — prisma 6 collapsed the
introspection engine into schema-engine, the binary no longer ships.
Schema is unchanged so existing fresh installs migrate identically.
Existing dev instances with a prisma_5-tracked _prisma_migrations table
will need a one-time `prisma migrate resolve` step on first boot under
the new client; deploy targets are all fresh installs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NDK 2.8.1 → 3.0.3 bump (041f431) regenerated pnpm-lock.yaml at
lockfile v9, which pnpm_8 refuses to read. Switch the derivation to
pnpm_9 and regen the pnpmDeps hash to match the v9 lockfile.
The package.json/pnpm-lock realignment that `patchNdk` used to fix is
no longer needed — the same bump also pinned NDK as `"3.0.3"` in
package.json, so manifest + lockfile already agree. Drop the
substitute (kept as a no-op shim for the next time a bump diverges
them) instead of carrying a substituteStream that errors out under
--replace-fail because the source string no longer exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first cut of `maybeAutounlock` enumerated `prisma.key` based on
the design issue's pseudocode. Empirically that's the wrong source:
the Prisma `Key` table is only populated by the NIP-05
`create_account` path, which stores keys *plain-at-rest* in
`nsecbunker.json` (no encryption involved). The `create_new_key`
flow that lnbits's `RemoteBunkerSigner` uses provisions encrypted
`{iv, data}` blobs directly into the JSON `keys` map without
touching the Prisma table at all.
Result of the v1 enumeration on regtest:
🔓 autounlock: enabled (source=NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE),
unlocked 0/0 keys in 0ms
…despite 67 encrypted blobs sitting in nsecbunker.json. The Prisma
table was empty because none of the regtest keys came from
`create_account`. Greg's key would have been a no-op even with the
autounlock env set; the manual `unlock_key` admin RPC would still
have been required.
Fix: enumerate `this.config.allKeys` (the in-memory snapshot of
`nsecbunker.json`'s `keys` map, populated at daemon-fork time per
`src/commands/start.ts:144`) filtered to entries with the `iv`+`data`
shape. That's the canonical "what's encrypted at rest" set —
exactly the rows for which manual `unlock_key` was previously
required per restart.
Plain-key entries (`{key: ...}` from `create_account`) are skipped
here for log clarity — they were already loaded by `startKeys`'
second pass and live in `activeKeys`; `unlockKey`'s post-#16
idempotency guard would no-op them anyway, but emitting "unlocked"
log lines for keys that didn't need unlocking is noise.
Updates `docs/AUTOUNLOCK.md` accordingly so the description matches
the implementation.
Refs aiolabs/nsecbunkerd#16.
Adds opt-in autounlock to the daemon's boot sequence. Closes the
"O(N) manual unlock_key RPC per bunker restart" paper-cut without
breaking the secure-by-default posture: deployments that want every
restart to gate crypto capability on a human action keep that
property by leaving both env vars unset.
Configuration — two mutually exclusive env vars:
NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE literal passphrase
NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE path (newline-trimmed)
Both set → fail loud at boot. Neither set → no-op (default,
behavior unchanged from pre-#16). Var names follow the bunker's
existing NSEC_BUNKER_* convention (see NSEC_BUNKER_DEBUG_TRANSPORT,
NSEC_BUNKER_DISABLE_WATCHDOG); the design issue spec'd NSECBUNKER_*
but aligning with the existing prefix matters more for operator
muscle-memory than matching the issue text verbatim.
Implementation:
- `Daemon.maybeAutounlock()` wedged at the tail of `startKeys()`.
Inherits the relay-subscription lifecycle (EOSE-awaited per #9)
that the existing per-key startKey calls established, so there's
no "client sees key locked" race window.
- Enumeration via `prisma.key.findMany({ where: { deletedAt: null } })`
— Key table is the canonical source of truth for what keys exist
on the bunker; respects soft-delete.
- Per-key call to the existing `unlockKey(keyName, passphrase)`,
which is idempotent post-#16 — encrypted-at-rest keys get unlocked
on first call; rows already loaded via the unencrypted-config
passes above are no-ops.
- Sequential loop with continue-on-error. One bad row (corrupted
blob, key encrypted under a historical passphrase, etc.) doesn't
block the rest of the fleet. Per-key INFO/WARN/ERROR + one
summary line.
- File-source error (missing path, permission denied) is fatal at
boot — same severity as a misconfig.
Observability output:
🔓 autounlock: unlocked <keyName> (success)
⚠️ autounlock: unlockKey returned false for <keyName> (...) (soft fail)
❌ autounlock: <keyName> failed: <message> (throw)
🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms> (summary)
Single-passphrase invariant: every `create_new_key(name, passphrase)`
in our usage today uses the same passphrase
(LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE on the lnbits side), so one
autounlock passphrase covers every encrypted key. Per-key passphrase
support is a separate feature (out of scope — see #16 "out of scope"
section + docs/AUTOUNLOCK.md "What's not in scope").
`docs/AUTOUNLOCK.md` ships alongside: usage, the security trade
spelled out by deployment shape, observability hooks, what's
deliberately not in scope. Required-reading link before any operator
flips the env var on for a production-shaped deployment.
Refs aiolabs/nsecbunkerd#16. Builds on idempotent unlockKey from the
previous commit on this branch.
`unlockKey(keyName, passphrase)` previously had no short-circuit on
re-entry — calling it against an already-unlocked key would happily
run through the full path:
1. decryptNsec (cheap, same result)
2. overwrite this.activeKeys[keyName] with the same nsec
3. call startKey(keyName, nsec) → spawn a SECOND Backend instance
Step 3 is the actual hazard. Each Backend opens its own NIP-46 kind-
24133 subscription with the relay, scoped to the key's pubkey. Two
Backends → duplicate subscription → wire events delivered twice and
each handler races to publish its response. Response amplification +
ordering hazards downstream, plus a slow leak of NDK subscription
state every time unlock fires.
This bug was latent under the manual-unlock posture today (admins
rarely re-issue unlock_key for the same name in one session) but
becomes load-bearing for #16's autounlock loop, which is designed
to run alongside the existing startKeys() loops and may legitimately
encounter a key that was already loaded via the unencrypted-config
path. Belt-and-suspenders lnbits-side scripts + future periodic
"re-unlock sweeps for paranoia" can also fire this.
Fix: short-circuit on `this.activeKeys[keyName]` already set. Return
true so callers can rely on "after this call returns, the key is
unlocked and ready" regardless of whether work was done. Doesn't
break the manual flow (still unlocks first-time), doesn't change
the failure path (corrupt blob / wrong passphrase still throws),
just closes the re-entry foot-gun.
Refs aiolabs/nsecbunkerd#16 (autounlock — this is the idempotency
sub-task lnbits flagged in the design surface).
Caught during regtest dogfood after the previous three commits
landed. With `nostr-tools: ^2.17.2` pnpm resolved to 2.23.5, which
in turn pulls `@noble/curves@2.0.1` — ESM-only. The regtest
Dockerfile runs on Node 20.11.1, where CJS `require()` of pure-ESM
modules is hard-blocked:
Error [ERR_REQUIRE_ESM]: require() of ES Module
/app/node_modules/.pnpm/@noble+curves@2.0.1/.../secp256k1.js
from /app/node_modules/.pnpm/nostr-tools@2.23.5/.../index.js
not supported.
nostr-tools 2.21.0 was the cutover — that release flipped
`@noble/curves` from `1.2.0` to `2.0.1`. 2.20.0 is the last
nostr-tools 2.x release that's still CJS-friendly via @noble/curves
1.2.0. Capping our pin at `~2.20.0` keeps us within the
"nostr-tools >= 2.17.2" range NDK 3.0.3 asks for in its
peerDependency while sidestepping the ESM/CJS hazard.
This isn't a regression we introduce — it's a CJS-output footgun
unique to the regtest container's Node 20 + tsup-default-CJS
combination. Long-term fix paths (out of scope here):
* Bump the container's Node base image to >= 22 (where
`--experimental-require-module` is on by default for `.js`
files inside `package.json type: "commonjs"`)
* Switch tsup output to ESM (`tsup --format esm`) — wider
surface change across the daemon, the client CLI, and the
Dockerfile entrypoint
* Accept the cap forever (small downside: 2.21+ patch fixes
won't reach us until we fix one of the above)
The cap is intentionally tight (`~2.20.0` allows 2.20.x patches,
nothing newer) so a future `pnpm update` doesn't silently jump us
back over the 2.21 edge. Revisit when one of the long-term paths
above lands.
Refs aiolabs/nsecbunkerd#14, regtest dogfood 2026-05-31.
NDK 3.x's `NDKNip46Backend` passes the wire method name verbatim
to `pubkeyAllowed` — `nip04_encrypt`, `nip04_decrypt`,
`nip44_encrypt`, `nip44_decrypt`, etc. NDK 2.8.1 normalized these to
`encrypt`/`decrypt` before calling the permit callback; that
normalization was the root of why our encrypt/decrypt path had
never worked end-to-end against lnbits's bunker-backed signer
(lnbits stores `PolicyRule.method` using wire names, our auth
lookup looked for the normalized name → no match → request fell
through to the never-resolved admin prompt and timed out at 15s).
Source `IMethod` directly from NDK's exported `NIP46Method` union so
it can't drift across future bumps. If NDK adds a new method
(e.g. `nip60_*`) we pick it up for free. Drop the `method as IMethod`
cast at the `signingAuthorizationCallback` call site — both sides
now share the same vocabulary by construction.
This is the substantive win that aiolabs/nsecbunkerd#14 is filed for.
With this commit:
- `sign_event` policy rules with kinds continue to match exactly as
before (kind stringification path unchanged).
- `nip04_encrypt` / `nip04_decrypt` / `nip44_encrypt` / `nip44_decrypt`
policy rules — kind-less — now match the live-policy join (step 4
of `checkIfPubkeyAllowed`) by their method-name alone. lnbits's
bunker-mediated `signer.nip44_decrypt` and `signer.nip44_encrypt`
calls (per `aiolabs/lnbits` PR #38 phase 2.4) start succeeding
end-to-end against any operator account whose Policy carries those
rules — which `_ensure_policy`'s self-heal already ensures for
every newly-bound operator (per coord log 2026-05-30T22:00Z).
- `switch_relays` (new in NDK 3) flows through the auth check the
same way as any other method.
`requestToSigningConditionQuery` needs no further change — the
existing `sign_event` switch case covers the only method that
discriminates on kind; all other methods use the default
`{ method }` query against the override layer, which is correct
for the kind-less wire names too.
Refs aiolabs/nsecbunkerd#14, aiolabs/nsecbunkerd#11 (whose live-policy
join this finally puts to use).
Mechanical adjustments to the source after the dep bump in the
previous commit. No semantic changes — every site adapts to API
drift between the pinned versions.
Surface changes addressed:
* `NDKKind` strict numeric enum (was wider in 2.8.1). 18 sites
passed the literal `24134` (NIP-46 admin-RPC response kind) to
`rpc.sendResponse` / `rpc.sendRequest`; NDK 3's `NDKKind` enum
omits 24134. Introduced `src/daemon/admin/kinds.ts` exporting
`NIP46_ADMIN_RESPONSE_KIND = 24134 as NDKKind` so the cast lives
once, and routed all 18 sites through the named constant.
* `NDKPrivateKeySigner` constructor now accepts nsec1 or hex
directly (the `@ai-guardrail` in NDK 3 source explicitly tells
callers not to `nip19.decode` ahead of construction). Simplified
`Daemon.startKey` and `createNewKey` accordingly — the bech32
decode workaround for #8 was tied to NDK 2.8.1's old behavior
and is no longer needed.
* `NDKPrivateKeySigner.privateKey` is `string` (hex) on the public
surface, not `Uint8Array`. `nostr-tools` v2's `nip19.nsecEncode`
wants `Uint8Array`. Replaced `nip19.nsecEncode(key.privateKey!)`
with `key.nsec` (NDK 3 exposes the getter directly), avoiding
both the type mismatch and the unnecessary round-trip. For the
one remaining hex-string-from-config call site, used
`nostrUtils.hexToBytes` to convert before encoding.
* `NDKPool` event rename: `'relay:notice'` → `'notice'`, with
flipped arg order `(notice, relay)` → `(relay, notice)`.
* `NDKUser.fromNip05` now requires the `ndk` instance as a 2nd
positional arg (was implicit-global before).
* `Nip46PermitCallbackParams.params` narrowed to `string |
NostrEvent`; type guards added at the two access sites
(`authorize.ts` and `acl/index.ts:requestToSigningConditionQuery`).
* `req.params` is now `(string | undefined)[]` instead of `any[]`;
`create_account.ts` `authorizationWithPayload` branch now
explicitly throws on missing username/domain before passing to
`createAccountReal` (validates what was implicit before).
* Removed `src/daemon/backend/publish-event.ts` (defined a strategy
that's never registered — wiring is commented out in
`backend/index.ts:22`; in NDK 3 the file refs the removed
`NDKNip46Backend.signEvent`). Dead since at least NDK 2.x; the
bump just made the breakage visible.
Pre-existing `tsc` errors at `src/db.ts` and `src/daemon/authorize.ts`
on `'PrismaClient'` / `'Request'` exports are unrelated to this PR —
the regtest container's nix derivation can't reach the prisma engine
binary store on this host (`nsecbunkerd#14` parked separately).
`pnpm run build` (tsup) is green; the Docker container runs
`prisma generate` against its own engine at image-build time and
resolves these at runtime.
#11's wire-name policy convention adoption is the next commit —
this one is purely keep-it-compiling work.
Refs aiolabs/nsecbunkerd#14.