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