feat(transport): port the admin RPC off NDK onto the relay pool (#42)
Some checks are pending
Docker image / build-and-push-image (push) Waiting to run

Third increment of the NDK -> nostr-tools transport swap. The admin interface's
runtime RPC now runs on the RelayPool transport, so the admin channel — like the
signer channel — re-subscribes on every relay reconnect and can't go silently
deaf after a flap (#41).

- nip46/transport.ts is now a full RPC: besides serving inbound requests it
  routes inbound RESPONSES to one-shot handlers (the pending map) and can
  sendRequest() — needed for the interactive approval flow (bunker -> operator
  "acl" request). start() takes the kinds to listen on; sendResponse() takes the
  response kind. The signer backend is unaffected (still one kind, response-only).
- admin/index.ts: rebuilt on RelayPool + Nip46Transport instead of NDK + NDKNostrRpc
  + attachIndefiniteReconnect. `rpc` is a small adapter the command handlers keep
  calling; it resolves each request's envelope scheme (nip04/nip44) by id and
  publishes on the admin channel (24134). requestPermission/Response use
  transport.sendRequest + nip19 instead of NDKNostrRpc + NDKUser. The
  connectedRelays()-only watchdog is replaced by a session-liveness one on
  pool.healthy() (connected AND subscribed) — the check the old one couldn't make
  (#20/#41).
- admin/types.ts: AdminRpcRequest / AdminRpc replace NDKRpcRequest / NDKNostrRpc.
  The ~16 command + validation handlers swap the import only (they use
  req.{id,pubkey,method,params,event.kind}); no logic change.
- admin/kinds.ts: plain numeric kinds (24133/24134), no NDKKind type dep.
- relay-reconnect.ts deleted — its job (reconnect) now lives in the pool, and its
  blind spot (no resubscribe) is exactly what #41 was.

Still on NDK (not transport, addressed separately): the one-shot boot DM
(notifyAdminsOnBoot, throwaway NDK over public relays), key generation in
create_new_key/create_account, the getKeys npub helper, and the standalone CLI
client (src/client.ts).

Tests (tests/admin-transport.test.ts): a request on 24133 is answered on 24134
and survives a relay flap; the sendRequest + response-routing approval flow round-
trips. lifecycle 7 / relay 2 / nip46 1 / admin 2 all green; daemon bundles clean;
zero new type errors.

Refs: #42, #41, #20, #7
This commit is contained in:
Padreug 2026-06-27 01:23:53 +02:00
commit a676d4fa98
25 changed files with 429 additions and 369 deletions

View file

@ -24,8 +24,9 @@
"test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/lifecycle.test.ts", "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: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: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: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:integration", "test:all": "npm run test && npm run test:relay && npm run test:nip46 && npm run test:admin && npm run test:integration",
"prisma:generate": "npx prisma generate", "prisma:generate": "npx prisma generate",
"prisma:migrate": "npx prisma migrate deploy", "prisma:migrate": "npx prisma migrate deploy",
"prisma:create": "npx prisma db push --preview-feature", "prisma:create": "npx prisma db push --preview-feature",

View file

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.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 * `rule.kind.toString()` storage and the override-layer convention. The
* `'all'` literal is honored at sign-time as a wildcard across kinds. * `'all'` literal is honored at sign-time as a wildcard across kinds.
*/ */
export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRequest) { export default async function addPolicyRule(admin: AdminInterface, req: AdminRpcRequest) {
const [ _payload ] = req.params as [ string ]; const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params"); if (!_payload) throw new Error("Invalid params");

View file

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.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 * checkIfPubkeyAllowed (step 3 vs step 4), so `allowed: false` here
* denies regardless of the policy. * denies regardless of the policy.
*/ */
export default async function addSigningCondition(admin: AdminInterface, req: NDKRpcRequest) { export default async function addSigningCondition(admin: AdminInterface, req: AdminRpcRequest) {
const [ _payload ] = req.params as [ string ]; const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params"); if (!_payload) throw new Error("Invalid params");

View file

@ -1,4 +1,5 @@
import { Hexpubkey, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk"; import { Hexpubkey, NDKPrivateKeySigner, NDKUserProfile } from "@nostr-dev-kit/ndk";
import { AdminRpcRequest } from "../types.js";
import AdminInterface from ".."; import AdminInterface from "..";
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { setupSkeletonProfile } from "../../lib/profile"; import { setupSkeletonProfile } from "../../lib/profile";
@ -69,7 +70,7 @@ const RESERVED_USERNAMES = [
"admin", "root", "_", "administrator", "__" "admin", "root", "_", "administrator", "__"
]; ];
async function validateUsername(username: string | undefined, domain: string, admin: AdminInterface, req: NDKRpcRequest) { async function validateUsername(username: string | undefined, domain: string, admin: AdminInterface, req: AdminRpcRequest) {
if (!username || username.length === 0) { if (!username || username.length === 0) {
// create a random username of 10 characters // create a random username of 10 characters
username = Math.random().toString(36).substring(2, 15); username = Math.random().toString(36).substring(2, 15);
@ -83,7 +84,7 @@ async function validateUsername(username: string | undefined, domain: string, ad
return username; return username;
} }
async function validateDomain(domain: string | undefined, admin: AdminInterface, req: NDKRpcRequest) { async function validateDomain(domain: string | undefined, admin: AdminInterface, req: AdminRpcRequest) {
const availableDomains = (await admin.config()).domains; const availableDomains = (await admin.config()).domains;
if (!availableDomains || Object.keys(availableDomains).length === 0) if (!availableDomains || Object.keys(availableDomains).length === 0)
@ -99,7 +100,7 @@ async function validateDomain(domain: string | undefined, admin: AdminInterface,
return domain; return domain;
} }
export default async function createAccount(admin: AdminInterface, req: NDKRpcRequest) { export default async function createAccount(admin: AdminInterface, req: AdminRpcRequest) {
let [ username, domain, email ] = req.params as [ string?, string?, string? ]; let [ username, domain, email ] = req.params as [ string?, string?, string? ];
try { try {
@ -143,7 +144,7 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
*/ */
export async function createAccountReal( export async function createAccountReal(
admin: AdminInterface, admin: AdminInterface,
req: NDKRpcRequest, req: AdminRpcRequest,
username: string, username: string,
domain: string, domain: string,
email?: string email?: string
@ -227,7 +228,7 @@ export async function createAccountReal(
} }
} }
async function grantPermissions(req: NDKRpcRequest, keyName: string) { async function grantPermissions(req: AdminRpcRequest, keyName: string) {
await allowAllRequestsFromKey(req.pubkey, keyName, "connect"); await allowAllRequestsFromKey(req.pubkey, keyName, "connect");
await allowAllRequestsFromKey(req.pubkey, keyName, "sign_event", undefined, undefined, { kind: 'all' }); await allowAllRequestsFromKey(req.pubkey, keyName, "sign_event", undefined, undefined, { kind: 'all' });
await allowAllRequestsFromKey(req.pubkey, keyName, "encrypt"); await allowAllRequestsFromKey(req.pubkey, keyName, "encrypt");

View file

@ -1,4 +1,5 @@
import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKPrivateKeySigner, type NostrEvent } from "@nostr-dev-kit/ndk";
import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import { saveEncrypted } from "../../../commands/add.js"; import { saveEncrypted } from "../../../commands/add.js";
@ -6,7 +7,7 @@ import { getCurrentConfig } from "../../../config/index.js";
import { decryptNsec } from "../../../config/keys.js"; import { decryptNsec } from "../../../config/keys.js";
import { setupSkeletonProfile } from "../../lib/profile.js"; import { setupSkeletonProfile } from "../../lib/profile.js";
export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) { export default async function createNewKey(admin: AdminInterface, req: AdminRpcRequest) {
const [ keyName, passphrase, _nsec ] = req.params as [ string, string, string? ]; const [ keyName, passphrase, _nsec ] = req.params as [ string, string, string? ];
if (!keyName || !passphrase) throw new Error("Invalid params"); if (!keyName || !passphrase) throw new Error("Invalid params");

View file

@ -1,9 +1,9 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js"; import prisma from "../../../db.js";
export default async function createNewPolicy(admin: AdminInterface, req: NDKRpcRequest) { export default async function createNewPolicy(admin: AdminInterface, req: AdminRpcRequest) {
const [ _policy ] = req.params as [ string ]; const [ _policy ] = req.params as [ string ];
if (!_policy) throw new Error("Invalid params"); if (!_policy) throw new Error("Invalid params");

View file

@ -1,9 +1,9 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js"; import prisma from "../../../db.js";
export default async function createNewToken(admin: AdminInterface, req: NDKRpcRequest) { export default async function createNewToken(admin: AdminInterface, req: AdminRpcRequest) {
const [ keyName, clientName, policyId, durationInHours ] = req.params as [ string, string, string, string? ]; const [ keyName, clientName, policyId, durationInHours ] = req.params as [ string, string, string, string? ];
if (!clientName || !policyId) throw new Error("Invalid params"); if (!clientName || !policyId) throw new Error("Invalid params");

View file

@ -1,7 +1,7 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
export default async function ping(admin: AdminInterface, req: NDKRpcRequest) { export default async function ping(admin: AdminInterface, req: AdminRpcRequest) {
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", NIP46_ADMIN_RESPONSE_KIND); return admin.rpc.sendResponse(req.id, req.pubkey, "ok", NIP46_ADMIN_RESPONSE_KIND);
} }

View file

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.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 * removes across instance versions can race. Adds are safe, removes
* are not. * are not.
*/ */
export default async function removePolicyRule(admin: AdminInterface, req: NDKRpcRequest) { export default async function removePolicyRule(admin: AdminInterface, req: AdminRpcRequest) {
const [ _payload ] = req.params as [ string ]; const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params"); if (!_payload) throw new Error("Invalid params");

View file

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js"; import prisma from "../../../db.js";
@ -10,7 +10,7 @@ import prisma from "../../../db.js";
* Param shape (JSON-stringified): * Param shape (JSON-stringified):
* { conditionId: number } * { conditionId: number }
*/ */
export default async function removeSigningCondition(admin: AdminInterface, req: NDKRpcRequest) { export default async function removeSigningCondition(admin: AdminInterface, req: AdminRpcRequest) {
const [ _payload ] = req.params as [ string ]; const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params"); if (!_payload) throw new Error("Invalid params");

View file

@ -1,9 +1,9 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js"; import prisma from "../../../db.js";
export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRequest) { export default async function renameKeyUser(admin: AdminInterface, req: AdminRpcRequest) {
const [ keyUserPubkey, name ] = req.params as [ string, string ]; const [ keyUserPubkey, name ] = req.params as [ string, string ];
if (!keyUserPubkey || !name) throw new Error("Invalid params"); if (!keyUserPubkey || !name) throw new Error("Invalid params");

View file

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.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 * bound to it continue to grant via their own policies. Use
* revoke_user for the binary "this user is gone" case. * revoke_user for the binary "this user is gone" case.
*/ */
export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequest) { export default async function revokeToken(admin: AdminInterface, req: AdminRpcRequest) {
const [ _payload ] = req.params as [ string ]; const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params"); if (!_payload) throw new Error("Invalid params");

View file

@ -1,9 +1,9 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js"; import prisma from "../../../db.js";
export default async function revokeUser(admin: AdminInterface, req: NDKRpcRequest) { export default async function revokeUser(admin: AdminInterface, req: AdminRpcRequest) {
const [ keyUserId ] = req.params as [ string ]; const [ keyUserId ] = req.params as [ string ];
if (!keyUserId) throw new Error("Invalid params"); if (!keyUserId) throw new Error("Invalid params");

View file

@ -1,8 +1,8 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
export default async function unlockKey(admin: AdminInterface, req: NDKRpcRequest) { export default async function unlockKey(admin: AdminInterface, req: AdminRpcRequest) {
const [ keyName, passphrase ] = req.params as [ string, string ]; const [ keyName, passphrase ] = req.params as [ string, string ];
if (!keyName || !passphrase) throw new Error("Invalid params"); if (!keyName || !passphrase) throw new Error("Invalid params");

View file

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js"; import prisma from "../../../db.js";
@ -17,7 +17,7 @@ import prisma from "../../../db.js";
* `expiresAt: null` explicitly clears the field; `expiresAt` absent * `expiresAt: null` explicitly clears the field; `expiresAt` absent
* from the patch leaves it alone. * from the patch leaves it alone.
*/ */
export default async function updatePolicy(admin: AdminInterface, req: NDKRpcRequest) { export default async function updatePolicy(admin: AdminInterface, req: AdminRpcRequest) {
const [ _payload ] = req.params as [ string ]; const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params"); if (!_payload) throw new Error("Invalid params");

View file

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.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 * 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. * limit within the window is denied until its trailing count falls below it.
*/ */
export default async function updatePolicyRule(admin: AdminInterface, req: NDKRpcRequest) { export default async function updatePolicyRule(admin: AdminInterface, req: AdminRpcRequest) {
const [ _payload ] = req.params as [ string ]; const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params"); if (!_payload) throw new Error("Invalid params");

View file

@ -1,6 +1,5 @@
import "websocket-polyfill"; import NDK, { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser } from '@nostr-dev-kit/ndk'; import { getPublicKey, nip19 } from 'nostr-tools';
import { NDKNostrRpc } from '@nostr-dev-kit/ndk';
import createDebug from 'debug'; import createDebug from 'debug';
import { Key, KeyUser } from '../run'; import { Key, KeyUser } from '../run';
import { allowAllRequestsFromKey } from '../lib/acl/index.js'; import { allowAllRequestsFromKey } from '../lib/acl/index.js';
@ -21,12 +20,16 @@ import addSigningCondition from './commands/add_signing_condition';
import removeSigningCondition from './commands/remove_signing_condition'; import removeSigningCondition from './commands/remove_signing_condition';
import revokeToken from './commands/revoke_token'; import revokeToken from './commands/revoke_token';
import { NIP46_ADMIN_RESPONSE_KIND } from './kinds.js'; import { NIP46_ADMIN_RESPONSE_KIND } from './kinds.js';
import { NIP46_NOSTR_CONNECT_KIND } from './kinds.js';
import fs from 'fs'; import fs from 'fs';
import { validateRequestFromAdmin } from './validations/request-from-admin'; import { validateRequestFromAdmin } from './validations/request-from-admin';
import { dmUser } from '../../utils/dm-user'; import { dmUser } from '../../utils/dm-user';
import { IConfig, getCurrentConfig } from "../../config"; import { IConfig, getCurrentConfig } from "../../config";
import path from 'path'; import path from 'path';
import { attachIndefiniteReconnect } from '../lib/relay-reconnect.js'; 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';
const debug = createDebug("nsecbunker:admin"); const debug = createDebug("nsecbunker:admin");
@ -45,49 +48,65 @@ const allowNewKeys = true;
* This class represents the admin interface for the nsecbunker daemon. * This class represents the admin interface for the nsecbunker daemon.
* *
* It provides an interface for a UI to manage the daemon over nostr. * 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 { class AdminInterface {
private npubs: string[]; private npubs: string[];
private ndk: NDK; private pool: RelayPool;
private signerUser?: NDKUser; private transport: Nip46Transport;
readonly rpc: NDKNostrRpc; 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<string, "nip04" | "nip44"> = new Map();
readonly rpc: AdminRpc;
readonly configFile: string; readonly configFile: string;
public getKeys?: () => Promise<Key[]>; public getKeys?: () => Promise<Key[]>;
public getKeyUsers?: (req: NDKRpcRequest) => Promise<KeyUser[]>; public getKeyUsers?: (req: AdminRpcRequest) => Promise<KeyUser[]>;
public unlockKey?: (keyName: string, passphrase: string) => Promise<boolean>; public unlockKey?: (keyName: string, passphrase: string) => Promise<boolean>;
public loadNsec?: (keyName: string, nsec: string) => void; public loadNsec?: (keyName: string, nsec: string) => void;
constructor(opts: IAdminOpts, configFile: string) { constructor(opts: IAdminOpts, configFile: string) {
this.configFile = configFile; this.configFile = configFile;
this.npubs = opts.npubs || []; this.npubs = opts.npubs || [];
this.ndk = new NDK({ this.adminNsec = opts.key;
explicitRelayUrls: opts.adminRelays, this.adminPubkey = getPublicKey(secretKeyBytes(opts.key));
signer: new NDKPrivateKeySigner(opts.key),
this.pool = new RelayPool(opts.adminRelays, {
log: (...a: any[]) => console.log(...a),
heartbeatMs: 30_000,
}); });
this.transport = new Nip46Transport(secretKeyBytes(opts.key), this.pool);
// Override NDK's "give up after detecting flapping" behavior so the // The admin RPC the command handlers call. sendResponse encrypts with
// bunker's admin NDK keeps trying to reconnect indefinitely. The // the scheme the request used (resolved per id) and publishes on the
// watchdog (when enabled) still fires after 60s of zero connected // admin response channel (24134) unless a handler mirrors the request
// relays; this helper handles shorter disconnects (e.g. an lnbits // kind for errors.
// restart that pulls the nostrrelay extension's WS for a few this.rpc = {
// seconds) without involving the supervisor. See aiolabs/nsecbunkerd#20. sendResponse: async (
attachIndefiniteReconnect(this.ndk, 'admin'); id: string,
remotePubkey: string,
this.ndk.signer?.user().then((user: NDKUser) => { result: string,
let connectionString = `bunker://${user.npub}`; 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);
},
};
const npub = nip19.npubEncode(this.adminPubkey);
let connectionString = `bunker://${npub}`;
if (opts.adminRelays.length > 0) { if (opts.adminRelays.length > 0) {
connectionString += '@' + encodeURIComponent(`${opts.adminRelays.join(',').replace(/wss:\/\//g, '')}`); connectionString += '@' + encodeURIComponent(`${opts.adminRelays.join(',').replace(/wss:\/\//g, '')}`);
} }
console.log(`\n\nnsecBunker connection string:\n\n${connectionString}\n\n`); console.log(`\n\nnsecBunker connection string:\n\n${connectionString}\n\n`);
const configFolder = path.dirname(configFile);
// write connection string to connection.txt
const configFolder = path.dirname(configFile)
fs.writeFileSync(path.join(configFolder, 'connection.txt'), connectionString); fs.writeFileSync(path.join(configFolder, 'connection.txt'), connectionString);
this.signerUser = user;
this.connect(); this.connect();
this.config().then((config) => { this.config().then((config) => {
@ -95,19 +114,21 @@ class AdminInterface {
this.notifyAdminsOfNewConnection(connectionString); this.notifyAdminsOfNewConnection(connectionString);
} }
}); });
});
this.rpc = new NDKNostrRpc(this.ndk, this.ndk.signer!, debug);
} }
public async config(): Promise<IConfig> { public async config(): Promise<IConfig> {
return getCurrentConfig(this.configFile); 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) { private async notifyAdminsOfNewConnection(connectionString: string) {
const blastrNdk = new NDK({ const blastrNdk = new NDK({
explicitRelayUrls: ['wss://blastr.f7z.xyz', 'wss://nostr.mutinywallet.com'], explicitRelayUrls: ['wss://blastr.f7z.xyz', 'wss://nostr.mutinywallet.com'],
signer: this.ndk.signer signer: new NDKPrivateKeySigner(this.adminNsec),
}); });
await blastrNdk.connect(2500); await blastrNdk.connect(2500);
@ -120,7 +141,7 @@ class AdminInterface {
* Get the npub of the admin interface. * Get the npub of the admin interface.
*/ */
public async npub() { public async npub() {
return (await this.ndk.signer?.user())!.npub; return nip19.npubEncode(this.adminPubkey);
} }
private connect() { private connect() {
@ -129,81 +150,59 @@ class AdminInterface {
return; return;
} }
const debugTransport = process.env.NSEC_BUNKER_DEBUG_TRANSPORT === '1'; this.pool.start();
// Listen for admin requests on the NostrConnect channel (24133) AND the
// Per-relay publish-status logging for diagnosing aiolabs/nsecbunkerd#7. // admin response channel (24134, where admin clients address us).
// NDKNostrRpc.sendResponse calls event.publish() and discards the this.transport
// returned Set<NDKRelay>, so a silent outbox-drop is invisible without .start((req) => this.onRequest(req), [NIP46_NOSTR_CONNECT_KIND, NIP46_ADMIN_RESPONSE_KIND])
// hooking the underlying per-relay events. Gated by env flag so .then(() => console.log('✅ nsecBunker Admin Interface ready'))
// production deployments stay quiet. .catch((err) => {
const attachRelayLogging = (relay: any) => { console.log('❌ admin transport failed');
relay.on('published', (event: NDKEvent) => { console.log(err);
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]
}); });
// Attach per-relay logging to relays that connected before our // Session-liveness watchdog: exit (so the process supervisor restarts)
// 'relay:connect' listener was registered above (NDK can connect // if the admin pool can't stay healthy — connected AND subscribed — for
// synchronously inside .connect() under some paths). // >60s. Unlike the old connectedRelays()-only watchdog (#20), this can't
if (debugTransport) { // be fooled by a reconnected-but-deaf socket (#41). Disable via
this.ndk.pool.relays.forEach((relay: any) => attachRelayLogging(relay));
// Wrap sendResponse to log id + kind + elapsed time so we
// can correlate REQUEST_IN → RESPONSE_SENT → PUBLISHED.
const originalSendResponse = this.rpc.sendResponse.bind(this.rpc);
this.rpc.sendResponse = async (id: string, remotePubkey: string, result: string, kind?: number, error?: string) => {
const start = Date.now();
try {
await originalSendResponse(id, remotePubkey, result, kind, error);
console.log(`📨 RESPONSE_SENT id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} elapsed=${Date.now()-start}ms`);
} catch (e: any) {
console.log(`❌ RESPONSE_SEND_FAILED id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} err=${e?.message ?? e}`);
throw e;
}
};
}
this.rpc.on('request', (req) => {
if (debugTransport) {
console.log(`📥 REQUEST_IN method=${req.method} id=${req.id} from=${req.pubkey?.slice(0,8)} kind=${req.event?.kind}`);
}
this.handleRequest(req);
});
// 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. // NSEC_BUNKER_DISABLE_WATCHDOG=1.
if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') { if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') {
relayConnectionWatchdog(this.ndk); this.startWatchdog();
} else { } else {
console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1'); console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1');
} }
}).catch((err) => {
console.log('❌ admin connection failed');
console.log(err);
});
} }
private async handleRequest(req: NDKRpcRequest) { 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;
}
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);
}
}, POLL_INTERVAL_MS);
}
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) {
try { try {
await this.validateRequest(req); await this.validateRequest(req);
@ -228,30 +227,25 @@ class AdminInterface {
case 'remove_signing_condition': await removeSigningCondition(this, req); break; case 'remove_signing_condition': await removeSigningCondition(this, req); break;
case 'revoke_token': await revokeToken(this, req); break; case 'revoke_token': await revokeToken(this, req); break;
default: default:
const originalKind = req.event.kind!;
console.log(`Unknown method ${req.method}`); console.log(`Unknown method ${req.method}`);
return this.rpc.sendResponse( return this.rpc.sendResponse(
req.id, req.id,
req.pubkey, req.pubkey,
JSON.stringify(['error', `Unknown method ${req.method}`]), JSON.stringify(['error', `Unknown method ${req.method}`]),
originalKind req.event.kind,
); );
} }
} catch (err: any) { } catch (err: any) {
debug(`Error handling request ${req.method}: ${err?.message ?? err}`, req.params); debug(`Error handling request ${req.method}: ${err?.message ?? err}`, req.params);
// NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — using it // Mirror req.event.kind so the error goes back on the channel the
// makes sendResponse fall through to its default of 24133, which // request came in on (aiolabs/nsecbunkerd#7).
// sends the error on a different channel than the request came in const originalKind = req.event.kind;
// 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}`); console.log(`⚠️ HANDLE_REQUEST_ERROR method=${req.method} id=${req.id} kind=${originalKind} err=${err?.message ?? err}`);
return this.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, err?.message); return this.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, err?.message);
} }
} }
private async validateRequest(req: NDKRpcRequest): Promise<void> { private async validateRequest(req: AdminRpcRequest): Promise<void> {
// if this request is of type create_account, allow it // if this request is of type create_account, allow it
// TODO: require some POW to prevent spam // TODO: require some POW to prevent spam
if (req.method === 'create_account' && allowNewKeys) { if (req.method === 'create_account' && allowNewKeys) {
@ -267,7 +261,7 @@ class AdminInterface {
/** /**
* Command to list tokens * Command to list tokens
*/ */
private async reqGetKeyTokens(req: NDKRpcRequest) { private async reqGetKeyTokens(req: AdminRpcRequest) {
const keyName = req.params[0]; const keyName = req.params[0];
const tokens = await prisma.token.findMany({ const tokens = await prisma.token.findMany({
where: { keyName }, where: { keyName },
@ -313,7 +307,7 @@ class AdminInterface {
/** /**
* Command to list policies * Command to list policies
*/ */
private async reqListPolicies(req: NDKRpcRequest) { private async reqListPolicies(req: AdminRpcRequest) {
const policies = await prisma.policy.findMany({ const policies = await prisma.policy.findMany({
include: { include: {
rules: true, rules: true,
@ -346,7 +340,7 @@ class AdminInterface {
/** /**
* Command to fetch keys and their current state * Command to fetch keys and their current state
*/ */
private async reqGetKeys(req: NDKRpcRequest) { private async reqGetKeys(req: AdminRpcRequest) {
if (!this.getKeys) throw new Error('getKeys() not implemented'); if (!this.getKeys) throw new Error('getKeys() not implemented');
const result = JSON.stringify(await this.getKeys()); const result = JSON.stringify(await this.getKeys());
@ -358,7 +352,7 @@ class AdminInterface {
/** /**
* Command to fetch users of a key * Command to fetch users of a key
*/ */
private async reqGetKeyUsers(req: NDKRpcRequest): Promise<void> { private async reqGetKeyUsers(req: AdminRpcRequest): Promise<void> {
if (!this.getKeyUsers) throw new Error('getKeyUsers() not implemented'); if (!this.getKeyUsers) throw new Error('getKeyUsers() not implemented');
const result = JSON.stringify(await this.getKeyUsers(req)); const result = JSON.stringify(await this.getKeyUsers(req));
@ -388,36 +382,29 @@ class AdminInterface {
}, },
}); });
console.trace({method, param});
if (method === 'sign_event') { if (method === 'sign_event') {
const e = param.rawEvent(); // `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;
param = JSON.stringify(e); param = JSON.stringify(e);
console.log(`👀 Event to be signed\n`, { console.log(`👀 Event to be signed\n`, {
kind: e.kind, kind: e?.kind,
content: e.content, content: e?.content,
tags: e.tags, tags: e?.tags,
}); });
} }
return new Promise((resolve, reject) => { return new Promise((resolve) => {
console.log(`requesting permission for`, keyName); console.log(`requesting permission for`, keyName, { remotePubkey, method });
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(() => { setTimeout(() => {
resolve(undefined); resolve(undefined);
}, 10000); }, 10000);
for (const npub of this.npubs) { for (const npub of this.npubs) {
const remoteUser = new NDKUser({npub}); const adminPubkey = nip19.decode(npub).data as string;
console.log(`sending request to ${npub}`, remoteUser.pubkey);
const params = JSON.stringify({ const params = JSON.stringify({
keyName, keyName,
remotePubkey, remotePubkey,
@ -426,12 +413,14 @@ class AdminInterface {
description: keyUser?.description, description: keyUser?.description,
}); });
this.rpc.sendRequest( const id = this.transport.sendRequest(
remoteUser.pubkey, adminPubkey,
'acl', 'acl',
[params], [params],
'nip44',
NIP46_ADMIN_RESPONSE_KIND, NIP46_ADMIN_RESPONSE_KIND,
(res: NDKRpcResponse) => { (res) => {
this.transport.clearPending(id);
this.requestPermissionResponse( this.requestPermissionResponse(
remotePubkey, remotePubkey,
keyName, keyName,
@ -452,7 +441,7 @@ class AdminInterface {
method: string, method: string,
param: string, param: string,
resolve: (value: boolean) => void, resolve: (value: boolean) => void,
res: NDKRpcResponse res: { id: string; result: string; error?: string }
) { ) {
let resObj; let resObj;
try { try {
@ -485,47 +474,4 @@ 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; export default AdminInterface;

View file

@ -1,14 +1,12 @@
import type { NDKKind } from '@nostr-dev-kit/ndk'; /**
* 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;
/** /**
* NIP-46 admin-RPC response channel kind-24134. Distinct from the * NIP-46 admin-RPC response channel kind-24134. Distinct from the client
* standard NIP-46 client channel kind-24133 (`NDKKind.NostrConnect`) * channel (24133) so signer clients and admin clients don't subscribe to each
* which carries `sign_event` / `nip04_*` / `nip44_*` / etc. * other's events.
*
* 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 as NDKKind; export const NIP46_ADMIN_RESPONSE_KIND = 24134;

35
src/daemon/admin/types.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* 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<void>;
}

View file

@ -1,8 +1,8 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { AdminRpcRequest } from "../types.js";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
export async function validateRequestFromAdmin( export async function validateRequestFromAdmin(
req: NDKRpcRequest, req: AdminRpcRequest,
npubs: string[], npubs: string[],
): Promise<boolean> { ): Promise<boolean> {
const hexpubkey = req.pubkey; const hexpubkey = req.pubkey;

View file

@ -1,101 +0,0 @@
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<string, number>();
const pending = new Map<string, NodeJS.Timeout>();
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);
}
});
}

View file

@ -38,6 +38,9 @@ export function secretKeyBytes(key: string): Uint8Array {
export class Nip46Transport { export class Nip46Transport {
public readonly pubkey: string; public readonly pubkey: string;
private sub: { close: () => void } | null = null; 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<string, (res: { id: string; result: string; error?: string }) => void>();
constructor( constructor(
private readonly sk: Uint8Array, private readonly sk: Uint8Array,
@ -47,15 +50,13 @@ export class Nip46Transport {
this.pubkey = getPublicKey(sk); this.pubkey = getPublicKey(sk);
} }
/** Start listening for this key's kind:24133 requests. Resolves once the /** Start listening for this key's requests on the given kinds (the signer
* subscription's first EOSE lands (the #9 start-race guard). */ * channel is [24133]; the admin channel is [24133, 24134]). Resolves once
async start(onRequest: (req: Nip46Request) => void): Promise<void> { * the subscription's first EOSE lands (the #9 start-race guard). */
async start(onRequest: (req: Nip46Request) => void, kinds: number[] = [NIP46_KIND]): Promise<void> {
this.sub = await this.pool.subscribeAwaitingEose( this.sub = await this.pool.subscribeAwaitingEose(
[{ kinds: [NIP46_KIND], "#p": [this.pubkey] }], [{ kinds, "#p": [this.pubkey] }],
(event: Event) => { (event: Event) => this.onEvent(event, onRequest),
const req = this.parse(event);
if (req) onRequest(req);
},
{ id: `nip46:${this.pubkey}` }, { id: `nip46:${this.pubkey}` },
); );
} }
@ -65,9 +66,30 @@ export class Nip46Transport {
this.sub = null; this.sub = null;
} }
/** Decrypt + verify an inbound event into a request, or null if it's not a private onEvent(event: Event, onRequest: (req: Nip46Request) => void): void {
* valid request we can read. */ const env = this.parseEnvelope(event);
private parse(event: Event): Nip46Request | null { 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)) { if (!verifyEvent(event)) {
this.log("dropping event with invalid signature", event.id); this.log("dropping event with invalid signature", event.id);
return null; return null;
@ -83,42 +105,63 @@ export class Nip46Transport {
try { try {
decrypted = this.decrypt(remotePubkey, event.content, encryption); decrypted = this.decrypt(remotePubkey, event.content, encryption);
} catch (e) { } catch (e) {
this.log("failed to decrypt request", e); this.log("failed to decrypt event", e);
return null; return null;
} }
} }
let parsed: any;
try { try {
parsed = JSON.parse(decrypted); return { body: JSON.parse(decrypted), encryption, remotePubkey, kind: event.kind };
} catch { } catch {
this.log("request content was not JSON"); this.log("event content was not JSON");
return null; return null;
} }
if (!parsed?.method) return null; // it's a response, not a request
return {
id: parsed.id,
method: parsed.method,
params: parsed.params ?? [],
remotePubkey,
encryption,
};
} }
/** Encrypt + sign + publish a NIP-46 response, matching the request's scheme. */ /**
* 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( async sendResponse(
id: string, id: string,
remotePubkey: string, remotePubkey: string,
result: string, result: string,
encryption: "nip04" | "nip44", encryption: "nip04" | "nip44",
error?: string, error?: string,
kind: number = NIP46_KIND,
): Promise<void> { ): Promise<void> {
const payload: { id: string; result: string; error?: string } = { id, result }; const payload: { id: string; result: string; error?: string } = { id, result };
if (error) payload.error = error; if (error) payload.error = error;
const content = this.encrypt(remotePubkey, JSON.stringify(payload), encryption); const content = this.encrypt(remotePubkey, JSON.stringify(payload), encryption);
const event = finalizeEvent( const event = finalizeEvent(
{ {
kind: NIP46_KIND, kind,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [["p", remotePubkey]], tags: [["p", remotePubkey]],
content, content,

View file

@ -47,4 +47,7 @@ export interface Nip46Request {
remotePubkey: string; remotePubkey: string;
/** Which envelope encryption the client used; the response must match it. */ /** Which envelope encryption the client used; the response must match it. */
encryption: "nip04" | "nip44"; 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;
} }

View file

@ -7,7 +7,7 @@ import type { Nip46PermitCallback, Nip46PermitCallbackParams } from './nip46/typ
import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js'; import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js';
import AdminInterface from './admin/index.js'; import AdminInterface from './admin/index.js';
import { IConfig } from '../config/index.js'; import { IConfig } from '../config/index.js';
import { NDKRpcRequest } from '@nostr-dev-kit/ndk'; import type { AdminRpcRequest } from './admin/types.js';
import prisma from '../db.js'; import prisma from '../db.js';
import { DaemonConfig } from './index.js'; import { DaemonConfig } from './index.js';
import { decryptNsec } from '../config/keys.js'; import { decryptNsec } from '../config/keys.js';
@ -59,7 +59,7 @@ function getKeys(config: DaemonConfig) {
} }
function getKeyUsers(config: IConfig) { function getKeyUsers(config: IConfig) {
return async (req: NDKRpcRequest): Promise<KeyUser[]> => { return async (req: AdminRpcRequest): Promise<KeyUser[]> => {
const keyUsers: KeyUser[] = []; const keyUsers: KeyUser[] = [];
const keyName = req.params[0]; const keyName = req.params[0];

View file

@ -0,0 +1,133 @@
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<void> {
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<string, (r: { result: string; error?: string }) => 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<string>((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();
});