feat(#14): bump @nostr-dev-kit/ndk 2.8.1 → 3.0.3 + nostr-tools v1 → v2.20 + acl wire-name vocabulary #15

Merged
padreug merged 4 commits from issue-14-ndk-bump into dev 2026-05-31 11:49:30 +00:00
Owner

Closes #14.

Why

aiolabs/lnbits PR #38 (phase 2.4) shipped bunker-mediated signer.nip44_encrypt / signer.nip44_decrypt and the satmachineadmin PR #30 consumer migration started using them. The first live nip44_decrypt round-trip (2026-05-31T07:20Z) surfaced a NsecBunkerTimeoutError. lnbits ascribed it to a stopped bunker container; that was part of the story but masked a deeper structural problem:

Finding 1 — NDK 2.8.1's NDKNip46Backend.handlers dict has no nip44_encrypt / nip44_decrypt strategies. Wire RPCs would fall through to sendResponse(id, remotePubkey, "error", undefined, "Not authorized") (terminal NsecBunkerRpcError, not a retry-eligible timeout).

Finding 2 — NDK 2.8.1 normalizes wire nip04_encrypt → permit method encrypt before calling pubkeyAllowed. lnbits's _ensure_policy stores rules using wire names (nip04_encrypt, nip04_decrypt, nip44_encrypt, nip44_decrypt, kind-less). With NDK 2.8.1's normalization, our auth check looked up PolicyRule.method='encrypt' against rules stored as nip04_encrypt — never matched, fell through to requestAuthorization → 15s timeout. The encrypt/decrypt path through nsecbunkerd has never actually worked end-to-end; phase 2.4 just made it impossible to ignore.

Full diagnosis in coord log 2026-05-31T08:35Z.

What this PR does

Bumps @nostr-dev-kit/ndk from 2.8.1 (Apr 2024) to 3.0.3 (Feb 2026, current latest dist-tag, 3 months stable). The bump comes with nostr-tools v1 → v2.20.0 (NDK 3.x needs the nip49 subpath + finalizeEvent / generateSecretKey exports unique to nostr-tools v2).

Commits

  1. chore(deps): bump @nostr-dev-kit/ndk 2.8.1 → 3.0.3 + nostr-tools v1 → v2package.json + regenerated pnpm-lock.yaml. Reviewable on its own.
  2. refactor: adapt source to NDK 3.0.3 / nostr-tools v2 surface — mechanical API-drift adjustments:
    • NDKKind strict numeric enum: 18 sites passing literal 24134 (NIP-46 admin-RPC channel) now go through NIP46_ADMIN_RESPONSE_KIND defined in a new src/daemon/admin/kinds.ts with the cast living once.
    • NDKPrivateKeySigner(nsec) now accepts hex or nsec directly — removed the #8 bech32-decode workaround.
    • key.nsec getter replaces nip19.nsecEncode(key.privateKey) (NDK 3 surfaces privateKey as string/hex, not Uint8Array, so the v2 nsecEncode args mismatched).
    • NDKPool 'relay:notice''notice' event rename + flipped arg order.
    • NDKUser.fromNip05(nip05) now requires an ndk positional arg.
    • Type-narrowing guards at Nip46PermitCallbackParams.params access sites.
    • req.params is (string | undefined)[]; create_account.ts explicitly throws on missing username/domain after authorization payload parse.
    • Removed dead src/daemon/backend/publish-event.ts (referenced removed NDKNip46Backend.signEvent; wiring was commented out at backend/index.ts:22).
  3. refactor(acl): align IMethod with NIP-46 wire-name vocabulary — the substantive issue-14 win. IMethod is now NIP46Method (sourced from NDK 3 directly so it can't drift). With the wire-name vocabulary on both sides, the post-#11 live-policy join (step 4 of checkIfPubkeyAllowed) naturally matches lnbits's existing nip04_encrypt / nip44_decrypt policy rules. The encrypt/decrypt normalization layer is retired — there's no longer a "name-translation" gap between the wire and the stored policy.
  4. fix(deps): cap nostr-tools at ~2.20.0 — caught during regtest dogfood. nostr-tools 2.21+ flipped @noble/curves from 1.2.0 (CJS + ESM) to 2.0.1 (pure ESM). The regtest container's Node 20 + tsup-default-CJS combo hard-errors on require() of pure-ESM modules. Capping at ~2.20.0 keeps us inside NDK 3.0.3's >=2.17.2 peer range without crossing the ESM hazard. Long-term: bump container Node to >= 22, or switch tsup to ESM output.

What this unlocks beyond the immediate fix

  • switch_relays NIP-46 support for client-side relay migration
  • Configurable NDKNip46Signer timeout (pairs with lnbits PR #38's matching client-side config)
  • NIP-44 default outgoing encryption with NIP-04 compat fallback
  • Async error handling fix in backend dispatch (failed strategies now report errors instead of silent drop, NDK commit deb7f93d)
  • "Not enough relays received this event" race-condition fix on publish (relevant to open #7 — may close that one too)
  • Signature verification moved in-house (off the legacy nostr-tools v1 path)
  • 2 years of security/perf updates in transitive @noble/* crypto primitives

Test plan

  • pnpm install clean (peer-dep warning resolved post-nostr-tools ~2.20.0 cap)
  • pnpm run build (tsup) green
  • Docker container build (docker compose -f docker-compose.dev.yml build --no-cache nsecbunker) succeeds
  • Container boots cleanly: 🔑 Starting keys [] / ✅ Connected to ws://lnbits:5001/nostrrelay/test/ / ✅ nsecBunker Admin Interface ready / ✅ nsecBunker ready to serve requests.
  • NDK 3.0.3's nip44_encrypt / nip44_decrypt strategies present in the deployed dist/index.js (grep-verified in container)
  • Live nip44_decrypt round-trip from satmachineadmin's consumer — requires:
    1. Greg's bunker_name = ac35c9fc842f40f0a0e9809347cd24d1 unlocked via admin RPC after the container restart (lock state didn't survive the rebuild)
    2. satmachineadmin's pending v1.1 cassette-state event from bitspire's Sintra to re-deliver via the relay (its state_event_id is still pinned to the v1 bootstrap f46b6dcf62f6...)
    3. cassette_configs.state_event_id advances to the new v1.1 event ID, confirming bunker-mediated decrypt succeeded
  • Webapp regression: smoke a regular operator sign_event flow through the bunker (any existing extension)
  • aiolabs/nsecbunkerd#11 regression: re-run the 8 live-policy auth cases — should pass identically (the rewrite is conceptually unchanged, just with widened method-name vocabulary)

Pre-existing tsc errors (NOT a regression)

src/db.ts and src/daemon/authorize.ts show 'PrismaClient' / 'Request' missing from @prisma/client. Pre-existing — prisma generate fails on this nix host (engines binary skew, parked separately as session task #14). The container's Docker build runs prisma generate against its own engine and resolves these at runtime. Build is green; tsc reports false-positive at host level.

Coord-log thread

  • 2026-05-31T07:25Z — satmachineadmin migration ships, bunker timeout surfaces
  • 2026-05-31T08:10Z — lnbits diagnoses "stopped container"
  • 2026-05-31T08:35Z — nsecbunkerd flags Finding 1 + Finding 2 are deeper than the container restart
  • 2026-05-31T09:55Z — Padreug picks NDK bump (A) over local strategy patch (B); this PR is the result

🤖 Generated with Claude Code

Closes #14. ## Why `aiolabs/lnbits` PR #38 (phase 2.4) shipped bunker-mediated `signer.nip44_encrypt` / `signer.nip44_decrypt` and the satmachineadmin PR #30 consumer migration started using them. The first live `nip44_decrypt` round-trip (2026-05-31T07:20Z) surfaced a `NsecBunkerTimeoutError`. lnbits ascribed it to a stopped bunker container; that was *part* of the story but masked a deeper structural problem: **Finding 1** — NDK 2.8.1's `NDKNip46Backend.handlers` dict has no `nip44_encrypt` / `nip44_decrypt` strategies. Wire RPCs would fall through to `sendResponse(id, remotePubkey, "error", undefined, "Not authorized")` (terminal `NsecBunkerRpcError`, not a retry-eligible timeout). **Finding 2** — NDK 2.8.1 normalizes wire `nip04_encrypt` → permit method `encrypt` before calling `pubkeyAllowed`. lnbits's `_ensure_policy` stores rules using wire names (`nip04_encrypt`, `nip04_decrypt`, `nip44_encrypt`, `nip44_decrypt`, kind-less). With NDK 2.8.1's normalization, our auth check looked up `PolicyRule.method='encrypt'` against rules stored as `nip04_encrypt` — never matched, fell through to `requestAuthorization` → 15s timeout. The encrypt/decrypt path through nsecbunkerd has never actually worked end-to-end; phase 2.4 just made it impossible to ignore. Full diagnosis in coord log [2026-05-31T08:35Z](https://git.atitlan.io/aiolabs/nsecbunkerd/issues/14). ## What this PR does Bumps `@nostr-dev-kit/ndk` from `2.8.1` (Apr 2024) to `3.0.3` (Feb 2026, current `latest` dist-tag, 3 months stable). The bump comes with `nostr-tools` v1 → v2.20.0 (NDK 3.x needs the `nip49` subpath + `finalizeEvent` / `generateSecretKey` exports unique to nostr-tools v2). ### Commits 1. **`chore(deps): bump @nostr-dev-kit/ndk 2.8.1 → 3.0.3 + nostr-tools v1 → v2`** — `package.json` + regenerated `pnpm-lock.yaml`. Reviewable on its own. 2. **`refactor: adapt source to NDK 3.0.3 / nostr-tools v2 surface`** — mechanical API-drift adjustments: - `NDKKind` strict numeric enum: 18 sites passing literal `24134` (NIP-46 admin-RPC channel) now go through `NIP46_ADMIN_RESPONSE_KIND` defined in a new `src/daemon/admin/kinds.ts` with the cast living once. - `NDKPrivateKeySigner(nsec)` now accepts hex or nsec directly — removed the `#8` bech32-decode workaround. - `key.nsec` getter replaces `nip19.nsecEncode(key.privateKey)` (NDK 3 surfaces `privateKey` as `string`/hex, not `Uint8Array`, so the v2 nsecEncode args mismatched). - NDKPool `'relay:notice'` → `'notice'` event rename + flipped arg order. - `NDKUser.fromNip05(nip05)` now requires an `ndk` positional arg. - Type-narrowing guards at `Nip46PermitCallbackParams.params` access sites. - `req.params` is `(string | undefined)[]`; `create_account.ts` explicitly throws on missing username/domain after authorization payload parse. - Removed dead `src/daemon/backend/publish-event.ts` (referenced removed `NDKNip46Backend.signEvent`; wiring was commented out at `backend/index.ts:22`). 3. **`refactor(acl): align IMethod with NIP-46 wire-name vocabulary`** — the substantive issue-14 win. `IMethod` is now `NIP46Method` (sourced from NDK 3 directly so it can't drift). With the wire-name vocabulary on both sides, the post-#11 live-policy join (step 4 of `checkIfPubkeyAllowed`) naturally matches lnbits's existing `nip04_encrypt` / `nip44_decrypt` policy rules. The `encrypt`/`decrypt` normalization layer is retired — there's no longer a "name-translation" gap between the wire and the stored policy. 4. **`fix(deps): cap nostr-tools at ~2.20.0`** — caught during regtest dogfood. `nostr-tools` 2.21+ flipped `@noble/curves` from `1.2.0` (CJS + ESM) to `2.0.1` (pure ESM). The regtest container's Node 20 + tsup-default-CJS combo hard-errors on `require()` of pure-ESM modules. Capping at `~2.20.0` keeps us inside NDK 3.0.3's `>=2.17.2` peer range without crossing the ESM hazard. Long-term: bump container Node to >= 22, or switch tsup to ESM output. ## What this unlocks beyond the immediate fix - `switch_relays` NIP-46 support for client-side relay migration - Configurable NDKNip46Signer timeout (pairs with lnbits PR #38's matching client-side config) - NIP-44 default outgoing encryption with NIP-04 compat fallback - Async error handling fix in backend dispatch (failed strategies now report errors instead of silent drop, NDK commit `deb7f93d`) - "Not enough relays received this event" race-condition fix on publish (relevant to open `#7` — may close that one too) - Signature verification moved in-house (off the legacy nostr-tools v1 path) - 2 years of security/perf updates in transitive `@noble/*` crypto primitives ## Test plan - [x] `pnpm install` clean (peer-dep warning resolved post-`nostr-tools ~2.20.0` cap) - [x] `pnpm run build` (tsup) green - [x] Docker container build (`docker compose -f docker-compose.dev.yml build --no-cache nsecbunker`) succeeds - [x] Container boots cleanly: `🔑 Starting keys [] / ✅ Connected to ws://lnbits:5001/nostrrelay/test/ / ✅ nsecBunker Admin Interface ready / ✅ nsecBunker ready to serve requests.` - [x] NDK 3.0.3's `nip44_encrypt` / `nip44_decrypt` strategies present in the deployed `dist/index.js` (`grep`-verified in container) - [ ] **Live `nip44_decrypt` round-trip from satmachineadmin's consumer** — requires: 1. Greg's `bunker_name = ac35c9fc842f40f0a0e9809347cd24d1` unlocked via admin RPC after the container restart (lock state didn't survive the rebuild) 2. satmachineadmin's pending v1.1 cassette-state event from bitspire's Sintra to re-deliver via the relay (its `state_event_id` is still pinned to the v1 bootstrap `f46b6dcf62f6...`) 3. `cassette_configs.state_event_id` advances to the new v1.1 event ID, confirming bunker-mediated decrypt succeeded - [ ] Webapp regression: smoke a regular operator `sign_event` flow through the bunker (any existing extension) - [ ] `aiolabs/nsecbunkerd#11` regression: re-run the 8 live-policy auth cases — should pass identically (the rewrite is conceptually unchanged, just with widened method-name vocabulary) ## Pre-existing `tsc` errors (NOT a regression) `src/db.ts` and `src/daemon/authorize.ts` show `'PrismaClient'` / `'Request'` missing from `@prisma/client`. Pre-existing — `prisma generate` fails on this nix host (engines binary skew, parked separately as session task #14). The container's Docker build runs `prisma generate` against its own engine and resolves these at runtime. Build is green; tsc reports false-positive at host level. ## Coord-log thread - [2026-05-31T07:25Z](https://git.atitlan.io/aiolabs/nsecbunkerd/issues/14) — satmachineadmin migration ships, bunker timeout surfaces - [2026-05-31T08:10Z](https://git.atitlan.io/aiolabs/nsecbunkerd/issues/14) — lnbits diagnoses "stopped container" - [2026-05-31T08:35Z](https://git.atitlan.io/aiolabs/nsecbunkerd/issues/14) — nsecbunkerd flags Finding 1 + Finding 2 are deeper than the container restart - [2026-05-31T09:55Z](https://git.atitlan.io/aiolabs/nsecbunkerd/issues/14) — Padreug picks NDK bump (A) over local strategy patch (B); this PR is the result 🤖 Generated with [Claude Code](https://claude.com/claude-code)
NDK 2.8.1 (Apr 2024) is 2 years old and predates NIP-46 backend-side
nip44 support. With aiolabs/lnbits#38's phase-2.4 client-side migration
to bunker-mediated nip44_*, the bunker's lack of a `nip44_decrypt`
strategy registration causes wire RPCs to fall through to
`sendResponse(id, remotePubkey, "error", undefined, "Not authorized")`
at NDK 2.8.1's backend/index.ts:179. Even nip04 was silently broken:
2.8.1 normalizes the wire method to `encrypt`/`decrypt` for
`pubkeyAllowed` while lnbits's policy stores wire names. The
encrypt/decrypt path through nsecbunkerd has never actually worked
end-to-end; it just hadn't been exercised until phase 2.4 landed.

3.0.3 (Feb 2026) is the current `latest` dist-tag and ships:

  - `nip44_encrypt` / `nip44_decrypt` backend handlers registered
    by default + wire-name `pubkeyAllowed` semantics (the immediate fix)
  - `switch_relays` NIP-46 support for client-side relay migration
  - Configurable NDKNip46Signer timeout (pairs with lnbits PR #38's
    matching client-side config)
  - NIP-44 default outgoing encryption with NIP-04 compat fallback
  - Async error handling fix in backend dispatch — failed strategies
    report errors instead of silent drop (deb7f93d)
  - "Not enough relays received this event" race-condition fix on
    publish (relevant to open #7 — may close that one too)
  - Signature verification moved in-house (off legacy nostr-tools v1
    path)
  - 2 years of security/perf updates in transitive @noble/* crypto
    primitives

`nostr-tools` bumped from ^1.17.0 to ^2.17.2 alongside because NDK
3.x's `NDKPrivateKeySigner` imports `finalizeEvent` / `generateSecretKey`
+ uses the `./nip49` subpath, none of which exist in nostr-tools v1.17.
With v1.17 installed, `require('@nostr-dev-kit/ndk')` fails with
"Package subpath './nip49' is not defined". Confirmed against the
post-install module graph.

Source migrations for NDK 3 / nostr-tools v2 API surface land in the
follow-up commit; this commit is intentionally just the dep bump so
the diff stays reviewable. Refs aiolabs/nsecbunkerd#14 +
coord-log 2026-05-31T09:55Z.
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.
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).
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
e8f245c917
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.
padreug deleted branch issue-14-ndk-bump 2026-05-31 11:49:30 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/nsecbunkerd!15
No description provided.