nsecbunkerd/tests/admin-transport.test.ts
Padreug a676d4fa98
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
feat(transport): port the admin RPC off NDK onto the relay pool (#42)
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
2026-06-27 01:23:53 +02:00

133 lines
5.5 KiB
TypeScript

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();
});