diff --git a/package.json b/package.json index 1526d9a..07fa987 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,8 @@ "build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client", "build:client": "tsup src/client.ts -d dist/client", "test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/lifecycle.test.ts", - "test:relay": "TS_NODE_TRANSPILE_ONLY=1 node --test-force-exit -r ts-node/register --test tests/relay-pool.test.ts", - "test:nip46": "node --test-force-exit -r ./tests/register-ts.cjs --test tests/nip46-backend.test.ts", - "test:admin": "node --test-force-exit -r ./tests/register-ts.cjs --test tests/admin-transport.test.ts", "test:integration": "DATABASE_URL=\"file:./tests/.tmp/acl-int.db\" node -r ./tests/register-ts.cjs --test tests/acl.integration.test.ts", - "test:all": "npm run test && npm run test:relay && npm run test:nip46 && npm run test:admin && npm run test:integration", + "test:all": "npm run test && npm run test:integration", "prisma:generate": "npx prisma generate", "prisma:migrate": "npx prisma migrate deploy", "prisma:create": "npx prisma db push --preview-feature", diff --git a/src/daemon/admin/commands/add_policy_rule.ts b/src/daemon/admin/commands/add_policy_rule.ts index 76d5d83..503dd17 100644 --- a/src/daemon/admin/commands/add_policy_rule.ts +++ b/src/daemon/admin/commands/add_policy_rule.ts @@ -1,4 +1,4 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -23,7 +23,7 @@ import prisma from "../../../db.js"; * `rule.kind.toString()` storage and the override-layer convention. The * `'all'` literal is honored at sign-time as a wildcard across kinds. */ -export default async function addPolicyRule(admin: AdminInterface, req: AdminRpcRequest) { +export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/add_signing_condition.ts b/src/daemon/admin/commands/add_signing_condition.ts index a9aebce..f734d24 100644 --- a/src/daemon/admin/commands/add_signing_condition.ts +++ b/src/daemon/admin/commands/add_signing_condition.ts @@ -1,4 +1,4 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -18,7 +18,7 @@ import prisma from "../../../db.js"; * checkIfPubkeyAllowed (step 3 vs step 4), so `allowed: false` here * denies regardless of the policy. */ -export default async function addSigningCondition(admin: AdminInterface, req: AdminRpcRequest) { +export default async function addSigningCondition(admin: AdminInterface, req: NDKRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/create_account.ts b/src/daemon/admin/commands/create_account.ts index 6a2d561..2919d9c 100644 --- a/src/daemon/admin/commands/create_account.ts +++ b/src/daemon/admin/commands/create_account.ts @@ -1,5 +1,4 @@ -import { Hexpubkey, NDKPrivateKeySigner, NDKUserProfile } from "@nostr-dev-kit/ndk"; -import { AdminRpcRequest } from "../types.js"; +import { Hexpubkey, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk"; import AdminInterface from ".."; import { nip19 } from 'nostr-tools'; import { setupSkeletonProfile } from "../../lib/profile"; @@ -70,7 +69,7 @@ const RESERVED_USERNAMES = [ "admin", "root", "_", "administrator", "__" ]; -async function validateUsername(username: string | undefined, domain: string, admin: AdminInterface, req: AdminRpcRequest) { +async function validateUsername(username: string | undefined, domain: string, admin: AdminInterface, req: NDKRpcRequest) { if (!username || username.length === 0) { // create a random username of 10 characters username = Math.random().toString(36).substring(2, 15); @@ -84,7 +83,7 @@ async function validateUsername(username: string | undefined, domain: string, ad return username; } -async function validateDomain(domain: string | undefined, admin: AdminInterface, req: AdminRpcRequest) { +async function validateDomain(domain: string | undefined, admin: AdminInterface, req: NDKRpcRequest) { const availableDomains = (await admin.config()).domains; if (!availableDomains || Object.keys(availableDomains).length === 0) @@ -100,7 +99,7 @@ async function validateDomain(domain: string | undefined, admin: AdminInterface, return domain; } -export default async function createAccount(admin: AdminInterface, req: AdminRpcRequest) { +export default async function createAccount(admin: AdminInterface, req: NDKRpcRequest) { let [ username, domain, email ] = req.params as [ string?, string?, string? ]; try { @@ -144,7 +143,7 @@ export default async function createAccount(admin: AdminInterface, req: AdminRpc */ export async function createAccountReal( admin: AdminInterface, - req: AdminRpcRequest, + req: NDKRpcRequest, username: string, domain: string, email?: string @@ -228,7 +227,7 @@ export async function createAccountReal( } } -async function grantPermissions(req: AdminRpcRequest, keyName: string) { +async function grantPermissions(req: NDKRpcRequest, keyName: string) { await allowAllRequestsFromKey(req.pubkey, keyName, "connect"); await allowAllRequestsFromKey(req.pubkey, keyName, "sign_event", undefined, undefined, { kind: 'all' }); await allowAllRequestsFromKey(req.pubkey, keyName, "encrypt"); diff --git a/src/daemon/admin/commands/create_new_key.ts b/src/daemon/admin/commands/create_new_key.ts index c9182ed..cdedcd3 100644 --- a/src/daemon/admin/commands/create_new_key.ts +++ b/src/daemon/admin/commands/create_new_key.ts @@ -1,5 +1,4 @@ -import NDK, { NDKEvent, NDKPrivateKeySigner, type NostrEvent } from "@nostr-dev-kit/ndk"; -import { AdminRpcRequest } from "../types.js"; +import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { saveEncrypted } from "../../../commands/add.js"; @@ -7,7 +6,7 @@ import { getCurrentConfig } from "../../../config/index.js"; import { decryptNsec } from "../../../config/keys.js"; import { setupSkeletonProfile } from "../../lib/profile.js"; -export default async function createNewKey(admin: AdminInterface, req: AdminRpcRequest) { +export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) { const [ keyName, passphrase, _nsec ] = req.params as [ string, string, string? ]; if (!keyName || !passphrase) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/create_new_policy.ts b/src/daemon/admin/commands/create_new_policy.ts index 3579a76..af4bfd4 100644 --- a/src/daemon/admin/commands/create_new_policy.ts +++ b/src/daemon/admin/commands/create_new_policy.ts @@ -1,9 +1,9 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; -export default async function createNewPolicy(admin: AdminInterface, req: AdminRpcRequest) { +export default async function createNewPolicy(admin: AdminInterface, req: NDKRpcRequest) { const [ _policy ] = req.params as [ string ]; if (!_policy) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/create_new_token.ts b/src/daemon/admin/commands/create_new_token.ts index 97c711e..b66765f 100644 --- a/src/daemon/admin/commands/create_new_token.ts +++ b/src/daemon/admin/commands/create_new_token.ts @@ -1,9 +1,9 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; -export default async function createNewToken(admin: AdminInterface, req: AdminRpcRequest) { +export default async function createNewToken(admin: AdminInterface, req: NDKRpcRequest) { const [ keyName, clientName, policyId, durationInHours ] = req.params as [ string, string, string, string? ]; if (!clientName || !policyId) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/ping.ts b/src/daemon/admin/commands/ping.ts index 6abdb11..9368c44 100644 --- a/src/daemon/admin/commands/ping.ts +++ b/src/daemon/admin/commands/ping.ts @@ -1,7 +1,7 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; -export default async function ping(admin: AdminInterface, req: AdminRpcRequest) { +export default async function ping(admin: AdminInterface, req: NDKRpcRequest) { return admin.rpc.sendResponse(req.id, req.pubkey, "ok", NIP46_ADMIN_RESPONSE_KIND); } diff --git a/src/daemon/admin/commands/remove_policy_rule.ts b/src/daemon/admin/commands/remove_policy_rule.ts index 07836c3..f8b7c60 100644 --- a/src/daemon/admin/commands/remove_policy_rule.ts +++ b/src/daemon/admin/commands/remove_policy_rule.ts @@ -1,4 +1,4 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -17,7 +17,7 @@ import prisma from "../../../db.js"; * removes across instance versions can race. Adds are safe, removes * are not. */ -export default async function removePolicyRule(admin: AdminInterface, req: AdminRpcRequest) { +export default async function removePolicyRule(admin: AdminInterface, req: NDKRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/remove_signing_condition.ts b/src/daemon/admin/commands/remove_signing_condition.ts index 661851f..0e4da78 100644 --- a/src/daemon/admin/commands/remove_signing_condition.ts +++ b/src/daemon/admin/commands/remove_signing_condition.ts @@ -1,4 +1,4 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -10,7 +10,7 @@ import prisma from "../../../db.js"; * Param shape (JSON-stringified): * { conditionId: number } */ -export default async function removeSigningCondition(admin: AdminInterface, req: AdminRpcRequest) { +export default async function removeSigningCondition(admin: AdminInterface, req: NDKRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/rename_key_user.ts b/src/daemon/admin/commands/rename_key_user.ts index fa9e561..0877cf4 100644 --- a/src/daemon/admin/commands/rename_key_user.ts +++ b/src/daemon/admin/commands/rename_key_user.ts @@ -1,9 +1,9 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; -export default async function renameKeyUser(admin: AdminInterface, req: AdminRpcRequest) { +export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRequest) { const [ keyUserPubkey, name ] = req.params as [ string, string ]; if (!keyUserPubkey || !name) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/revoke_token.ts b/src/daemon/admin/commands/revoke_token.ts index 8993fae..db04c21 100644 --- a/src/daemon/admin/commands/revoke_token.ts +++ b/src/daemon/admin/commands/revoke_token.ts @@ -1,4 +1,4 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -17,7 +17,7 @@ import prisma from "../../../db.js"; * bound to it continue to grant via their own policies. Use * revoke_user for the binary "this user is gone" case. */ -export default async function revokeToken(admin: AdminInterface, req: AdminRpcRequest) { +export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/revoke_user.ts b/src/daemon/admin/commands/revoke_user.ts index 4477cba..9deb5b6 100644 --- a/src/daemon/admin/commands/revoke_user.ts +++ b/src/daemon/admin/commands/revoke_user.ts @@ -1,9 +1,9 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; -export default async function revokeUser(admin: AdminInterface, req: AdminRpcRequest) { +export default async function revokeUser(admin: AdminInterface, req: NDKRpcRequest) { const [ keyUserId ] = req.params as [ string ]; if (!keyUserId) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/unlock_key.ts b/src/daemon/admin/commands/unlock_key.ts index dd2d174..dc27f39 100644 --- a/src/daemon/admin/commands/unlock_key.ts +++ b/src/daemon/admin/commands/unlock_key.ts @@ -1,8 +1,8 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; -export default async function unlockKey(admin: AdminInterface, req: AdminRpcRequest) { +export default async function unlockKey(admin: AdminInterface, req: NDKRpcRequest) { const [ keyName, passphrase ] = req.params as [ string, string ]; if (!keyName || !passphrase) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/update_policy.ts b/src/daemon/admin/commands/update_policy.ts index 78e1045..ebc9805 100644 --- a/src/daemon/admin/commands/update_policy.ts +++ b/src/daemon/admin/commands/update_policy.ts @@ -1,4 +1,4 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -17,7 +17,7 @@ import prisma from "../../../db.js"; * `expiresAt: null` explicitly clears the field; `expiresAt` absent * from the patch leaves it alone. */ -export default async function updatePolicy(admin: AdminInterface, req: AdminRpcRequest) { +export default async function updatePolicy(admin: AdminInterface, req: NDKRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/update_policy_rule.ts b/src/daemon/admin/commands/update_policy_rule.ts index 4e054d0..9b66bcf 100644 --- a/src/daemon/admin/commands/update_policy_rule.ts +++ b/src/daemon/admin/commands/update_policy_rule.ts @@ -1,4 +1,4 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -23,7 +23,7 @@ import prisma from "../../../db.js"; * Tightening a cap takes effect immediately — a client already over the new * limit within the window is denied until its trailing count falls below it. */ -export default async function updatePolicyRule(admin: AdminInterface, req: AdminRpcRequest) { +export default async function updatePolicyRule(admin: AdminInterface, req: NDKRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/index.ts b/src/daemon/admin/index.ts index 633b050..1354685 100644 --- a/src/daemon/admin/index.ts +++ b/src/daemon/admin/index.ts @@ -1,5 +1,6 @@ -import NDK, { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; -import { getPublicKey, nip19 } from 'nostr-tools'; +import "websocket-polyfill"; +import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser } from '@nostr-dev-kit/ndk'; +import { NDKNostrRpc } from '@nostr-dev-kit/ndk'; import createDebug from 'debug'; import { Key, KeyUser } from '../run'; import { allowAllRequestsFromKey } from '../lib/acl/index.js'; @@ -20,16 +21,12 @@ import addSigningCondition from './commands/add_signing_condition'; import removeSigningCondition from './commands/remove_signing_condition'; import revokeToken from './commands/revoke_token'; import { NIP46_ADMIN_RESPONSE_KIND } from './kinds.js'; -import { NIP46_NOSTR_CONNECT_KIND } from './kinds.js'; import fs from 'fs'; import { validateRequestFromAdmin } from './validations/request-from-admin'; import { dmUser } from '../../utils/dm-user'; import { IConfig, getCurrentConfig } from "../../config"; import path from 'path'; -import { RelayPool } from '../lib/relay-pool.js'; -import { Nip46Transport, secretKeyBytes } from '../nip46/transport.js'; -import type { AdminRpc, AdminRpcRequest } from './types.js'; -import type { Nip46Request } from '../nip46/types.js'; +import { attachIndefiniteReconnect } from '../lib/relay-reconnect.js'; const debug = createDebug("nsecbunker:admin"); @@ -48,91 +45,73 @@ const allowNewKeys = true; * This class represents the admin interface for the nsecbunker daemon. * * It provides an interface for a UI to manage the daemon over nostr. - * - * Ported off NDK onto the nostr-tools RelayPool transport (aiolabs/nsecbunkerd#42) - * so the admin channel, like the signer channel, re-subscribes on every relay - * reconnect and can't go silently deaf after a flap (#41). */ class AdminInterface { private npubs: string[]; - private pool: RelayPool; - private transport: Nip46Transport; - private adminPubkey: string; - private adminNsec: string; - /** Envelope encryption (nip04/nip44) of each in-flight request, by id, so - * responses go back in the scheme the client used. */ - private reqEncryption: Map = new Map(); - readonly rpc: AdminRpc; + private ndk: NDK; + private signerUser?: NDKUser; + readonly rpc: NDKNostrRpc; readonly configFile: string; public getKeys?: () => Promise; - public getKeyUsers?: (req: AdminRpcRequest) => Promise; + public getKeyUsers?: (req: NDKRpcRequest) => Promise; public unlockKey?: (keyName: string, passphrase: string) => Promise; public loadNsec?: (keyName: string, nsec: string) => void; constructor(opts: IAdminOpts, configFile: string) { this.configFile = configFile; - this.npubs = opts.npubs || []; - this.adminNsec = opts.key; - this.adminPubkey = getPublicKey(secretKeyBytes(opts.key)); - - this.pool = new RelayPool(opts.adminRelays, { - log: (...a: any[]) => console.log(...a), - heartbeatMs: 30_000, + this.npubs = opts.npubs||[]; + this.ndk = new NDK({ + explicitRelayUrls: opts.adminRelays, + signer: new NDKPrivateKeySigner(opts.key), }); - this.transport = new Nip46Transport(secretKeyBytes(opts.key), this.pool); - // The admin RPC the command handlers call. sendResponse encrypts with - // the scheme the request used (resolved per id) and publishes on the - // admin response channel (24134) unless a handler mirrors the request - // kind for errors. - this.rpc = { - sendResponse: async ( - id: string, - remotePubkey: string, - result: string, - kind: number = NIP46_NOSTR_CONNECT_KIND, - error?: string, - ) => { - const encryption = this.reqEncryption.get(id) ?? "nip44"; - await this.transport.sendResponse(id, remotePubkey, result, encryption, error, kind); - }, - }; + // Override NDK's "give up after detecting flapping" behavior so the + // bunker's admin NDK keeps trying to reconnect indefinitely. The + // watchdog (when enabled) still fires after 60s of zero connected + // relays; this helper handles shorter disconnects (e.g. an lnbits + // restart that pulls the nostrrelay extension's WS for a few + // seconds) without involving the supervisor. See aiolabs/nsecbunkerd#20. + attachIndefiniteReconnect(this.ndk, 'admin'); - const npub = nip19.npubEncode(this.adminPubkey); - let connectionString = `bunker://${npub}`; - if (opts.adminRelays.length > 0) { - connectionString += '@' + encodeURIComponent(`${opts.adminRelays.join(',').replace(/wss:\/\//g, '')}`); - } - console.log(`\n\nnsecBunker connection string:\n\n${connectionString}\n\n`); - const configFolder = path.dirname(configFile); - fs.writeFileSync(path.join(configFolder, 'connection.txt'), connectionString); + this.ndk.signer?.user().then((user: NDKUser) => { + let connectionString = `bunker://${user.npub}`; - this.connect(); - - this.config().then((config) => { - if (config.admin?.notifyAdminsOnBoot) { - this.notifyAdminsOfNewConnection(connectionString); + if (opts.adminRelays.length > 0) { + connectionString += '@' + encodeURIComponent(`${opts.adminRelays.join(',').replace(/wss:\/\//g, '')}`); } + + console.log(`\n\nnsecBunker connection string:\n\n${connectionString}\n\n`); + + // write connection string to connection.txt + const configFolder = path.dirname(configFile) + fs.writeFileSync(path.join(configFolder, 'connection.txt'), connectionString); + + this.signerUser = user; + + this.connect(); + + this.config().then((config) => { + if (config.admin?.notifyAdminsOnBoot) { + this.notifyAdminsOfNewConnection(connectionString); + } + }); }); + + this.rpc = new NDKNostrRpc(this.ndk, this.ndk.signer!, debug); } public async config(): Promise { return getCurrentConfig(this.configFile); } - /** - * Boot-time DM to the admin npubs. One-shot, best-effort notification over - * public relays — not part of the reconnect-sensitive RPC path, so it still - * uses a throwaway NDK + the existing dmUser helper. (#42 leaves this on NDK.) - */ private async notifyAdminsOfNewConnection(connectionString: string) { const blastrNdk = new NDK({ explicitRelayUrls: ['wss://blastr.f7z.xyz', 'wss://nostr.mutinywallet.com'], - signer: new NDKPrivateKeySigner(this.adminNsec), + signer: this.ndk.signer }); await blastrNdk.connect(2500); - for (const npub of this.npubs || []) { + for (const npub of this.npubs||[]) { dmUser(blastrNdk, npub, `nsecBunker has started; use ${connectionString} to connect to it and unlock your key(s)`); } } @@ -141,7 +120,7 @@ class AdminInterface { * Get the npub of the admin interface. */ public async npub() { - return nip19.npubEncode(this.adminPubkey); + return (await this.ndk.signer?.user())!.npub; } private connect() { @@ -150,59 +129,81 @@ class AdminInterface { return; } - this.pool.start(); - // Listen for admin requests on the NostrConnect channel (24133) AND the - // admin response channel (24134, where admin clients address us). - this.transport - .start((req) => this.onRequest(req), [NIP46_NOSTR_CONNECT_KIND, NIP46_ADMIN_RESPONSE_KIND]) - .then(() => console.log('✅ nsecBunker Admin Interface ready')) - .catch((err) => { - console.log('❌ admin transport failed'); - console.log(err); + 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({ + "kinds": [NDKKind.NostrConnect, NIP46_ADMIN_RESPONSE_KIND], + "#p": [this.signerUser!.pubkey] }); - // Session-liveness watchdog: exit (so the process supervisor restarts) - // if the admin pool can't stay healthy — connected AND subscribed — for - // >60s. Unlike the old connectedRelays()-only watchdog (#20), this can't - // be fooled by a reconnected-but-deaf socket (#41). Disable via - // NSEC_BUNKER_DISABLE_WATCHDOG=1. - if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') { - this.startWatchdog(); - } else { - console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1'); - } - } + // 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)); - private startWatchdog() { - const POLL_INTERVAL_MS = 10_000; - const UNHEALTHY_THRESHOLD_MS = 60_000; - let lastHealthyAt = Date.now(); - setInterval(() => { - if (this.pool.healthy()) { - lastHealthyAt = Date.now(); - return; + // 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; + } + }; } - const elapsed = Date.now() - lastHealthyAt; - if (elapsed > UNHEALTHY_THRESHOLD_MS) { - console.log(`❌ Admin pool unhealthy for ${Math.floor(elapsed / 1000)}s. Exiting.`); - process.exit(1); + + 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'); } - }, POLL_INTERVAL_MS); + }).catch((err) => { + console.log('❌ admin connection failed'); + console.log(err); + }); } - private onRequest(req: Nip46Request) { - const adminReq: AdminRpcRequest = { - id: req.id, - pubkey: req.remotePubkey, - method: req.method, - params: req.params, - event: { kind: req.kind }, - }; - this.reqEncryption.set(req.id, req.encryption); - void this.handleRequest(adminReq).finally(() => this.reqEncryption.delete(req.id)); - } - - private async handleRequest(req: AdminRpcRequest) { + private async handleRequest(req: NDKRpcRequest) { try { await this.validateRequest(req); @@ -227,25 +228,30 @@ class AdminInterface { case 'remove_signing_condition': await removeSigningCondition(this, req); break; case 'revoke_token': await revokeToken(this, req); break; default: + const originalKind = req.event.kind!; console.log(`Unknown method ${req.method}`); return this.rpc.sendResponse( req.id, req.pubkey, JSON.stringify(['error', `Unknown method ${req.method}`]), - req.event.kind, + originalKind ); } } catch (err: any) { - debug(`Error handling request ${req.method}: ${err?.message ?? err}`, req.params); - // Mirror req.event.kind so the error goes back on the channel the - // request came in on (aiolabs/nsecbunkerd#7). - const originalKind = req.event.kind; + 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); } } - private async validateRequest(req: AdminRpcRequest): Promise { + private async validateRequest(req: NDKRpcRequest): Promise { // if this request is of type create_account, allow it // TODO: require some POW to prevent spam if (req.method === 'create_account' && allowNewKeys) { @@ -261,7 +267,7 @@ class AdminInterface { /** * Command to list tokens */ - private async reqGetKeyTokens(req: AdminRpcRequest) { + private async reqGetKeyTokens(req: NDKRpcRequest) { const keyName = req.params[0]; const tokens = await prisma.token.findMany({ where: { keyName }, @@ -289,7 +295,7 @@ class AdminInterface { id: t.id, key_name: t.keyName, client_name: t.clientName, - token: [npub, t.token].join('#'), + token: [ npub, t.token ].join('#'), policy_id: t.policyId, policy_name: t.policy?.name, created_at: t.createdAt, @@ -307,7 +313,7 @@ class AdminInterface { /** * Command to list policies */ - private async reqListPolicies(req: AdminRpcRequest) { + private async reqListPolicies(req: NDKRpcRequest) { const policies = await prisma.policy.findMany({ include: { rules: true, @@ -340,7 +346,7 @@ class AdminInterface { /** * Command to fetch keys and their current state */ - private async reqGetKeys(req: AdminRpcRequest) { + private async reqGetKeys(req: NDKRpcRequest) { if (!this.getKeys) throw new Error('getKeys() not implemented'); const result = JSON.stringify(await this.getKeys()); @@ -352,7 +358,7 @@ class AdminInterface { /** * Command to fetch users of a key */ - private async reqGetKeyUsers(req: AdminRpcRequest): Promise { + private async reqGetKeyUsers(req: NDKRpcRequest): Promise { if (!this.getKeyUsers) throw new Error('getKeyUsers() not implemented'); const result = JSON.stringify(await this.getKeyUsers(req)); @@ -382,29 +388,36 @@ class AdminInterface { }, }); + console.trace({method, param}); + if (method === 'sign_event') { - // `param` is the parsed event object the signer passed to the ACL - // (a plain event under #42, no longer an NDKEvent.rawEvent()). - const e = param; + const e = param.rawEvent(); param = JSON.stringify(e); console.log(`👀 Event to be signed\n`, { - kind: e?.kind, - content: e?.content, - tags: e?.tags, + kind: e.kind, + content: e.content, + tags: e.tags, }); } - return new Promise((resolve) => { - console.log(`requesting permission for`, keyName, { remotePubkey, method }); + return new Promise((resolve, reject) => { + console.log(`requesting permission for`, keyName); + console.log(`remotePubkey`, remotePubkey); + console.log(`method`, method); + console.log(`param`, param); + console.log(`keyUser`, keyUser); - // If an admin doesn't respond within 10 seconds, report timeout. + /** + * If an admin doesn't respond within 10 seconds, report back to the user that the request timed out + */ setTimeout(() => { resolve(undefined); }, 10000); for (const npub of this.npubs) { - const adminPubkey = nip19.decode(npub).data as string; + const remoteUser = new NDKUser({npub}); + console.log(`sending request to ${npub}`, remoteUser.pubkey); const params = JSON.stringify({ keyName, remotePubkey, @@ -413,14 +426,12 @@ class AdminInterface { description: keyUser?.description, }); - const id = this.transport.sendRequest( - adminPubkey, + this.rpc.sendRequest( + remoteUser.pubkey, 'acl', [params], - 'nip44', NIP46_ADMIN_RESPONSE_KIND, - (res) => { - this.transport.clearPending(id); + (res: NDKRpcResponse) => { this.requestPermissionResponse( remotePubkey, keyName, @@ -441,7 +452,7 @@ class AdminInterface { method: string, param: string, resolve: (value: boolean) => void, - res: { id: string; result: string; error?: string } + res: NDKRpcResponse ) { let resObj; try { @@ -474,4 +485,47 @@ 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(); + + 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.`); + process.exit(1); + } + }, POLL_INTERVAL_MS); +} + export default AdminInterface; diff --git a/src/daemon/admin/kinds.ts b/src/daemon/admin/kinds.ts index 60df753..85ba137 100644 --- a/src/daemon/admin/kinds.ts +++ b/src/daemon/admin/kinds.ts @@ -1,12 +1,14 @@ -/** - * NIP-46 client channel — kind-24133. Carries `connect` / `sign_event` / - * `nip04_*` / `nip44_*` etc. (NDK called this `NDKKind.NostrConnect`.) - */ -export const NIP46_NOSTR_CONNECT_KIND = 24133; +import type { NDKKind } from '@nostr-dev-kit/ndk'; /** - * NIP-46 admin-RPC response channel — kind-24134. Distinct from the client - * channel (24133) so signer clients and admin clients don't subscribe to each - * other's events. + * NIP-46 admin-RPC response channel — kind-24134. Distinct from the + * standard NIP-46 client channel kind-24133 (`NDKKind.NostrConnect`) + * which carries `sign_event` / `nip04_*` / `nip44_*` / etc. + * + * nsecbunkerd's admin surface uses a dedicated kind so signer clients + * and admin clients don't subscribe to each other's events. + * + * NDK 3.x's `NDKKind` enum does not include 24134; the cast happens + * once here so callers can pass a typed value to `rpc.sendResponse`. */ -export const NIP46_ADMIN_RESPONSE_KIND = 24134; +export const NIP46_ADMIN_RESPONSE_KIND = 24134 as NDKKind; diff --git a/src/daemon/admin/types.ts b/src/daemon/admin/types.ts deleted file mode 100644 index d450b86..0000000 --- a/src/daemon/admin/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Admin-RPC types (aiolabs/nsecbunkerd#42). - * - * Replace NDK's `NDKRpcRequest` / `NDKNostrRpc` so the admin interface — like - * the signer backend — runs on the nostr-tools RelayPool transport instead of - * NDK. Shaped to match what the admin command handlers already use - * (`req.{id,pubkey,method,params,event.kind}`), so the handlers are unchanged - * apart from the import. - */ - -export interface AdminRpcRequest { - id: string; - /** The verified sender (admin client) pubkey, hex. */ - pubkey: string; - method: string; - params: string[]; - /** The inbound event — handlers read `event.kind` to mirror the channel. */ - event: { kind: number }; -} - -/** - * The subset of NDKNostrRpc the admin command handlers call. Backed by the - * Nip46Transport; `sendResponse` encrypts the reply with the same scheme the - * request used (resolved per request id) and publishes it on `kind` (the admin - * response channel 24134, or the mirrored request kind for errors). - */ -export interface AdminRpc { - sendResponse( - id: string, - remotePubkey: string, - result: string, - kind?: number, - error?: string, - ): Promise; -} diff --git a/src/daemon/admin/validations/request-from-admin.ts b/src/daemon/admin/validations/request-from-admin.ts index 963bd7f..38ccd04 100644 --- a/src/daemon/admin/validations/request-from-admin.ts +++ b/src/daemon/admin/validations/request-from-admin.ts @@ -1,8 +1,8 @@ -import { AdminRpcRequest } from "../types.js"; +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { nip19 } from "nostr-tools"; export async function validateRequestFromAdmin( - req: AdminRpcRequest, + req: NDKRpcRequest, npubs: string[], ): Promise { const hexpubkey = req.pubkey; diff --git a/src/daemon/backend/index.ts b/src/daemon/backend/index.ts index 1562fb0..91f2f58 100644 --- a/src/daemon/backend/index.ts +++ b/src/daemon/backend/index.ts @@ -1,147 +1,130 @@ -import type { FastifyInstance } from "fastify"; -import type { RelayPool } from "../lib/relay-pool.js"; -import { Nip46Transport, secretKeyBytes } from "../nip46/transport.js"; -import type { Nip46PermitCallback, Nip46Request } from "../nip46/types.js"; +import NDK, { NDKNip46Backend, NDKPrivateKeySigner, Nip46PermitCallback } from '@nostr-dev-kit/ndk'; +import prisma from '../../db.js'; +import type {FastifyInstance} from "fastify"; +import { grantIsLive } from '../lib/acl/index.js'; -export interface BackendConfig { - pool: RelayPool; - /** The held key (nsec1… or hex). Never leaves this object. */ - nsec: string; - permitCallback: Nip46PermitCallback; - /** Connection-token redemption hook. The daemon injects the prisma-backed - * `applyToken` from `./token-store`; tests inject a stub. Required only if - * clients connect with a token. */ - applyToken?: (remotePubkey: string, token: string) => Promise; - baseUrl?: string; - fastify?: FastifyInstance; -} - -/** - * NIP-46 signing backend for one held key (aiolabs/nsecbunkerd#42). - * - * Was `extends NDKNip46Backend`; now built on {@link Nip46Transport} over the - * RelayPool so the signing path survives relay flaps (#41) — NDK never replayed - * the kind:24133 subscription on reconnect. The protocol, response strings, and - * ACL hook (`pubkeyAllowed` → permitCallback) are preserved byte-for-byte so - * existing clients (lnbits, the spire) are unaffected. The token-redemption - * logic (`validateToken`/`applyToken`) is unchanged from the NDK version. - */ -export class Backend { +export class Backend extends NDKNip46Backend { public baseUrl?: string; - public fastify?: FastifyInstance; - public readonly transport: Nip46Transport; + public fastify: FastifyInstance; - private readonly permitCallback: Nip46PermitCallback; - private readonly applyTokenFn: (remotePubkey: string, token: string) => Promise; + constructor( + ndk: NDK, + fastify: FastifyInstance, + key: string, + cb: Nip46PermitCallback, + baseUrl?: string + ) { + const signer = new NDKPrivateKeySigner(key); + super(ndk, signer, cb); - constructor(config: BackendConfig) { - this.transport = new Nip46Transport(secretKeyBytes(config.nsec), config.pool); - this.permitCallback = config.permitCallback; - this.applyTokenFn = - config.applyToken ?? - (async () => { - throw new Error("connection token redemption not configured"); - }); - this.baseUrl = config.baseUrl; - this.fastify = config.fastify; + this.baseUrl = baseUrl; + this.fastify = fastify; } - /** The held key's public key (hex) — the npub the bunker signs as. */ - get pubkey(): string { - return this.transport.pubkey; - } - - /** Subscribe to this key's kind:24133 channel and serve requests. Resolves - * after the subscription's first EOSE (the #9 start-race guard). */ + /** + * 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 { - await this.transport.start((req) => void this.handleRequest(req)); + this.localUser = await this.signer.user(); + await new Promise((resolve) => { + // Pin this subscription to the daemon's explicit relays via + // `relayUrls`. Without that, NDK 3.x's outbox routing tries to + // resolve the relay set from `this.localUser.pubkey`'s NIP-65 + // relay list (kind:10002). Newly-provisioned bunker keys have + // no published kind:10002 yet, so NDK's subscription manager + // queues the REQ waiting for a relay list that will never + // arrive — the kind:24133 subscription never lands on the + // wire, and inbound NIP-46 events (sign_event, get_public_key, + // nip44_*) targeted at this key get dropped by the relay + // with "Filter didn't match" because the bunker isn't actually + // subscribed for them. + // + // `relayUrls` was added in NDK 2.13.0 as the supported way to + // bypass outbox routing per subscription (see + // NDKSubscriptionOptions.relayUrls in @nostr-dev-kit/ndk). + // The relay set built from these URLs matches what the rest + // of the bunker uses (admin RPC channel + per-key Backend + // channels alike), so events flow through the same connection + // the admin interface already established. + // + // See aiolabs/nsecbunkerd#21. + const sub = this.ndk.subscribe( + { + kinds: [24133], + "#p": [this.localUser!.pubkey], + }, + { + closeOnEose: false, + relayUrls: this.ndk.explicitRelayUrls, + } + ); + sub.on("event", (e: any) => this.handleIncomingEvent(e)); + sub.on("eose", () => resolve()); + }); } - private async pubkeyAllowed(params: { - id: string; - pubkey: string; - method: any; - params?: any; - }): Promise { - return this.permitCallback(params); + private async validateToken(token: string) { + if (!token) throw new Error("Invalid token"); + + const tokenRecord = await prisma.token.findUnique({ where: { + token + }, include: { policy: { include: { rules: true } } } }); + + if (!tokenRecord) throw new Error("Token not found"); + if (tokenRecord.redeemedAt) throw new Error("Token already redeemed"); + if (!tokenRecord.policy) throw new Error("Policy not found"); + // Revoke + expiry via the single grantIsLive predicate — the exact + // check the sign-time ACL uses, so redeem-time and sign-time cannot + // drift (the root of #24). See aiolabs/nsecbunkerd#25. + if (!grantIsLive(tokenRecord)) throw new Error("Token expired or revoked"); + + return tokenRecord; } - private async handleRequest(req: Nip46Request): Promise { - const { id, method, params, remotePubkey, encryption } = req; - try { - const result = await this.dispatch(id, method, params, remotePubkey); - if (result !== undefined) { - await this.transport.sendResponse(id, remotePubkey, result, encryption); - } else { - await this.transport.sendResponse(id, remotePubkey, "error", encryption, "Not authorized"); + async applyToken(userPubkey: string, token: string): Promise { + const tokenRecord = await this.validateToken(token); + const keyName = tokenRecord.keyName; + + // Record ONLY the binding (KeyUser <- Token). Under #25 the token's + // policy is evaluated live at sign time (checkIfPubkeyAllowed step 4) + // off Token -> Policy -> PolicyRule, NOT photocopied into + // SigningCondition rows here. That photocopy was the root of #24: the + // copy carried no expiry/revoke and short-circuited the live check, so + // an expired or revoked token kept signing forever. With no copy, the + // token's lifecycle is re-checked on every request and there is nothing + // to keep in sync. + const upsertedUser = await prisma.keyUser.upsert({ + where: { unique_key_user: { keyName, userPubkey } }, + update: { }, + create: { keyName, userPubkey, description: tokenRecord.clientName }, + }); + + await prisma.token.update({ + where: { id: tokenRecord.id }, + data: { + redeemedAt: new Date(), + keyUserId: upsertedUser.id, } - } catch (e: any) { - try { - await this.transport.sendResponse(id, remotePubkey, "error", encryption, e?.message ?? String(e)); - } catch { - /* publish failed; nothing more we can do */ - } - } + }); } - /** Route a request to its handler. Returns the result string, or undefined - * for "Not authorized" — matching NDK's strategy contract exactly. */ - private async dispatch( - id: string, - method: string, - params: string[], - remotePubkey: string, - ): Promise { - switch (method) { - case "connect": { - const [, token] = params; - if (token) await this.applyTokenFn(remotePubkey, token); - const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method: "connect", params: token }); - return ok ? "ack" : undefined; - } - case "ping": { - const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method: "ping" }); - return ok ? "pong" : undefined; - } - case "get_public_key": - return this.pubkey; - case "sign_event": { - const [eventString] = params; - const tmpl = JSON.parse(eventString); - const ok = await this.pubkeyAllowed({ - id, - pubkey: remotePubkey, - method: "sign_event", - params: tmpl, // ACL reads only `.kind` - }); - if (!ok) return undefined; - const signed = this.transport.sign({ - kind: tmpl.kind, - created_at: tmpl.created_at ?? Math.floor(Date.now() / 1000), - tags: tmpl.tags ?? [], - content: tmpl.content ?? "", - }); - return JSON.stringify(signed); - } - case "nip44_encrypt": - case "nip04_encrypt": { - const [recipientPubkey, payload] = params; - const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method, params: payload }); - if (!ok) return undefined; - const scheme = method === "nip04_encrypt" ? "nip04" : "nip44"; - return this.transport.encryptTo(recipientPubkey, payload, scheme); - } - case "nip44_decrypt": - case "nip04_decrypt": { - const [senderPubkey, ciphertext] = params; - const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method, params: ciphertext }); - if (!ok) return undefined; - const scheme = method === "nip04_decrypt" ? "nip04" : "nip44"; - return this.transport.decryptFrom(senderPubkey, ciphertext, scheme); - } - default: - // Unknown method — undefined surfaces as "Not authorized". - return undefined; - } - } } diff --git a/src/daemon/backend/token-store.ts b/src/daemon/backend/token-store.ts deleted file mode 100644 index 810be6c..0000000 --- a/src/daemon/backend/token-store.ts +++ /dev/null @@ -1,57 +0,0 @@ -import prisma from "../../db.js"; -import { grantIsLive } from "../lib/acl/index.js"; - -/** - * Prisma-backed connection-token redemption (aiolabs/nsecbunkerd#42). - * - * Split out of the Backend so the NIP-46 protocol layer (`backend/index.ts`) - * has no database dependency and can be unit-tested without a generated prisma - * client. The daemon wires {@link applyToken} into the Backend as its - * `applyToken` hook; tests inject a stub. Logic is unchanged from the prior - * NDK-based Backend's `validateToken`/`applyToken`. - */ - -async function validateToken(token: string) { - if (!token) throw new Error("Invalid token"); - - const tokenRecord = await prisma.token.findUnique({ - where: { token }, - include: { policy: { include: { rules: true } } }, - }); - - if (!tokenRecord) throw new Error("Token not found"); - if (tokenRecord.redeemedAt) throw new Error("Token already redeemed"); - if (!tokenRecord.policy) throw new Error("Policy not found"); - // Revoke + expiry via the single grantIsLive predicate — the exact check - // the sign-time ACL uses, so redeem-time and sign-time cannot drift (the - // root of #24). See aiolabs/nsecbunkerd#25. - if (!grantIsLive(tokenRecord)) throw new Error("Token expired or revoked"); - - return tokenRecord; -} - -export async function applyToken(userPubkey: string, token: string): Promise { - const tokenRecord = await validateToken(token); - const keyName = tokenRecord.keyName; - - // Record ONLY the binding (KeyUser <- Token). Under #25 the token's policy - // is evaluated live at sign time (checkIfPubkeyAllowed step 4) off - // Token -> Policy -> PolicyRule, NOT photocopied into SigningCondition rows - // here. That photocopy was the root of #24: the copy carried no - // expiry/revoke and short-circuited the live check, so an expired or revoked - // token kept signing forever. With no copy, the token's lifecycle is - // re-checked on every request and there is nothing to keep in sync. - const upsertedUser = await prisma.keyUser.upsert({ - where: { unique_key_user: { keyName, userPubkey } }, - update: {}, - create: { keyName, userPubkey, description: tokenRecord.clientName }, - }); - - await prisma.token.update({ - where: { id: tokenRecord.id }, - data: { - redeemedAt: new Date(), - keyUserId: upsertedUser.id, - }, - }); -} diff --git a/src/daemon/lib/relay-pool.ts b/src/daemon/lib/relay-pool.ts deleted file mode 100644 index 6ca0c89..0000000 --- a/src/daemon/lib/relay-pool.ts +++ /dev/null @@ -1,407 +0,0 @@ -import { Relay } from "nostr-tools"; -import type { Event, Filter } from "nostr-tools"; - -// nostr-tools needs a WebSocket implementation injected under Node (no global -// WebSocket on the deploy target, Node 20). `useWebSocketImplementation` lives -// in the `nostr-tools/relay` subpath export, which tsc's classic -// `moduleResolution: node` can't resolve — but the build target is CommonJS and -// Node honours the package exports map at runtime, so require it. The same goes -// for `ws` (no @types/ws in the tree). `useWebSocketImplementation` sets -// nostr-tools' internal WS binding at call time, so import-order / global-capture -// don't apply. -// eslint-disable-next-line @typescript-eslint/no-var-requires -const WebSocket = require("ws"); -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { useWebSocketImplementation } = require("nostr-tools/relay") as { - useWebSocketImplementation: (ws: unknown) => void; -}; -useWebSocketImplementation(WebSocket); - -/** - * RelayPool — the daemon's relay/transport layer (aiolabs/nsecbunkerd#42). - * - * Replaces the NDK-based transport whose subscriptions did not survive a relay - * reconnect (#41): NDK registers `relay.once("ready", execute)` — fires once on - * the initial connect, never re-arms — so after a flap the socket reconnects but - * the kind:24133 REQ is never re-sent, and the bunker goes silently deaf. We - * chased that through #4 → #7 → #20 → #21 without closing it, because it is - * structural in NDK. - * - * The fix, modelled on lightning.pub's `RelayConnection` and signet's - * `relay-pool` (both nostr-tools, both bind resubscribe to reconnect): **we own - * the connect loop**, and every (re)connect re-subscribes the entire registry of - * active subscriptions. Subscription liveness can no longer drift from socket - * liveness because the two are established together, atomically, on every cycle. - * - * We deliberately disable nostr-tools' own `enableReconnect`: its resubscribe - * behaviour is version-fragile right now (the auto-resubscribe regressed in - * 2.23.0 `fb7de7f` and the fix `455124e` is unreleased as of 2026-06-26), so we - * make the resubscribe OUR code instead of depending on which nostr-tools - * version is installed. See #42 for the version analysis. - */ - -export interface PoolSubscription { - id: string; - filters: Filter[]; - onevent: (event: Event) => void; - /** Fires on EOSE — note it fires again on every reconnect's resubscribe, so - * callers that want only the FIRST EOSE (e.g. the #9 start-race guard) must - * latch it themselves. */ - oneose?: () => void; -} - -interface ActiveSub { - close: () => void; -} - -const RECONNECT_BASE_MS = 1_000; -const RECONNECT_CAP_MS = 10_000; // match #20's cap — cheap to retry a LAN relay - -/** - * A single relay connection that owns its (re)connect loop. On every successful - * connect it re-subscribes the shared registry; when the socket closes the loop - * reconnects and re-subscribes again. This is the unit that makes - * connected-but-deaf impossible. - */ -class ManagedRelay { - private relay: Relay | null = null; - private active: Map = new Map(); - private stopped = false; - /** Interrupts a pending reconnect backoff so stop() takes effect at once. */ - private wake: (() => void) | null = null; - - public connected = false; - public lastConnectedAt = 0; - public lastDisconnectedAt = 0; - - constructor( - public readonly url: string, - private readonly registry: Map, - private readonly log: (...args: any[]) => void, - ) {} - - start(): void { - void this.connectLoop(); - } - - stop(): void { - this.stopped = true; - this.wake?.(); // break any pending reconnect backoff so we exit promptly - this.closeAllSubs(); - try { - this.relay?.close(); - } catch { - /* ignore */ - } - this.relay = null; - this.connected = false; - } - - /** Reconnect backoff wait that (a) unrefs so a stopped daemon isn't held - * open by a pending timer, and (b) can be woken early by stop(). */ - private backoff(ms: number): Promise { - return new Promise((resolve) => { - const t = setTimeout(() => { - this.wake = null; - resolve(); - }, ms); - t.unref?.(); - this.wake = () => { - clearTimeout(t); - this.wake = null; - resolve(); - }; - }); - } - - /** Subscribe one entry on the live connection (no-op if not connected — it - * will be picked up by the next resubscribeAll on connect). */ - subscribeOne(id: string, s: PoolSubscription): void { - if (!this.relay || !this.connected) return; - // Replace any existing handle for this id (idempotent). - this.active.get(id)?.close(); - try { - const sub = this.relay.subscribe(s.filters, { - onevent: (e: Event) => s.onevent(e), - oneose: () => s.oneose?.(), - }); - this.active.set(id, sub); - } catch (e) { - this.log("subscribe failed", id, e); - } - } - - closeSub(id: string): void { - const sub = this.active.get(id); - if (sub) { - try { - sub.close(); - } catch { - /* ignore */ - } - this.active.delete(id); - } - } - - async publish(event: Event): Promise { - if (!this.relay || !this.connected) { - throw new Error(`relay not connected: ${this.url}`); - } - await this.relay.publish(event); - } - - /** Number of registry entries currently active on the wire. The watchdog - * uses this to detect a connected-but-deaf relay (active < registry). */ - activeCount(): number { - return this.active.size; - } - - private async connectLoop(): Promise { - let failures = 0; - while (!this.stopped) { - const wasReal = await this.connectOnce(); - if (this.stopped) break; - // A real connection that later dropped resets the backoff; a failed - // connect attempt grows it (capped). Either way we keep trying — a - // bunker disconnected is strictly worse than retry pressure on a LAN - // relay (#20's rationale). - failures = wasReal ? 0 : failures + 1; - const delay = Math.min(RECONNECT_BASE_MS * 2 ** failures, RECONNECT_CAP_MS); - await this.backoff(delay); - } - } - - /** - * Open the socket, re-subscribe the whole registry, and resolve when the - * socket closes (or immediately if the open failed). Returns true if we had - * a real connection (so the caller resets backoff), false if the open failed. - */ - private connectOnce(): Promise { - return new Promise((resolve) => { - void (async () => { - let relay: Relay; - try { - // enableReconnect:false — WE own reconnect, not nostr-tools. - relay = await Relay.connect(this.url, { enableReconnect: false }); - } catch (e: any) { - this.log("connect failed:", e?.message ?? e); - resolve(false); - return; - } - - this.relay = relay; - this.connected = true; - this.lastConnectedAt = Date.now(); - this.log(`connected; (re)subscribing ${this.registry.size} sub(s)`); - this.resubscribeAll(); - - relay.onclose = () => { - this.connected = false; - this.lastDisconnectedAt = Date.now(); - this.closeAllSubs(); - if (this.relay) { - this.relay.onclose = null; - this.relay = null; - } - this.log("disconnected"); - resolve(true); - }; - })(); - }); - } - - /** Re-establish every registered subscription on the current connection. - * This is the line that fixes #41. */ - private resubscribeAll(): void { - this.closeAllSubs(); - for (const [id, s] of this.registry) { - this.subscribeOne(id, s); - } - } - - private closeAllSubs(): void { - for (const sub of this.active.values()) { - try { - sub.close(); - } catch { - /* ignore */ - } - } - this.active.clear(); - } - - /** Force a clean reconnect (close → loop reconnects → resubscribes). Used by - * the pool's heartbeat after a detected time-jump (sleep/wake), when the - * socket may look alive but be stale. */ - forceReconnect(): void { - if (this.relay) { - try { - this.relay.close(); - } catch { - /* the onclose handler drives the reconnect */ - } - } - } -} - -export interface RelayPoolOptions { - log?: (...args: any[]) => void; - /** Heartbeat interval for sleep/wake detection (signet pattern). 0 disables. */ - heartbeatMs?: number; - /** If a heartbeat tick is later than interval + this slack, treat it as a - * process suspend (sleep/VM-pause) and force-reconnect all relays. */ - sleepSlackMs?: number; -} - -/** - * A pool of ManagedRelay connections sharing one subscription registry. Mirrors - * the surface the daemon needs from the old NDK instance: subscribe / publish / - * connect-status — plus a `healthy()` signal the watchdog can trust. - */ -export class RelayPool { - private readonly registry: Map = new Map(); - private readonly relays: ManagedRelay[]; - private readonly log: (...args: any[]) => void; - private counter = 0; - private heartbeatTimer: ReturnType | undefined; - private lastHeartbeat = 0; - - constructor( - public readonly relayUrls: string[], - private readonly opts: RelayPoolOptions = {}, - ) { - this.log = opts.log ?? (() => {}); - this.relays = relayUrls.map( - (url) => - new ManagedRelay(url, this.registry, (...a: any[]) => - this.log(`[relay:${url}]`, ...a), - ), - ); - } - - /** Start every relay's connect loop + (optionally) the sleep/wake heartbeat. */ - start(): void { - for (const r of this.relays) r.start(); - const interval = this.opts.heartbeatMs ?? 0; - if (interval > 0) { - this.lastHeartbeat = Date.now(); - this.heartbeatTimer = setInterval(() => this.runHeartbeat(), interval); - } - } - - stop(): void { - if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); - this.heartbeatTimer = undefined; - for (const r of this.relays) r.stop(); - } - - /** - * Register a subscription and establish it on all connected relays. The - * registry entry persists across reconnects (each ManagedRelay re-subscribes - * it on every connect), which is the whole point. Returns a handle whose - * `close()` removes it from the registry so it stops being replayed. - */ - subscribe( - filters: Filter[], - onevent: (event: Event) => void, - opts: { id?: string; oneose?: () => void } = {}, - ): { id: string; close: () => void } { - const id = opts.id ?? `sub-${++this.counter}`; - const s: PoolSubscription = { id, filters, onevent, oneose: opts.oneose }; - this.registry.set(id, s); - for (const r of this.relays) r.subscribeOne(id, s); - return { - id, - close: () => { - this.registry.delete(id); - for (const r of this.relays) r.closeSub(id); - }, - }; - } - - /** - * Subscribe and resolve once the FIRST EOSE arrives from any relay (the #9 - * race guard: callers must not publish before the relay has the REQ on - * file). Re-EOSEs on later reconnects are ignored. - */ - subscribeAwaitingEose( - filters: Filter[], - onevent: (event: Event) => void, - opts: { id?: string } = {}, - ): Promise<{ id: string; close: () => void }> { - return new Promise((resolve) => { - let eosed = false; - const handle = this.subscribe(filters, onevent, { - id: opts.id, - oneose: () => { - if (!eosed) { - eosed = true; - resolve(handle); - } - }, - }); - }); - } - - /** - * Publish to every relay; resolves once at least one accepts it. Retries - * across a reconnect window: a publish that lands while a relay is mid-flap - * rejects ("relay connection errored"/"relay not connected"), so we wait for - * the pool to recover and try again. Safe to retry — relays dedupe by event - * id and a NIP-46 client matches the response by request id. Bounded so a - * genuinely-down relay set still surfaces an error rather than hanging. - */ - async publish(event: Event, opts: { retries?: number; retryDelayMs?: number } = {}): Promise { - const retries = opts.retries ?? 4; - const retryDelayMs = opts.retryDelayMs ?? 300; - let lastReasons = ""; - for (let attempt = 0; attempt <= retries; attempt++) { - const results = await Promise.allSettled(this.relays.map((r) => r.publish(event))); - if (results.some((r) => r.status === "fulfilled")) return; - lastReasons = results - .map((r) => (r.status === "rejected" ? r.reason?.message ?? r.reason : "")) - .filter(Boolean) - .join("; "); - if (attempt < retries) { - await new Promise((r) => setTimeout(r, retryDelayMs)); - } - } - throw new Error(`publish failed on all relays after ${retries + 1} attempts: ${lastReasons}`); - } - - /** Relays currently holding an open socket. */ - connectedCount(): number { - return this.relays.filter((r) => r.connected).length; - } - - /** - * Session-liveness, not just socket-liveness. Healthy iff at least one relay - * is connected AND has every registered subscription active on the wire. - * This is the check the old watchdog couldn't make: after #20 a reconnected - * socket looked healthy while the subscription set was empty (#41). Here a - * connected relay that hasn't (re)subscribed the registry reads as unhealthy. - */ - healthy(): boolean { - const want = this.registry.size; - return this.relays.some((r) => r.connected && r.activeCount() >= want); - } - - private runHeartbeat(): void { - const now = Date.now(); - const elapsed = now - this.lastHeartbeat; - this.lastHeartbeat = now; - const interval = this.opts.heartbeatMs ?? 0; - const slack = this.opts.sleepSlackMs ?? 30_000; - // A tick far later than scheduled means the process was suspended - // (laptop sleep, VM pause); sockets may be half-open but look alive. - // Force a clean reconnect so subscriptions are re-established. (signet) - if (interval > 0 && elapsed > interval + slack) { - this.log( - `heartbeat: ${Math.round(elapsed / 1000)}s gap (expected ~${Math.round( - interval / 1000, - )}s) — suspected sleep/wake, forcing reconnect`, - ); - for (const r of this.relays) r.forceReconnect(); - } - } -} diff --git a/src/daemon/lib/relay-reconnect.ts b/src/daemon/lib/relay-reconnect.ts new file mode 100644 index 0000000..e3fdb4f --- /dev/null +++ b/src/daemon/lib/relay-reconnect.ts @@ -0,0 +1,101 @@ +import NDK from "@nostr-dev-kit/ndk"; + +/** + * Attaches an aggressive-reconnect supervisor to an NDK instance. + * + * NDK 3.x's per-relay connectivity state machine gives up retrying after + * a few consecutive fast-fail (e.g. ECONNREFUSED returns in <1 ms) + * connection attempts: + * + * 1. Each attempt's duration is recorded in `_connectionStats.durations`. + * 2. After every 3 attempts, `isFlapping()` checks the std-dev of those + * durations against `FLAPPING_THRESHOLD_MS` (1 second). Three fast + * failures look identical → tiny std-dev → flapping=true → status + * transitions to FLAPPING and the per-relay retry stops. + * 3. `NDKPool.handleFlapping` catches the event and reschedules a + * reconnect via doubling backoff (5s → 10s → 20s → 40s → 80s …), + * growing unbounded. + * + * For nsecbunkerd, where the admin relay is typically a single relay we + * **must** stay subscribed to, "disconnected for 80+s after every dev + * restart" is the failure mode users hit (aiolabs/nsecbunkerd#20). The + * pool's doubling backoff is too pessimistic for our use case. + * + * This helper sidesteps the give-up path: when the pool emits `flapping` + * (the symptom that NDK has internally given up), or when we see the + * relay disconnect outside our own request, we manually call + * `relay.connect()` with a SHORT capped delay. Successful connect resets + * the attempt counter so a future disconnect storm doesn't grow the + * delay. + * + * Trade-off: we may hammer a permanently-down relay every 10s. That's + * fine for a bunker — being disconnected silently is strictly worse than + * a retry storm against localhost. Acceptable because: + * - The bunker's primary relay is typically on the same host or LAN + * (`ws://lnbits:5001/...`); TCP RSTs are cheap. + * - Public-relay setups can layer external supervision on top if they + * care about retry pressure. + */ +export function attachIndefiniteReconnect(ndk: NDK, label: string): void { + const RECONNECT_BASE_MS = 1_000; + const RECONNECT_CAP_MS = 10_000; + + const attempts = new Map(); + const pending = new Map(); + + const reconnectDelay = (n: number): number => + Math.min(RECONNECT_BASE_MS * 2 ** n, RECONNECT_CAP_MS); + + const scheduleReconnect = (relay: any): void => { + const url: string = relay.url; + if (pending.has(url)) return; + const n = attempts.get(url) ?? 0; + const delay = reconnectDelay(n); + console.log( + `🔁 ${label}: scheduling reconnect to ${url} in ${delay}ms ` + + `(attempt ${n + 1}, overriding NDK give-up)` + ); + const timer = setTimeout(() => { + pending.delete(url); + attempts.set(url, n + 1); + relay.connect().catch((e: any) => { + console.log( + `❌ ${label}: manual reconnect to ${url} failed: ` + + `${e?.message ?? e}` + ); + // Don't recurse here — the next 'flapping' or 'disconnect' + // event will fire and schedule another attempt. + }); + }, delay); + pending.set(url, timer); + }; + + ndk.pool.on("flapping", (relay: any) => { + console.log( + `⚠️ ${label}: NDK flagged ${relay.url} as flapping ` + + `(connectivity machine gave up internally)` + ); + scheduleReconnect(relay); + }); + + ndk.pool.on("relay:disconnect", (relay: any) => { + scheduleReconnect(relay); + }); + + ndk.pool.on("relay:connect", (relay: any) => { + const url: string = relay.url; + const n = attempts.get(url) ?? 0; + if (n > 0) { + console.log( + `✅ ${label}: recovered ${url} after ${n} manual reconnect ` + + `attempt(s)` + ); + } + attempts.delete(url); + const timer = pending.get(url); + if (timer) { + clearTimeout(timer); + pending.delete(url); + } + }); +} diff --git a/src/daemon/nip46/transport.ts b/src/daemon/nip46/transport.ts deleted file mode 100644 index b787cee..0000000 --- a/src/daemon/nip46/transport.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { finalizeEvent, verifyEvent, getPublicKey, nip04, nip44, nip19 } from "nostr-tools"; -import type { Event } from "nostr-tools"; -import type { RelayPool } from "../lib/relay-pool.js"; -import type { Nip46Request } from "./types.js"; - -const NIP46_KIND = 24133; - -/** nsec1… or 64-hex → 32-byte secret key. */ -export function secretKeyBytes(key: string): Uint8Array { - if (key.startsWith("nsec1")) { - const { type, data } = nip19.decode(key); - if (type !== "nsec") throw new Error("not an nsec"); - return data as Uint8Array; - } - const bytes = new Uint8Array(key.length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(key.substring(i * 2, i * 2 + 2), 16); - } - return bytes; -} - -/** - * Nip46Transport — the NIP-46 RPC wire layer over a {@link RelayPool}, - * replacing NDK's `NDKNostrRpc` (aiolabs/nsecbunkerd#42). It owns exactly the - * crypto + framing NDK did, so existing clients (lnbits, the spire) keep - * working byte-for-byte: - * - * - inbound: verify the kind:24133 signature, decrypt the content (nip04 if it - * carries `?iv=`, else nip44 — falling back to the other on failure, the same - * adaptive scheme as `NDKNostrRpc.parseEvent`), JSON-parse `{id, method, - * params}`. - * - outbound: JSON `{id, result, error?}`, encrypted to the client with the - * SAME scheme the request used, signed as the held key, published kind:24133 - * `#p`-tagged to the client. - * - * The held key never leaves this object; it signs/encrypts only. - */ -export class Nip46Transport { - public readonly pubkey: string; - private sub: { close: () => void } | null = null; - /** Callbacks awaiting a response to one of our outbound sendRequest()s, by - * request id (the admin approval flow). */ - private pending = new Map void>(); - - constructor( - private readonly sk: Uint8Array, - private readonly pool: RelayPool, - private readonly log: (...args: any[]) => void = () => {}, - ) { - this.pubkey = getPublicKey(sk); - } - - /** Start listening for this key's requests on the given kinds (the signer - * channel is [24133]; the admin channel is [24133, 24134]). Resolves once - * the subscription's first EOSE lands (the #9 start-race guard). */ - async start(onRequest: (req: Nip46Request) => void, kinds: number[] = [NIP46_KIND]): Promise { - this.sub = await this.pool.subscribeAwaitingEose( - [{ kinds, "#p": [this.pubkey] }], - (event: Event) => this.onEvent(event, onRequest), - { id: `nip46:${this.pubkey}` }, - ); - } - - stop(): void { - this.sub?.close(); - this.sub = null; - } - - private onEvent(event: Event, onRequest: (req: Nip46Request) => void): void { - const env = this.parseEnvelope(event); - if (!env) return; - const { body, encryption, remotePubkey, kind } = env; - if (body.method) { - onRequest({ - id: body.id, - method: body.method, - params: body.params ?? [], - remotePubkey, - encryption, - kind, - }); - } else if (body.id !== undefined) { - // a response to one of our outbound requests (admin approval flow) - this.pending.get(body.id)?.({ id: body.id, result: body.result, error: body.error }); - } - } - - /** Verify + decrypt an inbound event into its JSON body + envelope metadata, - * or null if unreadable. Adaptive nip04/nip44 like NDKNostrRpc.parseEvent. */ - private parseEnvelope( - event: Event, - ): { body: any; encryption: "nip04" | "nip44"; remotePubkey: string; kind: number } | null { - if (!verifyEvent(event)) { - this.log("dropping event with invalid signature", event.id); - return null; - } - const remotePubkey = event.pubkey; - // nip04 ciphertext carries a `?iv=`; nip44 does not. - let encryption: "nip04" | "nip44" = event.content.includes("?iv=") ? "nip04" : "nip44"; - let decrypted: string; - try { - decrypted = this.decrypt(remotePubkey, event.content, encryption); - } catch { - encryption = encryption === "nip04" ? "nip44" : "nip04"; - try { - decrypted = this.decrypt(remotePubkey, event.content, encryption); - } catch (e) { - this.log("failed to decrypt event", e); - return null; - } - } - try { - return { body: JSON.parse(decrypted), encryption, remotePubkey, kind: event.kind }; - } catch { - this.log("event content was not JSON"); - return null; - } - } - - /** - * Send a NIP-46 request to a peer and register a one-shot response handler - * (the admin approval flow: bunker -> operator "acl" request). Returns the - * request id so the caller can `clearPending(id)` on timeout. - */ - sendRequest( - remotePubkey: string, - method: string, - params: string[], - encryption: "nip04" | "nip44", - kind: number, - cb: (res: { id: string; result: string; error?: string }) => void, - ): string { - const id = Math.random().toString(36).substring(2, 12); - this.pending.set(id, cb); - const content = this.encrypt(remotePubkey, JSON.stringify({ id, method, params }), encryption); - const event = finalizeEvent( - { kind, created_at: Math.floor(Date.now() / 1000), tags: [["p", remotePubkey]], content }, - this.sk, - ); - void this.pool.publish(event); - return id; - } - - clearPending(id: string): void { - this.pending.delete(id); - } - - /** Encrypt + sign + publish a NIP-46 response, matching the request's scheme. - * `kind` defaults to the signer channel (24133); the admin RPC passes its - * own response kind (24134) or mirrors the request kind for errors. */ - async sendResponse( - id: string, - remotePubkey: string, - result: string, - encryption: "nip04" | "nip44", - error?: string, - kind: number = NIP46_KIND, - ): Promise { - const payload: { id: string; result: string; error?: string } = { id, result }; - if (error) payload.error = error; - const content = this.encrypt(remotePubkey, JSON.stringify(payload), encryption); - const event = finalizeEvent( - { - kind, - created_at: Math.floor(Date.now() / 1000), - tags: [["p", remotePubkey]], - content, - }, - this.sk, - ); - await this.pool.publish(event); - } - - private encrypt(peerPubkey: string, plaintext: string, scheme: "nip04" | "nip44"): string { - if (scheme === "nip04") { - return nip04.encrypt(this.sk, peerPubkey, plaintext); - } - const convKey = nip44.getConversationKey(this.sk, peerPubkey); - return nip44.encrypt(plaintext, convKey); - } - - private decrypt(peerPubkey: string, ciphertext: string, scheme: "nip04" | "nip44"): string { - if (scheme === "nip04") { - return nip04.decrypt(this.sk, peerPubkey, ciphertext); - } - const convKey = nip44.getConversationKey(this.sk, peerPubkey); - return nip44.decrypt(ciphertext, convKey); - } - - /** Encrypt an arbitrary payload to a recipient (the nip04/44_encrypt method - * signs/encrypts on the client's behalf, as the held key). */ - encryptTo(recipientPubkey: string, payload: string, scheme: "nip04" | "nip44"): string { - return this.encrypt(recipientPubkey, payload, scheme); - } - - /** Decrypt a payload from a counterparty (the nip04/44_decrypt method). */ - decryptFrom(senderPubkey: string, ciphertext: string, scheme: "nip04" | "nip44"): string { - return this.decrypt(senderPubkey, ciphertext, scheme); - } - - /** Sign an event template as the held key (the sign_event method). */ - sign(template: { kind: number; created_at: number; tags: string[][]; content: string }): Event { - return finalizeEvent(template, this.sk); - } -} diff --git a/src/daemon/nip46/types.ts b/src/daemon/nip46/types.ts deleted file mode 100644 index 33e36c7..0000000 --- a/src/daemon/nip46/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Local NIP-46 types (aiolabs/nsecbunkerd#42). - * - * These replace the identically-named types we used to import from - * `@nostr-dev-kit/ndk`, so the daemon's signing path no longer depends on NDK. - * Kept structurally identical to NDK's so the ACL callback - * (`signingAuthorizationCallback`) and the admin layer don't have to change. - */ - -export type NIP46Method = - | "connect" - | "sign_event" - | "nip04_encrypt" - | "nip04_decrypt" - | "nip44_encrypt" - | "nip44_decrypt" - | "get_public_key" - | "ping"; - -export interface Nip46PermitCallbackParams { - /** Request id. */ - id: string; - /** The connected client's pubkey (hex). */ - pubkey: string; - /** The NIP-46 method being requested. */ - method: NIP46Method; - /** - * The method's payload. For `sign_event` it's the parsed event object (the - * ACL only reads `.kind`); for the encrypt/decrypt methods it's the payload - * string; for `connect` it's the token; otherwise undefined. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - params?: any; -} - -export type Nip46PermitCallback = (params: Nip46PermitCallbackParams) => Promise; - -/** Hook to redeem a connection token (the bunker's `applyToken`). */ -export type Nip46ApplyTokenCallback = (pubkey: string, token: string) => Promise; - -/** A decrypted, verified inbound NIP-46 request. */ -export interface Nip46Request { - id: string; - method: string; - params: string[]; - /** The verified sender (client) pubkey, hex. */ - remotePubkey: string; - /** Which envelope encryption the client used; the response must match it. */ - encryption: "nip04" | "nip44"; - /** The kind of the inbound event (24133 signer / 24134 admin) — lets a - * handler mirror the request's channel when responding. */ - kind: number; -} diff --git a/src/daemon/run.ts b/src/daemon/run.ts index 3ba08a0..738dc77 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -1,13 +1,10 @@ -import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; +import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk'; import { nip19, utils as nostrUtils } from 'nostr-tools'; import { Backend } from './backend/index.js'; -import { applyToken } from './backend/token-store.js'; -import { RelayPool } from './lib/relay-pool.js'; -import type { Nip46PermitCallback, Nip46PermitCallbackParams } from './nip46/types.js'; import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js'; import AdminInterface from './admin/index.js'; import { IConfig } from '../config/index.js'; -import type { AdminRpcRequest } from './admin/types.js'; +import { NDKRpcRequest } from '@nostr-dev-kit/ndk'; import prisma from '../db.js'; import { DaemonConfig } from './index.js'; import { decryptNsec } from '../config/keys.js'; @@ -18,6 +15,7 @@ import FastifyView from '@fastify/view'; import Handlebars from "handlebars"; import {authorizeRequestWebHandler, processRequestWebHandler} from "./web/authorize.js"; import {processRegistrationWebHandler} from "./web/authorize.js"; +import { attachIndefiniteReconnect } from "./lib/relay-reconnect.js"; export type Key = { name: string; @@ -59,7 +57,7 @@ function getKeys(config: DaemonConfig) { } function getKeyUsers(config: IConfig) { - return async (req: AdminRpcRequest): Promise => { + return async (req: NDKRpcRequest): Promise => { const keyUsers: KeyUser[] = []; const keyName = req.params[0]; @@ -153,7 +151,7 @@ class Daemon { private config: DaemonConfig; private activeKeys: Record; private adminInterface: AdminInterface; - private pool: RelayPool; + private ndk: NDK; public fastify: FastifyInstance; constructor(config: DaemonConfig) { @@ -169,15 +167,21 @@ class Daemon { this.fastify = Fastify({ logger: true }); this.fastify.register(FastifyFormBody); - // Backend transport. The RelayPool owns its reconnect loop and - // re-subscribes every held key's kind:24133 channel on every reconnect, - // so the bunker can't go deaf after a relay flap (#41/#42) — the failure - // the old NDK transport + attachIndefiniteReconnect (#20) couldn't close. - // The heartbeat adds sleep/wake (time-jump) recovery. - this.pool = new RelayPool(config.nostr.relays, { - log: (...a: any[]) => console.log(...a), - heartbeatMs: 30_000, + this.ndk = new NDK({ + explicitRelayUrls: config.nostr.relays, }); + this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) ); + this.ndk.pool.on('notice', (r, n) => { console.log(`👀 Notice from ${r.url}`, n); }); + + this.ndk.pool.on('relay:disconnect', (r) => { + console.log(`🚫 Disconnected from ${r.url}`); + }); + + // Override NDK's "give up after detecting flapping" behavior so the + // bunker's backend NDK keeps trying to reconnect indefinitely. + // Without this, an ECONNREFUSED storm at boot (relay not yet up) + // permanently strands the bunker. See aiolabs/nsecbunkerd#20. + attachIndefiniteReconnect(this.ndk, 'backend'); } async startWebAuth() { @@ -341,7 +345,7 @@ class Daemon { } async start() { - this.pool.start(); + await this.ndk.connect(5000); await this.startWebAuth(); await this.startKeys(); @@ -361,14 +365,7 @@ class Daemon { // passing it here"). The bech32-decode workaround for #8 was // tied to NDK 2.8.1's old constructor behavior and is no // longer needed post-#14 NDK bump. - const backend = new Backend({ - pool: this.pool, - fastify: this.fastify, - nsec, - permitCallback: cb, - applyToken, - baseUrl: this.config.baseUrl, - }); + const backend = new Backend(this.ndk, this.fastify, nsec, cb, this.config.baseUrl); await backend.start(); } diff --git a/tests/admin-transport.test.ts b/tests/admin-transport.test.ts deleted file mode 100644 index 88536e3..0000000 --- a/tests/admin-transport.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { generateSecretKey, getPublicKey, finalizeEvent, nip44, type Event } from "nostr-tools"; -import { MockRelay } from "./helpers/mock-relay"; -import { RelayPool } from "../src/daemon/lib/relay-pool"; -import { Nip46Transport } from "../src/daemon/nip46/transport"; - -/** - * The admin interface adds two things to the transport over the signer backend - * (#42): it listens on TWO kinds (24133 client + 24134 admin) and replies on a - * chosen kind, and it can *send* requests + match responses (the interactive - * approval flow, `rpc.sendRequest`). This exercises both directions over the - * pool and across a relay flap. - */ - -const NOSTR_CONNECT = 24133; -const ADMIN_RESPONSE = 24134; - -async function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 25): Promise { - const start = Date.now(); - while (!pred()) { - if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); - await new Promise((r) => setTimeout(r, stepMs)); - } -} - -/** Raw NIP-46 client: send a request on `kind`, read the response on either - * channel addressed back to us. nip44 envelope. */ -class RawClient { - readonly sk = generateSecretKey(); - readonly pubkey = getPublicKey(this.sk); - private pending = new Map void>(); - private n = 0; - - constructor(private readonly pool: RelayPool, private readonly peer: string) { - this.pool.subscribe([{ kinds: [NOSTR_CONNECT, ADMIN_RESPONSE], "#p": [this.pubkey] }], (e: Event) => { - const m = JSON.parse(nip44.decrypt(e.content, nip44.getConversationKey(this.sk, e.pubkey))); - if (m.method) return; // we only consume responses here - this.pending.get(m.id)?.({ result: m.result, error: m.error }); - }); - } - - request(method: string, params: string[] = [], kind = NOSTR_CONNECT, timeoutMs = 5000) { - const id = `c${++this.n}`; - const ck = nip44.getConversationKey(this.sk, this.peer); - const ev = finalizeEvent( - { kind, created_at: Math.floor(Date.now() / 1000), tags: [["p", this.peer]], content: nip44.encrypt(JSON.stringify({ id, method, params }), ck) }, - this.sk, - ); - return new Promise<{ result: string; error?: string }>((res, rej) => { - const t = setTimeout(() => rej(new Error(`${method} timed out`)), timeoutMs); - this.pending.set(id, (r) => { clearTimeout(t); res(r); }); - void this.pool.publish(ev); - }); - } -} - -test("admin transport: request on 24133 -> reply on 24134, survives a flap (#42)", async () => { - const relay = new MockRelay(); - await relay.start(); - - const adminSk = generateSecretKey(); - const adminPubkey = getPublicKey(adminSk); - - const adminPool = new RelayPool([relay.url], { log: () => {} }); - adminPool.start(); - const admin = new Nip46Transport(adminSk, adminPool); - // Echo handler: reply "ok" on the admin channel (24134), like the ping cmd. - await admin.start((req) => { - void admin.sendResponse(req.id, req.remotePubkey, "ok", req.encryption, undefined, ADMIN_RESPONSE); - }, [NOSTR_CONNECT, ADMIN_RESPONSE]); - - const clientPool = new RelayPool([relay.url], { log: () => {} }); - clientPool.start(); - await waitFor(() => clientPool.connectedCount() === 1 && adminPool.healthy()); - const client = new RawClient(clientPool, adminPubkey); - - assert.equal((await client.request("ping")).result, "ok"); - - await relay.flap(); - await waitFor(() => adminPool.healthy() && clientPool.healthy()); - assert.equal((await client.request("ping")).result, "ok", "admin still serving after flap"); - - admin.stop(); - adminPool.stop(); - clientPool.stop(); - await relay.stop(); -}); - -test("admin transport: sendRequest + response routing (the approval flow) (#42)", async () => { - const relay = new MockRelay(); - await relay.start(); - - // "admin" (bunker) and "operator" (the human's client) — each a transport. - const adminSk = generateSecretKey(); - const operatorSk = generateSecretKey(); - const operatorPubkey = getPublicKey(operatorSk); - - const adminPool = new RelayPool([relay.url], { log: () => {} }); - adminPool.start(); - const admin = new Nip46Transport(adminSk, adminPool); - await admin.start(() => {}, [NOSTR_CONNECT, ADMIN_RESPONSE]); - - const opPool = new RelayPool([relay.url], { log: () => {} }); - opPool.start(); - const operator = new Nip46Transport(operatorSk, opPool); - // The operator auto-approves any "acl" request it receives. - await operator.start((req) => { - if (req.method === "acl") { - void operator.sendResponse(req.id, req.remotePubkey, JSON.stringify(["always"]), req.encryption, undefined, ADMIN_RESPONSE); - } - }, [NOSTR_CONNECT, ADMIN_RESPONSE]); - - await waitFor(() => adminPool.healthy() && opPool.healthy()); - - // Admin asks the operator to approve; expects the routed response. - const approved = await new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error("approval timed out")), 5000); - const id = admin.sendRequest(operatorPubkey, "acl", ["{}"], "nip44", ADMIN_RESPONSE, (res) => { - admin.clearPending(id); - clearTimeout(t); - resolve(res.result); - }); - }); - - assert.equal(JSON.parse(approved)[0], "always", "operator's approval routed back to the admin"); - - admin.stop(); - operator.stop(); - adminPool.stop(); - opPool.stop(); - await relay.stop(); -}); diff --git a/tests/helpers/mock-relay.ts b/tests/helpers/mock-relay.ts deleted file mode 100644 index 6689c68..0000000 --- a/tests/helpers/mock-relay.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { AddressInfo } from "net"; - -// `ws` has no @types in this tree; require it (any) — same as src/relay-pool.ts. -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { WebSocketServer, WebSocket } = require("ws"); - -/** - * Minimal in-process nostr relay for transport tests (#42). Speaks just enough - * of the protocol — REQ / CLOSE / EVENT → EVENT / EOSE / OK — to exercise the - * RelayPool's subscribe + resubscribe-on-reconnect behaviour. It can be - * `flap()`ped: drop every client + close the server, then re-listen on the SAME - * port, simulating the relay restart that took the bunker deaf (#41). - */ - -type Filter = Record; -interface Sub { - subId: string; - filters: Filter[]; - socket: any; -} - -function matches(filter: Filter, event: any): boolean { - if (filter.ids && !filter.ids.includes(event.id)) return false; - if (filter.kinds && !filter.kinds.includes(event.kind)) return false; - if (filter.authors && !filter.authors.includes(event.pubkey)) return false; - for (const key of Object.keys(filter)) { - if (key.startsWith("#")) { - const tag = key.slice(1); - const want: string[] = filter[key]; - const have = (event.tags ?? []) - .filter((t: string[]) => t[0] === tag) - .map((t: string[]) => t[1]); - if (!want.some((v) => have.includes(v))) return false; - } - } - return true; -} - -export class MockRelay { - private wss: any = null; - private subs: Sub[] = []; - private sockets: Set = new Set(); - public port = 0; - /** Total REQs seen across the relay's life — proves a (re)subscribe landed. */ - public reqCount = 0; - - get url(): string { - return `ws://127.0.0.1:${this.port}`; - } - - async start(port = 0): Promise { - await new Promise((resolve) => { - this.wss = new WebSocketServer({ port }, () => { - this.port = (this.wss!.address() as AddressInfo).port; - resolve(); - }); - this.wss.on("connection", (socket: any) => { - this.sockets.add(socket); - socket.on("message", (data: any) => this.onMessage(socket, data.toString())); - socket.on("close", () => { - this.sockets.delete(socket); - this.subs = this.subs.filter((s) => s.socket !== socket); - }); - socket.on("error", () => { - /* ignore — flapping closes sockets abruptly */ - }); - }); - }); - } - - private onMessage(socket: any, raw: string): void { - let msg: any[]; - try { - msg = JSON.parse(raw); - } catch { - return; - } - const [type, ...rest] = msg; - if (type === "REQ") { - const [subId, ...filters] = rest; - this.reqCount++; - this.subs.push({ subId, filters, socket }); - // No stored events in this mock; just acknowledge the REQ is live. - socket.send(JSON.stringify(["EOSE", subId])); - } else if (type === "CLOSE") { - const [subId] = rest; - this.subs = this.subs.filter((s) => !(s.socket === socket && s.subId === subId)); - } else if (type === "EVENT") { - const [event] = rest; - socket.send(JSON.stringify(["OK", event.id, true, ""])); - this.deliver(event); - } - } - - /** Push a server-originated event to every matching live subscription. */ - inject(event: any): void { - this.deliver(event); - } - - private deliver(event: any): void { - for (const sub of this.subs) { - if (sub.socket.readyState !== WebSocket.OPEN) continue; - if (sub.filters.some((f) => matches(f, event))) { - sub.socket.send(JSON.stringify(["EVENT", sub.subId, event])); - } - } - } - - /** Drop all clients + close the server, keeping the port for a restart. */ - private async down(): Promise { - for (const s of this.sockets) { - try { - s.terminate(); - } catch { - /* ignore */ - } - } - this.sockets.clear(); - this.subs = []; - await new Promise((resolve) => { - if (!this.wss) return resolve(); - this.wss.close(() => resolve()); - this.wss = null; - }); - } - - /** Simulate a relay restart: go down, then come back on the SAME port. */ - async flap(): Promise { - const port = this.port; - await this.down(); - await this.start(port); - } - - async stop(): Promise { - await this.down(); - } -} diff --git a/tests/nip46-backend.test.ts b/tests/nip46-backend.test.ts deleted file mode 100644 index 5116567..0000000 --- a/tests/nip46-backend.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - finalizeEvent, - verifyEvent, - generateSecretKey, - getPublicKey, - nip44, - type Event, -} from "nostr-tools"; -import { MockRelay } from "./helpers/mock-relay"; -import { RelayPool } from "../src/daemon/lib/relay-pool"; -import { Backend } from "../src/daemon/backend/index"; - -/** - * End-to-end NIP-46 round-trip against the ported Backend (#42): a real - * nostr-tools client sends connect / ping / get_public_key / sign_event / - * nip44_encrypt through a mock relay; we assert the responses, then FLAP the - * relay and assert the backend still answers — proving the whole signing - * protocol survives a reconnect, which it never did on the NDK transport (#41). - */ - -const NIP46_KIND = 24133; - -/** Minimal NIP-46 client: publishes requests to the bunker pubkey, decrypts the - * responses addressed back to it. nip44 envelope (the modern default). */ -class TestClient { - readonly sk = generateSecretKey(); - readonly pubkey = getPublicKey(this.sk); - private pending = new Map void>(); - private n = 0; - - constructor( - private readonly pool: RelayPool, - private readonly bunkerPubkey: string, - ) { - this.pool.subscribe([{ kinds: [NIP46_KIND], "#p": [this.pubkey] }], (e: Event) => { - const ck = nip44.getConversationKey(this.sk, e.pubkey); - const msg = JSON.parse(nip44.decrypt(e.content, ck)); - this.pending.get(msg.id)?.({ result: msg.result, error: msg.error }); - }); - } - - request(method: string, params: string[] = [], timeoutMs = 5000): Promise<{ result: string; error?: string }> { - const id = `req-${++this.n}`; - const ck = nip44.getConversationKey(this.sk, this.bunkerPubkey); - const content = nip44.encrypt(JSON.stringify({ id, method, params }), ck); - const event = finalizeEvent( - { kind: NIP46_KIND, created_at: Math.floor(Date.now() / 1000), tags: [["p", this.bunkerPubkey]], content }, - this.sk, - ); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`${method} timed out`)), timeoutMs); - this.pending.set(id, (r) => { - clearTimeout(timer); - resolve(r); - }); - void this.pool.publish(event); - }); - } -} - -async function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 25): Promise { - const start = Date.now(); - while (!pred()) { - if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); - await new Promise((r) => setTimeout(r, stepMs)); - } -} - -test("Backend serves NIP-46 over the pool and survives a relay flap (#42)", async () => { - const relay = new MockRelay(); - await relay.start(); - - // The held key the bunker signs as. - const bunkerSk = generateSecretKey(); - const bunkerPubkey = getPublicKey(bunkerSk); - const bunkerNsec = Buffer.from(bunkerSk).toString("hex"); - - let allow = true; - const seen: string[] = []; - const bunkerPool = new RelayPool([relay.url], { log: () => {} }); - bunkerPool.start(); - const backend = new Backend({ - pool: bunkerPool, - nsec: bunkerNsec, - permitCallback: async (p) => { - seen.push(p.method); - return allow; - }, - applyToken: async () => { - /* no DB in this test */ - }, - }); - await backend.start(); - - const clientPool = new RelayPool([relay.url], { log: () => {} }); - clientPool.start(); - await waitFor(() => clientPool.connectedCount() === 1 && bunkerPool.healthy()); - const client = new TestClient(clientPool, bunkerPubkey); - - // connect -> ack - assert.equal((await client.request("connect", ["", ""])).result, "ack"); - // ping -> pong - assert.equal((await client.request("ping")).result, "pong"); - // get_public_key -> the bunker pubkey - assert.equal((await client.request("get_public_key")).result, bunkerPubkey); - - // sign_event -> a valid event signed AS the bunker key - const tmpl = { kind: 1, created_at: Math.floor(Date.now() / 1000), tags: [], content: "hi from atm" }; - const signRes = await client.request("sign_event", [JSON.stringify(tmpl)]); - const signed = JSON.parse(signRes.result) as Event; - assert.equal(signed.pubkey, bunkerPubkey, "signed as the held key"); - assert.equal(signed.kind, 1); - assert.ok(verifyEvent(signed), "signature valid"); - - // nip44_encrypt(clientPubkey, payload) -> ciphertext from bunker to client - const enc = await client.request("nip44_encrypt", [client.pubkey, "secret-payload"]); - const ck = nip44.getConversationKey(client.sk, bunkerPubkey); - assert.equal(nip44.decrypt(enc.result, ck), "secret-payload", "nip44_encrypt round-trips"); - - // FLAP the relay, then assert the backend still answers. - await relay.flap(); - await waitFor(() => bunkerPool.healthy() && clientPool.healthy()); - assert.equal((await client.request("ping")).result, "pong", "still serving after flap"); - - // Rejection path: permit returns false -> "Not authorized". - allow = false; - const denied = await client.request("ping"); - assert.equal(denied.result, "error"); - assert.equal(denied.error, "Not authorized"); - - assert.ok(seen.includes("sign_event") && seen.includes("connect"), "permit callback was consulted"); - - bunkerPool.stop(); - clientPool.stop(); - await relay.stop(); -}); diff --git a/tests/relay-pool.test.ts b/tests/relay-pool.test.ts deleted file mode 100644 index 148f144..0000000 --- a/tests/relay-pool.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; -import { MockRelay } from "./helpers/mock-relay"; -import { RelayPool } from "../src/daemon/lib/relay-pool"; - -// A throwaway client key. Events must be REAL (valid id + sig): nostr-tools -// verifies inbound events and silently drops invalid ones, so the test has to -// sign them exactly as a real bunker client would. -const CLIENT_SK = generateSecretKey(); -// The spire pubkey the bunker subscribes for (`#p`), a fixture here. -const PUBKEY = "1508b42094e65dff982ac8ca5a264089f7de2d4bbda81bf32a91678f337ced3b"; - -function makeEvent(): { id: string; [k: string]: any } { - return finalizeEvent( - { - kind: 24133, - created_at: Math.floor(Date.now() / 1000), - tags: [["p", PUBKEY]], - content: "encrypted-blob", - }, - CLIENT_SK, - ) as any; -} - -void getPublicKey; // (kept available for future signed-response assertions) - -async function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 25): Promise { - const start = Date.now(); - while (!pred()) { - if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); - await new Promise((r) => setTimeout(r, stepMs)); - } -} - -/** - * The regression that was missing every prior round (#4/#7/#20/#21): flap the - * relay mid-session and assert a subsequent inbound kind:24133 is still - * delivered. With the old NDK transport the socket reconnected but the REQ was - * never re-sent, so the bunker went silently deaf (#41). The RelayPool owns the - * connect loop and re-subscribes on every (re)connect, so this must pass. - */ -test("RelayPool re-subscribes after a relay flap — inbound events still delivered (#41/#42)", async () => { - const relay = new MockRelay(); - await relay.start(); - - const received: string[] = []; - const pool = new RelayPool([relay.url], { log: () => {} }); - pool.start(); - - // Subscribe for the bunker's kind:24133 channel; await the REQ landing. - await pool.subscribeAwaitingEose([{ kinds: [24133], "#p": [PUBKEY] }], (e) => - received.push(e.id), - ); - - // Baseline: a matching event before any flap is delivered. - const before = makeEvent(); - relay.inject(before); - await waitFor(() => received.includes(before.id)); - - // FLAP: relay down + back up on the same port (the relay-restart that took - // the demo bunker deaf). The pool must reconnect AND re-subscribe. - const reqsBefore = relay.reqCount; - await relay.flap(); - - // Wait until reconnected AND the subscription is re-established on the wire. - await waitFor(() => pool.healthy() && relay.reqCount > reqsBefore); - - // THE assertion: a matching event AFTER the flap is still delivered. - const after = makeEvent(); - relay.inject(after); - await waitFor(() => received.includes(after.id)); - - assert.ok(received.includes(before.id), "event before flap delivered"); - assert.ok(received.includes(after.id), "event after flap delivered (resubscribed)"); - assert.ok(pool.healthy(), "pool healthy (connected + subscribed) after flap"); - - pool.stop(); - await relay.stop(); -}); - -/** - * healthy() must distinguish a connected-but-deaf relay (the #41 state the old - * connectedRelays()-only watchdog could not see) from a genuinely serving one. - */ -test("RelayPool.healthy() is false until the registry is subscribed on the wire (#42)", async () => { - const relay = new MockRelay(); - await relay.start(); - - const pool = new RelayPool([relay.url], { log: () => {} }); - pool.start(); - - // No subscriptions registered yet — but once connected with an empty - // registry, healthy() is trivially true (nothing to be deaf about). - await waitFor(() => pool.connectedCount() === 1); - assert.equal(pool.healthy(), true, "connected, empty registry -> healthy"); - - // Register a sub; healthy stays true once it lands. - await pool.subscribeAwaitingEose([{ kinds: [24133], "#p": [PUBKEY] }], () => {}); - assert.equal(pool.healthy(), true, "connected + subscribed -> healthy"); - - pool.stop(); - await relay.stop(); -});