Commit graph

8 commits

Author SHA1 Message Date
1c16a2a4b7 fix(transport): harden RelayPool — connect timeout, stop-race, cross-relay dedup
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-06-27 12:27:36 +02:00
a676d4fa98 feat(transport): port the admin RPC off NDK onto the relay pool (#42)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-06-27 01:23:53 +02:00
ea923b472d feat(transport): port the NIP-46 backend off NDK onto the relay pool (#42)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-06-27 00:29:02 +02:00
1409941d11 feat(transport): nostr-tools relay pool that re-subscribes on reconnect (#42)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-06-26 23:42:36 +02:00
14d48ca0f9 fix(acl): hard-reject a lapsed token binding instead of prompting (#36)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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`.
2026-06-21 12:44:36 +02:00
c76bbf2791 test(acl)(#28): integration cases for windowed + stacked usage caps
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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>
2026-06-21 12:29:51 +02:00
0b9ffe8ca6 test(acl)(#29): DB-backed integration tests for checkIfPubkeyAllowed
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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>
2026-06-19 21:09:41 +00:00
e2cf10a66d test(acl)(#25): extract pure grantIsLive/liveWhere + unit tests
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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>
2026-06-19 15:16:37 +02:00