feat(transport): port the admin RPC off NDK onto the relay pool (#42)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
This commit is contained in:
parent
ea923b472d
commit
a676d4fa98
25 changed files with 429 additions and 369 deletions
133
tests/admin-transport.test.ts
Normal file
133
tests/admin-transport.test.ts
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue