diff --git a/Dockerfile b/Dockerfile index 1168d8c..9ace24a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,10 +39,20 @@ RUN npm install -g pnpm@9 # Copy built files from the build stage COPY --from=build /app . -# Install only runtime dependencies (pnpm respects the workspace protocol) -RUN pnpm install --prod --no-frozen-lockfile +# 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 EXPOSE 3000 -ENTRYPOINT [ "node", "./dist/index.js" ] +# 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" ] CMD ["start"] diff --git a/package.nix b/package.nix index 5c49bf7..adeb62b 100644 --- a/package.nix +++ b/package.nix @@ -13,12 +13,17 @@ }: let - # package.json pins `@nostr-dev-kit/ndk: "workspace:*"` but the lockfile - # resolves `^2.8.1`. With --frozen-lockfile pnpm refuses the mismatch, - # so rewrite the spec to match the lockfile. + # 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": "workspace:*"' \ + --replace-fail '"@nostr-dev-kit/ndk": "2.8.1"' \ '"@nostr-dev-kit/ndk": "^2.8.1"' ''; @@ -77,7 +82,12 @@ stdenv.mkDerivation (finalAttrs: { pnpm prisma generate pnpm build - pnpm prune --prod --ignore-scripts + # 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 @@ -87,14 +97,24 @@ stdenv.mkDerivation (finalAttrs: { runHook preInstall mkdir -p $out/{bin,share/nsecbunkerd} - cp -r dist node_modules prisma templates package.json \ + # 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 \ - --chdir $out/share/nsecbunkerd \ - --add-flags $out/share/nsecbunkerd/dist/index.js \ + --add-flags $out/share/nsecbunkerd/scripts/start.js \ --set NODE_ENV production \ - --prefix PATH : ${lib.makeBinPath [ openssl ]} \ + --prefix PATH : ${lib.makeBinPath [ openssl nodejs_20 ]} \ ${ lib.concatStringsSep " \\\n " ( lib.mapAttrsToList (n: v: "--set ${n} ${lib.escapeShellArg v}") prismaEnv diff --git a/scripts/start.js b/scripts/start.js index c3899f8..603d5b2 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -1,20 +1,32 @@ 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`); - // check if config folder exists - if (!fs.existsSync('./config')) { - execSync(`mkdir config`); + console.log(`Running migrations`); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); } - execSync('npm run prisma:migrate'); + execSync('npm run prisma:migrate', { cwd: pkgRoot, stdio: 'inherit' }); } catch (error) { - console.log(error); + console.log(error); // Handle any potential migration errors here } const args = process.argv.slice(2); -const childProcess = spawn('node', ['./dist/index.js', ...args], { +const childProcess = spawn('node', [path.join(pkgRoot, '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 e4632d6..f3c026a 100644 --- a/src/daemon/admin/commands/create_account.ts +++ b/src/daemon/admin/commands/create_account.ts @@ -1,4 +1,4 @@ -import { Hexpubkey, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk"; +import { Hexpubkey, 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 + * This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen — pragma: allowlist secret */ export async function createAccountReal( admin: AdminInterface, @@ -209,11 +209,18 @@ export async function createAccountReal( // access it without having to go through an approval flow await grantPermissions(req, keyName); - return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnectAdmin); + // 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); } catch (e: any) { console.trace('error', e); - return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, - e.message); + const originalKind = req.event.kind!; + return admin.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, e.message); } } diff --git a/src/daemon/admin/commands/create_new_token.ts b/src/daemon/admin/commands/create_new_token.ts index df145a2..04588c4 100644 --- a/src/daemon/admin/commands/create_new_token.ts +++ b/src/daemon/admin/commands/create_new_token.ts @@ -7,15 +7,19 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR if (!clientName || !policyId) throw new Error("Invalid params"); - const policy = await prisma.policy.findUnique({ where: { id: parseInt(policyId) }, include: { rules: true } }); + const policyIdInt = parseInt(policyId); + const policy = await prisma.policy.findUnique({ where: { id: policyIdInt }, 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, + keyName, clientName, policyId: policyIdInt, createdBy: req.pubkey, token }; diff --git a/src/daemon/admin/index.ts b/src/daemon/admin/index.ts index 75dfc4b..5adafe8 100644 --- a/src/daemon/admin/index.ts +++ b/src/daemon/admin/index.ts @@ -111,8 +111,28 @@ class AdminInterface { return; } - this.ndk.pool.on('relay:connect', () => console.log('✅ nsecBunker Admin Interface ready')); + 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:disconnect', () => console.log('❌ admin disconnected')); + this.ndk.connect(2500).then(() => { // connect for whitelisted admins this.rpc.subscribe({ @@ -120,7 +140,33 @@ class AdminInterface { "#p": [this.signerUser!.pubkey] }); - this.rpc.on('request', (req) => this.handleRequest(req)); + // 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)); + + // 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); + }); // pingOrDie disabled — NDK 2.8.1 outbox model doesn't echo // self-published events back through subscriptions on @@ -163,7 +209,15 @@ class AdminInterface { } } catch (err: any) { debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params); - return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, err?.message); + // 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); } }