Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
133 lines
5.5 KiB
TypeScript
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();
|
|
});
|