Commit graph

177 commits

Author SHA1 Message Date
df60a1c0b6 Revert "fix(nix): migrate pnpm fetchDeps to fetcherVersion 3"
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
This reverts commit e15c9f8a25.
2026-07-20 17:12:08 +02:00
e15c9f8a25 fix(nix): migrate pnpm fetchDeps to fetcherVersion 3
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-07-20 00:05:19 +02:00
5ff4e413be fix(nix): regenerate pnpm-deps hash after nixpkgs fetcher drift
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-07-19 23:51:53 +02:00
1f4afbf1c2 Merge pull request 'docs(backend): pin get_public_key as an intentional, load-bearing ACL exception (#26)' (#53) from fix/26-document-get-public-key-ungated into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #53
2026-07-04 13:59:30 +00:00
d35b98a8c9 docs(backend): pin get_public_key as an intentional, load-bearing ACL exception (#26)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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>
2026-07-04 15:58:08 +02:00
1cf24ff2ef Merge pull request 'fix(admin): clear pending callbacks on ACL timeout/response — stop the leak (review AD-1/CS-2)' (#51) from fix/admin-pending-leak into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #51
2026-07-04 13:32:43 +00:00
edf1ddc7da fix(admin): clear pending callbacks on ACL timeout/response — stop the leak (review AD-1/CS-2)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-07-04 13:32:32 +00:00
dd326e3042 Merge pull request 'fix(transport): harden RelayPool — connect timeout, stop-race, cross-relay dedup (review RP-1/2/4)' (#50) from fix/relay-pool-hardening into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #50
2026-07-04 13:32:23 +00:00
9f2f592f20 Merge pull request 'fix(admin): harden the boot DM — await a connected relay + guard teardown/rejection (#48, review CS-3)' (#49) from fix/boot-dm-await-connect into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #49
2026-06-27 11:19:50 +00:00
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
434817c899 fix(admin): harden the boot DM — guard teardown + unhandled rejection (review CS-3)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-06-27 12:22:24 +02:00
d8790087b4 fix(admin): wait for a connected relay before the boot DM, not a fixed sleep (#48)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-06-27 12:03:27 +02:00
1754027249 Merge pull request 'refactor: port the CLI off NDK and drop @nostr-dev-kit/ndk entirely' (#47) from fix/drop-ndk-cli into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #47
2026-06-27 00:58:25 +00:00
8d56536d2d refactor: port the CLI off NDK and drop @nostr-dev-kit/ndk entirely
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-06-27 02:56:04 +02:00
d802ab0bb5 Merge pull request 'refactor: remove @nostr-dev-kit/ndk from the daemon entirely (#44)' (#45) from fix/44-remove-ndk into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #45
2026-06-27 00:49:17 +00:00
056c52cb5b refactor: remove @nostr-dev-kit/ndk from the daemon entirely (#44)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
2026-06-27 02:41:22 +02:00
1ffa75dcd4 Merge pull request 'fix(transport): swap the relay layer NDK → nostr-tools so subscriptions survive reconnects (#42, fixes #41)' (#43) from fix/42-nostr-tools-transport into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #43
2026-06-27 00:14:46 +00: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
4a0a3e7cd4 Merge pull request 'fix(admin): make create_new_key idempotent — never clobber an existing key (#39)' (#40) from fix-create-new-key-idempotent into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #40
2026-06-21 14:01:47 +00:00
2cf3e55bf4 fix(admin): make create_new_key idempotent — never clobber an existing key
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
`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.
2026-06-21 15:51:13 +02:00
3468972733 Merge pull request 'fix(acl): hard-reject a lapsed token binding instead of prompting (#36)' (#38) from issue-36-expired-binding-hard-reject into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #38
2026-06-21 12:57:46 +00: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
281ca1c39f Merge pull request 'feat(acl): per-rule windowed usage caps enforced live at sign time (#28)' (#34) from issue-28-usage-caps into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #34
2026-06-21 10:34:22 +00: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
6929f42115 feat(acl)(#28): per-rule windowed usage caps enforced live at sign time
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>
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
14e20d50d4 docs: add migration & DB-maintenance runbook (never full-wipe nsecbunker.db)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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>
2026-06-19 22:58:12 +02:00
992c6a8d4a Merge pull request 'fix(acl): enforce token grant lifecycle live at sign time (#24, #25)' (#27) from issue-25-live-grant-lifecycle into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #27
2026-06-19 16:05:18 +00:00
7dcf97a296 refactor(acl)(#27 review): remove dead reject-all sentinel
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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>
2026-06-19 16:02:13 +02: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
85e781dfa9 fix(acl)(#24,#25): enforce token expiry+revoke live at sign time
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>
2026-06-19 15:11:23 +02:00
6397c7988d feat(schema)(#25): Request.keyUserId + SigningCondition lifecycle for live grant eval
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>
2026-06-19 15:00:33 +02:00
a707d203a1 docs(#25): source-verified ACL prior-art survey + keep-our-fork decision
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>
2026-06-19 14:54:18 +02:00
8326a16ea9 docs(#25): add lnbits/nostr_bunker comparison (prior art)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:54:18 +02:00
cb8dd0ceb0 Merge pull request 'fix(backend): pin per-key kind:24133 subscription to explicit relays (#21)' (#23) from fix-21-pin-backend-sub-to-explicit-relays into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #23
2026-06-03 17:07:23 +00:00
59e90d07c0 fix(backend): pin per-key kind:24133 subscription to explicit relays (#21)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
`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>
2026-06-03 19:05:03 +02:00
dad42a7669 Merge pull request 'fix(daemon): keep retrying relay reconnect indefinitely, overriding NDK give-up (#20)' (#22) from fix-20-indefinite-relay-reconnect into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #22
2026-06-03 16:58:48 +00:00
a690596b85 fix(daemon): keep retrying relay reconnect indefinitely, overriding NDK give-up (#20)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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>
2026-06-03 18:55:55 +02:00
131f689c6f deps: bump prisma 5.4.1 → 6.19.3 (nix build fix)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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>
2026-06-03 15:04:40 +02:00
8ee0595ea8 fix(nix): build under pnpm_9 + drop stale NDK substitute
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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>
2026-06-03 14:51:40 +02:00
67b1f46266 Merge pull request 'feat(#16): boot-time autounlock of encrypted keys from a configured passphrase source' (#17) from issue-16-autounlock into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #17
2026-05-31 13:39:18 +00:00
030d3cea0f fix(daemon): autounlock walks config.allKeys, not prisma.key (#16)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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.
2026-05-31 15:34:51 +02:00
7a3cb4f3da feat(daemon): boot-time autounlock of encrypted keys (#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.
2026-05-31 15:31:25 +02:00
b6f8abdb23 fix(daemon): make unlockKey idempotent (#16)
`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).
2026-05-31 15:29:07 +02:00
106fa807a1 Merge pull request 'feat(#14): bump @nostr-dev-kit/ndk 2.8.1 → 3.0.3 + nostr-tools v1 → v2.20 + acl wire-name vocabulary' (#15) from issue-14-ndk-bump into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #15
2026-05-31 11:49:29 +00:00
e8f245c917 fix(deps): cap nostr-tools at ~2.20.0 (regtest Node 20 / curves v2 ESM-only) (#14)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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.
2026-05-31 13:43:37 +02:00
db1a834587 refactor(acl): align IMethod with NIP-46 wire-name vocabulary (#14)
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).
2026-05-31 12:15:57 +02:00
94b5d55376 refactor: adapt source to NDK 3.0.3 / nostr-tools v2 surface (#14)
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.
2026-05-31 12:14:29 +02:00