diff --git a/Dockerfile b/Dockerfile index 9ace24a..1eb99be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,20 @@ -# Patched from upstream kind-0/nsecbunkerd Dockerfile to use pnpm — the -# upstream version uses `npm install` but package.json declares -# `@nostr-dev-kit/ndk` as `workspace:*`, which only pnpm understands. -# A clean clone of upstream fails to build with `EUNSUPPORTEDPROTOCOL` -# under npm. Switching to pnpm matches the lockfile that ships in-repo. -# Also drops `--frozen-lockfile` because the upstream pnpm-lock.yaml is -# out of date vs. package.json (ERR_PNPM_OUTDATED_LOCKFILE) — bug to -# file upstream once we've verified the rest of the stack works. - FROM node:20.11-bullseye AS build WORKDIR /app -RUN npm install -g pnpm@9 - -# Copy lockfile + manifest first so the install layer caches across -# source changes. -COPY package.json pnpm-lock.yaml ./ -RUN pnpm install --no-frozen-lockfile +# Copy package files and install dependencies +COPY package*.json ./ +RUN npm install # Copy application files COPY . . # Generate prisma client and build the application RUN npx prisma generate -RUN pnpm run build +RUN npm run build # Runtime stage -FROM node:20.11-alpine AS runtime +FROM node:20.11-alpine as runtime WORKDIR /app @@ -34,25 +22,13 @@ RUN apk update && \ apk add --no-cache openssl && \ rm -rf /var/cache/apk/* -RUN npm install -g pnpm@9 - # Copy built files from the build stage COPY --from=build /app . -# Install all dependencies (including devDeps). The prisma CLI lives in -# devDependencies but scripts/start.js invokes `prisma migrate deploy` -# at boot, so it must be available at runtime. Dropping --prod adds the -# CLI tooling to the runtime image — a modest size cost for the -# correctness of the migration step. -RUN pnpm install --no-frozen-lockfile +# Install only runtime dependencies +RUN npm install --only=production EXPOSE 3000 -# Run via scripts/start.js so `prisma migrate deploy` applies pending -# migrations before the daemon spawns. The upstream Dockerfile invokes -# ./dist/index.js directly, which silently bypasses the migration step -# and leaves the SQLite db empty on first boot — every command that -# touches Policy/KeyUser/Token/etc. then throws "table does not exist." -# Caught during aiolabs/nsecbunkerd#7 diagnosis 2026-05-27. -ENTRYPOINT [ "node", "./scripts/start.js" ] +ENTRYPOINT [ "node", "./dist/index.js" ] CMD ["start"] diff --git a/flake.lock b/flake.lock deleted file mode 100644 index f428ce8..0000000 --- a/flake.lock +++ /dev/null @@ -1,27 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-25.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 5bb1222..0000000 --- a/flake.nix +++ /dev/null @@ -1,51 +0,0 @@ -{ - description = "nsecbunkerd — Nostr remote signing daemon (NIP-46)"; - - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; - - outputs = { self, nixpkgs }: - let - systems = [ "x86_64-linux" "aarch64-linux" ]; - forAllSystems = nixpkgs.lib.genAttrs systems; - pkgsFor = system: import nixpkgs { inherit system; }; - in - { - packages = forAllSystems (system: - let pkgs = pkgsFor system; in - rec { - default = nsecbunkerd; - nsecbunkerd = pkgs.callPackage ./package.nix { }; - } - ); - - devShells = forAllSystems (system: - let pkgs = pkgsFor system; in - { - default = pkgs.mkShell { - packages = with pkgs; [ - nodejs_20 - pnpm_8 - prisma - prisma-engines - python3 - gcc - pkg-config - openssl - sqlite - ]; - - shellHook = '' - # Point prisma at the nix-provided engines so it doesn't try to - # download them from binaries.prisma.sh on every install. - export PRISMA_QUERY_ENGINE_BINARY=${pkgs.prisma-engines}/bin/query-engine - export PRISMA_QUERY_ENGINE_LIBRARY=${pkgs.prisma-engines}/lib/libquery_engine.node - export PRISMA_SCHEMA_ENGINE_BINARY=${pkgs.prisma-engines}/bin/schema-engine - export PRISMA_FMT_BINARY=${pkgs.prisma-engines}/bin/prisma-fmt - export PRISMA_INTROSPECTION_ENGINE_BINARY=${pkgs.prisma-engines}/bin/introspection-engine - export PRISMA_CLIENT_ENGINE_TYPE=binary - ''; - }; - } - ); - }; -} diff --git a/package.json b/package.json index 297fd2a..4232435 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@fastify/view": "^8.2.0", "@inquirer/password": "^1.1.2", "@inquirer/prompts": "^1.2.3", - "@nostr-dev-kit/ndk": "2.8.1", + "@nostr-dev-kit/ndk": "workspace:*", "@prisma/client": "^5.4.1", "@scure/base": "^1.1.1", "@types/yargs": "^17.0.24", diff --git a/package.nix b/package.nix deleted file mode 100644 index adeb62b..0000000 --- a/package.nix +++ /dev/null @@ -1,143 +0,0 @@ -{ - lib, - stdenv, - pnpm_8, - nodejs_20, - makeWrapper, - prisma-engines, - openssl, - sqlite, - python311, - pkg-config, - node-gyp, -}: - -let - # Fork commit `06272c8` ("pin @nostr-dev-kit/ndk to 2.8.1 instead of - # workspace:*") changed package.json to a pinned `"2.8.1"`, but the - # pnpm-lock.yaml still expresses the spec as `"^2.8.1"` (the way - # `pnpm add` originally generated it). pnpm with --frozen-lockfile - # rejects that mismatch. Patching package.json to use the caret form - # is non-semantic (2.8.1 is still the resolved version) and aligns - # both files. Same fix the Dockerfile-side already handles via - # `--no-frozen-lockfile`; in nix we prefer frozen + a targeted patch. - patchNdk = '' - substituteInPlace package.json \ - --replace-fail '"@nostr-dev-kit/ndk": "2.8.1"' \ - '"@nostr-dev-kit/ndk": "^2.8.1"' - ''; - - prismaEnv = { - PRISMA_SCHEMA_ENGINE_BINARY = lib.getExe' prisma-engines "schema-engine"; - PRISMA_QUERY_ENGINE_BINARY = lib.getExe' prisma-engines "query-engine"; - PRISMA_QUERY_ENGINE_LIBRARY = "${prisma-engines}/lib/libquery_engine.node"; - PRISMA_INTROSPECTION_ENGINE_BINARY = lib.getExe' prisma-engines "introspection-engine"; - PRISMA_FMT_BINARY = lib.getExe' prisma-engines "prisma-fmt"; - PRISMA_CLIENT_ENGINE_TYPE = "binary"; - }; -in -stdenv.mkDerivation (finalAttrs: { - pname = "nsecbunkerd"; - version = "0.10.5"; - - src = ./.; - - pnpmDeps = pnpm_8.fetchDeps { - inherit (finalAttrs) pname version src; - fetcherVersion = 2; - prePnpmInstall = patchNdk; - hash = "sha256-dQ+TX5jf1ZQKGoPCZgWaFwpAC3uP6iL1ZSxS0mFNdP8="; - }; - - postPatch = patchNdk; - - nativeBuildInputs = [ - pnpm_8.configHook - pnpm_8 - nodejs_20 - makeWrapper - node-gyp - python311 - pkg-config - ]; - - buildInputs = [ - openssl - sqlite - ]; - - env = prismaEnv; - - buildPhase = '' - runHook preBuild - - export npm_config_nodedir=${nodejs_20} - pnpm config set nodedir ${nodejs_20} - - # configHook ran with --ignore-scripts; re-run install to trigger - # native-module postinstall (bcrypt). --offline keeps it inside the - # store seeded by configHook. - pnpm install --force --offline --frozen-lockfile --reporter=append-only - - pnpm prisma generate - pnpm build - - # Do NOT `pnpm prune --prod` here — the prisma CLI lives in - # devDependencies and `scripts/start.js` invokes it at boot via - # `npx prisma migrate deploy`. Without the CLI, the migration step - # silently fails (npx falls back to downloading prisma fresh, which - # OOMs on most containers) and the SQLite db stays empty. See - # `aiolabs/nsecbunkerd#7` diagnosis 2026-05-27. - find node_modules -xtype l -delete - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - - mkdir -p $out/{bin,share/nsecbunkerd} - # scripts/ MUST be copied — it contains the start.js launcher that - # runs `prisma migrate deploy` before spawning the daemon. The - # upstream packaging (and the upstream Dockerfile) bypassed this by - # invoking dist/index.js directly, leaving migrations unapplied. - cp -r dist node_modules prisma scripts templates package.json \ - $out/share/nsecbunkerd/ - - # Wrapper invokes scripts/start.js, which runs `prisma migrate deploy` - # then spawns dist/index.js. start.js resolves sibling paths from - # __dirname, so the caller (systemd unit, docker compose, etc.) can - # set its own WorkingDirectory for the writable state dir without - # interfering with how the launcher finds its own package files. - # NSEC_BUNKER_CONFIG_DIR can override the config directory location; - # by default it's `./config` relative to cwd. - makeWrapper ${lib.getExe nodejs_20} $out/bin/nsecbunkerd \ - --add-flags $out/share/nsecbunkerd/scripts/start.js \ - --set NODE_ENV production \ - --prefix PATH : ${lib.makeBinPath [ openssl nodejs_20 ]} \ - ${ - lib.concatStringsSep " \\\n " ( - lib.mapAttrsToList (n: v: "--set ${n} ${lib.escapeShellArg v}") prismaEnv - ) - } - - makeWrapper ${lib.getExe nodejs_20} $out/bin/nsecbunker-client \ - --chdir $out/share/nsecbunkerd \ - --add-flags $out/share/nsecbunkerd/dist/client/client.js \ - --set NODE_ENV production - - runHook postInstall - ''; - - passthru = { - inherit prisma-engines; - }; - - meta = { - description = "Nostr remote signing daemon (NIP-46)"; - homepage = "https://github.com/kind-0/nsecbunkerd"; - license = lib.licenses.mit; - mainProgram = "nsecbunkerd"; - platforms = lib.platforms.linux; - }; -}) diff --git a/scripts/start.js b/scripts/start.js index 603d5b2..c3899f8 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -1,32 +1,20 @@ const { execSync, spawn } = require('child_process'); const fs = require('fs'); -const path = require('path'); - -// Resolve sibling paths from this script's location so the launcher -// works whether cwd is /app (docker), the nix store, or a writable -// state dir set by systemd's WorkingDirectory. The prisma CLI and -// dist/index.js live alongside this file in `/share/nsecbunkerd/` -// (nix) or `/app/` (docker). The migration-side env knobs: -// NSEC_BUNKER_CONFIG_DIR — directory holding nsecbunker.{json,db}; -// defaults to ./config relative to cwd. -// DATABASE_URL — prisma's source of truth for the sqlite -// path; honor whatever the caller set. -const pkgRoot = path.resolve(__dirname, '..'); -const configDir = process.env.NSEC_BUNKER_CONFIG_DIR || path.resolve(process.cwd(), 'config'); try { - console.log(`Running migrations`); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); + console.log(`Running migrations`); + // check if config folder exists + if (!fs.existsSync('./config')) { + execSync(`mkdir config`); } - execSync('npm run prisma:migrate', { cwd: pkgRoot, stdio: 'inherit' }); + execSync('npm run prisma:migrate'); } catch (error) { - console.log(error); + console.log(error); // Handle any potential migration errors here } const args = process.argv.slice(2); -const childProcess = spawn('node', [path.join(pkgRoot, 'dist/index.js'), ...args], { +const childProcess = spawn('node', ['./dist/index.js', ...args], { stdio: 'inherit', }); diff --git a/src/daemon/admin/commands/create_account.ts b/src/daemon/admin/commands/create_account.ts index f3c026a..e4632d6 100644 --- a/src/daemon/admin/commands/create_account.ts +++ b/src/daemon/admin/commands/create_account.ts @@ -1,4 +1,4 @@ -import { Hexpubkey, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk"; +import { Hexpubkey, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk"; import AdminInterface from ".."; import { nip19 } from 'nostr-tools'; import { setupSkeletonProfile } from "../../lib/profile"; @@ -136,7 +136,7 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe } /** - * This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen — pragma: allowlist secret + * This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen */ export async function createAccountReal( admin: AdminInterface, @@ -209,18 +209,11 @@ export async function createAccountReal( // access it without having to go through an approval flow await grantPermissions(req, keyName); - // NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — it resolves - // to `undefined` and sendResponse defaults to NDKKind.NostrConnect - // (24133), sending the response on the wrong channel. Mirror the - // request's kind so the response goes back on the same channel the - // client subscribed for. Filed as part of aiolabs/nsecbunkerd#7 - // diagnosis 2026-05-27. - const originalKind = req.event.kind!; - return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, originalKind); + return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnectAdmin); } catch (e: any) { console.trace('error', e); - const originalKind = req.event.kind!; - return admin.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, e.message); + return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, + e.message); } } diff --git a/src/daemon/admin/commands/create_new_token.ts b/src/daemon/admin/commands/create_new_token.ts index 04588c4..df145a2 100644 --- a/src/daemon/admin/commands/create_new_token.ts +++ b/src/daemon/admin/commands/create_new_token.ts @@ -7,19 +7,15 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR if (!clientName || !policyId) throw new Error("Invalid params"); - const policyIdInt = parseInt(policyId); - const policy = await prisma.policy.findUnique({ where: { id: policyIdInt }, include: { rules: true } }); + const policy = await prisma.policy.findUnique({ where: { id: parseInt(policyId) }, include: { rules: true } }); if (!policy) throw new Error("Policy not found"); console.log({clientName, policy, durationInHours}); const token = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); - // policyId must be Int per the Prisma schema (Token.policyId references - // Policy.id which is autoincrement Int). Upstream passes the raw string - // from the wire — caught during aiolabs/nsecbunkerd#7 diagnosis 2026-05-27. const data: any = { - keyName, clientName, policyId: policyIdInt, + keyName, clientName, policyId, createdBy: req.pubkey, token }; diff --git a/src/daemon/admin/index.ts b/src/daemon/admin/index.ts index db8733b..4db9cc2 100644 --- a/src/daemon/admin/index.ts +++ b/src/daemon/admin/index.ts @@ -1,5 +1,5 @@ import "websocket-polyfill"; -import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser } from '@nostr-dev-kit/ndk'; +import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser, NostrEvent } from '@nostr-dev-kit/ndk'; import { NDKNostrRpc } from '@nostr-dev-kit/ndk'; import createDebug from 'debug'; import { Key, KeyUser } from '../run'; @@ -111,28 +111,8 @@ class AdminInterface { return; } - const debugTransport = process.env.NSEC_BUNKER_DEBUG_TRANSPORT === '1'; - - // Per-relay publish-status logging for diagnosing aiolabs/nsecbunkerd#7. - // NDKNostrRpc.sendResponse calls event.publish() and discards the - // returned Set, so a silent outbox-drop is invisible without - // hooking the underlying per-relay events. Gated by env flag so - // production deployments stay quiet. - const attachRelayLogging = (relay: any) => { - relay.on('published', (event: NDKEvent) => { - console.log(`📤 PUBLISHED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)}`); - }); - relay.on('publish:failed', (event: NDKEvent, err: any) => { - console.log(`❌ PUBLISH_FAILED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)} err=${err?.message ?? err}`); - }); - }; - - this.ndk.pool.on('relay:connect', (relay: any) => { - console.log('✅ nsecBunker Admin Interface ready'); - if (debugTransport) attachRelayLogging(relay); - }); + this.ndk.pool.on('relay:connect', () => console.log('✅ nsecBunker Admin Interface ready')); this.ndk.pool.on('relay:disconnect', () => console.log('❌ admin disconnected')); - this.ndk.connect(2500).then(() => { // connect for whitelisted admins this.rpc.subscribe({ @@ -140,45 +120,9 @@ class AdminInterface { "#p": [this.signerUser!.pubkey] }); - // Attach per-relay logging to relays that connected before our - // 'relay:connect' listener was registered above (NDK can connect - // synchronously inside .connect() under some paths). - if (debugTransport) { - this.ndk.pool.relays.forEach((relay: any) => attachRelayLogging(relay)); + this.rpc.on('request', (req) => this.handleRequest(req)); - // Wrap sendResponse to log id + kind + elapsed time so we - // can correlate REQUEST_IN → RESPONSE_SENT → PUBLISHED. - const originalSendResponse = this.rpc.sendResponse.bind(this.rpc); - this.rpc.sendResponse = async (id: string, remotePubkey: string, result: string, kind?: number, error?: string) => { - const start = Date.now(); - try { - await originalSendResponse(id, remotePubkey, result, kind, error); - console.log(`📨 RESPONSE_SENT id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} elapsed=${Date.now()-start}ms`); - } catch (e: any) { - console.log(`❌ RESPONSE_SEND_FAILED id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} err=${e?.message ?? e}`); - throw e; - } - }; - } - - this.rpc.on('request', (req) => { - if (debugTransport) { - console.log(`📥 REQUEST_IN method=${req.method} id=${req.id} from=${req.pubkey?.slice(0,8)} kind=${req.event?.kind}`); - } - this.handleRequest(req); - }); - - // Connection watchdog: exit if pool reports no connected relays - // for >60s so the process supervisor (systemd / docker restart - // policy / k8s) can recover. Replaces the original self-echo - // pingOrDie — see relayConnectionWatchdog comment + #4 + #7. - // Operators with external liveness checking can disable via - // NSEC_BUNKER_DISABLE_WATCHDOG=1. - if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') { - relayConnectionWatchdog(this.ndk); - } else { - console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1'); - } + pingOrDie(this.ndk); }).catch((err) => { console.log('❌ admin connection failed'); console.log(err); @@ -214,15 +158,7 @@ class AdminInterface { } } catch (err: any) { debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params); - // NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — using it - // makes sendResponse fall through to its default of 24133, which - // sends the error on a different channel than the request came in - // on. Mirror req.event.kind so the response goes back where the - // client is listening. Filed as part of aiolabs/nsecbunkerd#7 - // diagnosis 2026-05-27. - const originalKind = req.event.kind!; - console.log(`⚠️ HANDLE_REQUEST_ERROR method=${req.method} id=${req.id} kind=${originalKind} err=${err?.message ?? err}`); - return this.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, err?.message); + return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, err?.message); } } @@ -459,47 +395,44 @@ class AdminInterface { } } -/** - * Pool-status connection watchdog. Exits the daemon if every relay in - * the pool stays disconnected for longer than PARTITION_THRESHOLD_MS. - * - * Replaces the original `pingOrDie` self-echo watchdog, which published - * a kind-24133 event to its own pubkey every 20s and exited if it - * didn't see the echo within 50s. That works on public relays but - * silently breaks on single-private-relay setups: NDK 2.8.1's outbox - * model doesn't reliably route self-publishes back through the - * matching subscription, so the watchdog fires false positives and - * exits the daemon every 50s while RPCs over the same channel still - * work fine. See aiolabs/nsecbunkerd#4 + #7. - * - * The pool-status approach uses NDK's own connection-lifecycle - * tracking — `pool.connectedRelays()` reports relays in - * NDKRelayStatus.CONNECTED — which is reliable across all relay - * configurations because it doesn't depend on round-trip - * publish/subscribe. No event is published; no relay traffic. - * - * Detects partition within POLL_INTERVAL + PARTITION_THRESHOLD ms. - * Transient disconnects shorter than PARTITION_THRESHOLD don't trip - * the watchdog — useful for relays that flap or briefly drop on - * network blips. - */ -async function relayConnectionWatchdog(ndk: NDK) { - const POLL_INTERVAL_MS = 10_000; - const PARTITION_THRESHOLD_MS = 60_000; - let lastConnectedAt = Date.now(); +async function pingOrDie(ndk: NDK) { + let deathTimer: NodeJS.Timeout | null = null; + + function resetDeath() { + if (deathTimer) clearTimeout(deathTimer); + deathTimer = setTimeout(() => { + console.log(`❌ No ping event received in 30 seconds. Exiting.`); + process.exit(1); + }, 50000); + } + + const self = await ndk.signer!.user(); + const sub = ndk.subscribe({ + authors: [self.pubkey], + kinds: [NDKKind.NostrConnect], + "#p": [self.pubkey] + }); + sub.on("event", (event: NDKEvent) => { + console.log(`🔔 Received ping event:`, event.created_at); + resetDeath(); + }); + sub.start(); + + resetDeath(); setInterval(() => { - const connectedCount = ndk.pool.connectedRelays().length; - if (connectedCount > 0) { - lastConnectedAt = Date.now(); - return; - } - const elapsed = Date.now() - lastConnectedAt; - if (elapsed > PARTITION_THRESHOLD_MS) { - console.log(`❌ No connected relays for ${Math.floor(elapsed / 1000)}s. Exiting.`); + const event = new NDKEvent(ndk, { + kind: NDKKind.NostrConnect, + tags: [ ["p", self.pubkey] ], + content: "ping" + } as NostrEvent); + event.publish().then(() => { + console.log(`🔔 Sent ping event:`, event.created_at); + }).catch((e: any) => { + console.log(`❌ Failed to send ping event:`, e.message); process.exit(1); - } - }, POLL_INTERVAL_MS); + }); + }, 20000); } export default AdminInterface; diff --git a/src/daemon/backend/index.ts b/src/daemon/backend/index.ts index 861f10f..7661a09 100644 --- a/src/daemon/backend/index.ts +++ b/src/daemon/backend/index.ts @@ -22,43 +22,6 @@ export class Backend extends NDKNip46Backend { // this.setStrategy('publish_event', new PublishEventHandlingStrategy()); } - /** - * Override NDKNip46Backend.start() to await the kind-24133 - * subscription's EOSE before resolving. The base implementation - * calls `this.ndk.subscribe(...)` and returns immediately — the - * NDKSubscription queues a REQ on the relay connection but the - * relay's acknowledgement (EOSE) hasn't arrived yet. Any caller - * that publishes a NIP-46 event in the immediate window after - * `start()` returns races against the relay registering this - * subscription. - * - * aiolabs/lnbits#33's eager-bind chain publishes a NIP-46 - * `connect` event in the same HTTP round-trip as `create_new_key`, - * which loses this race deterministically — the bunker never - * sees the connect event because its subscription wasn't yet - * registered with the relay when the event was broadcast. - * - * Awaiting EOSE closes the race: by the time `start()` resolves, - * the relay has confirmed it has the bunker's subscription on - * file and will route matching kind-24133 events to it. - * - * See aiolabs/nsecbunkerd#9 for the full diagnosis. - */ - async start(): Promise { - this.localUser = await this.signer.user(); - await new Promise((resolve) => { - const sub = this.ndk.subscribe( - { - kinds: [24133], - "#p": [this.localUser!.pubkey], - }, - { closeOnEose: false } - ); - sub.on("event", (e: any) => this.handleIncomingEvent(e)); - sub.on("eose", () => resolve()); - }); - } - private async validateToken(token: string) { if (!token) throw new Error("Invalid token"); diff --git a/src/daemon/run.ts b/src/daemon/run.ts index 89eda0a..262a150 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -230,14 +230,8 @@ class Daemon { if (nsec.startsWith('nsec1')) { try { - // NDK 2.8.1's NDKPrivateKeySigner constructor passes its - // arg straight to nostr-tools getPublicKey() which requires - // 32-byte hex / bytes / bigint, not bech32. Without this - // decode, every key created via create_new_key fails to - // load with the nostr-tools getPublicKey type error, so - // the bunker can never sign for any target it provisions. - // See aiolabs/nsecbunkerd#8. - hexpk = nip19.decode(nsec).data as string; + const key = new NDKPrivateKeySigner(nsec); + hexpk = key.privateKey!; } catch(e) { console.error(`Error loading key ${name}:`, e); return @@ -257,14 +251,14 @@ class Daemon { const nsec = decryptNsec(iv, data, passphrase); this.activeKeys[keyName] = nsec; - await this.startKey(keyName, nsec); + this.startKey(keyName, nsec); return true; } - async loadNsec(keyName: string, nsec: string) { + loadNsec(keyName: string, nsec: string) { this.activeKeys[keyName] = nsec; - await this.startKey(keyName, nsec); + this.startKey(keyName, nsec); } } \ No newline at end of file