mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(beehive): capture optional NIP-11 relay name for the host footer
Bounded optional relay display name for the host-settings footer. One unauthenticated NIP-11 document read per configured relay URL (4 s timeout, 32 KiB cap, no redirects, cancellable) supplies a terminal-sanitized name shown before the exact configured URL and actual connection state. Missing, failed, oversized or control-bearing names keep the truthful URL-only footer; the name never changes reported connection state, blocks startup, resets configuration, carries credentials or invents a name. A relay switch aborts the in-flight read and a late completion is never applied to a new relay. Installed smoke loader stubs the new HTTP path; no live relay is contacted in tests. Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -14,12 +14,13 @@ import { readHostIdentityPublic } from './host-identity.ts';
|
||||
import { createControllerConfig, readControllerConfig } from './controller-config.ts';
|
||||
import { ownerPublicInput } from './host-setup.ts';
|
||||
import { managementClient } from './intents.ts';
|
||||
import { fetchRelayName } from './relay-name.ts';
|
||||
import { message, type Message } from './protocol.ts';
|
||||
import { profileDrafts, editProfileDraft } from './profile-drafts.ts';
|
||||
|
||||
export type ManagerRequest = { id: number; action: string; values?: Record<string, string>; target?: string; revision?: number };
|
||||
export type ManagerItem = { id: string; label: string; detail: string; evidence?: string; disabled?: Record<string, string> };
|
||||
export type ManagerSnapshot = { local: ManagerItem[]; agents: (ManagerItem & { revision: number; configurations: string[] })[]; routing?: { owner: string; relay: string }; owner?: string; status: string; settings?: Settings; service?: ServiceStatus; hostRelay?: string; profilePreview?: RegisteredAgent; models?: string[]; runtimeExecutable?: string; harnesses?: DetectedHarness[]; databricksHost?: string };
|
||||
export type ManagerSnapshot = { local: ManagerItem[]; agents: (ManagerItem & { revision: number; configurations: string[] })[]; routing?: { owner: string; relay: string }; owner?: string; status: string; settings?: Settings; service?: ServiceStatus; hostRelay?: string; relayName?: string; profilePreview?: RegisteredAgent; models?: string[]; runtimeExecutable?: string; harnesses?: DetectedHarness[]; databricksHost?: string };
|
||||
const short = (s: string) => s.length > 22 ? `${s.slice(0,8)}…${s.slice(-6)}` : s;
|
||||
|
||||
/** Final plain display text thrown by this boundary; never rewrapped or retranslated. */
|
||||
@@ -139,14 +140,17 @@ export class ManagerController {
|
||||
private harnesses?: DetectedHarness[];
|
||||
private databricksHost?: string;
|
||||
private lastId = 0;
|
||||
private relayName?: { relay: string; name?: string };
|
||||
private relayNamePending?: { relay: string; abort: AbortController };
|
||||
private status = 'Local Host does not require owner sign-in. Quitting or signing out does not stop hosts or agents.';
|
||||
readonly home: string;
|
||||
readonly changed: (snapshot: ManagerSnapshot) => void;
|
||||
private credential: typeof managerCredential;
|
||||
private connect: typeof managementClient;
|
||||
private fetchName: typeof fetchRelayName;
|
||||
constructor(home: string, changed: (snapshot: ManagerSnapshot) => void,
|
||||
credential = managerCredential, connect = managementClient) {
|
||||
this.home = home; this.changed = changed; this.credential = credential; this.connect = connect;
|
||||
credential = managerCredential, connect = managementClient, fetchName = fetchRelayName) {
|
||||
this.home = home; this.changed = changed; this.credential = credential; this.connect = connect; this.fetchName = fetchName;
|
||||
}
|
||||
private get hostDirectory() { return join(this.home, '.beehive', 'host'); }
|
||||
private get ownerDirectory() { return join(this.home, '.beehive', 'owner'); }
|
||||
@@ -196,10 +200,32 @@ A Stop result is not a recent host report that confirms the agent is stopped.` }
|
||||
try { routing = readControllerConfig(this.ownerDirectory); } catch { /* Invalid retained routing is refused by sign-in; never reset here. */ }
|
||||
let settings: Settings | undefined, hostRelay: string | undefined;
|
||||
try { settings = readSettings(this.hostDirectory); if (existsSync(join(this.hostDirectory,'host-identity.json'))) hostRelay = readHostIdentityPublic(this.hostDirectory).pairing.relay; } catch { /* Existing error row remains actionable; never reset settings. */ }
|
||||
return { settings, service: this.service, hostRelay, profilePreview: this.profilePreview, models: this.models, runtimeExecutable: this.runtimeExecutable, harnesses: this.harnesses, databricksHost: this.databricksHost, local, agents, routing: routing ? { owner: routing.owner, relay: routing.relay } : undefined, owner: this.owner, status: this.status };
|
||||
const footerRelay = hostRelay ?? routing?.relay;
|
||||
this.observeRelayName(footerRelay);
|
||||
return { settings, service: this.service, hostRelay, relayName: this.relayName && this.relayName.relay === footerRelay ? this.relayName.name : undefined, profilePreview: this.profilePreview, models: this.models, runtimeExecutable: this.runtimeExecutable, harnesses: this.harnesses, databricksHost: this.databricksHost, local, agents, routing: routing ? { owner: routing.owner, relay: routing.relay } : undefined, owner: this.owner, status: this.status };
|
||||
}
|
||||
|
||||
private fresh(m: Message) { return Boolean(this.client?.connected && Date.now() - Number(m.body.observedAt) <= 6000); }
|
||||
/** Optional bounded footer relay name. One attempt per configured relay URL; absence,
|
||||
* failure or cancellation only omits the name. It never changes the reported
|
||||
* connection state, blocks the UI, resets configuration or invents a name, and a
|
||||
* late or stale completion is never applied to a different relay. */
|
||||
private observeRelayName(relay: string | undefined) {
|
||||
if (this.closed) return;
|
||||
if (this.relayName?.relay === relay || this.relayNamePending?.relay === relay) return;
|
||||
this.relayNamePending?.abort.abort();
|
||||
this.relayNamePending = undefined;
|
||||
this.relayName = undefined;
|
||||
if (!relay) return;
|
||||
const pending = { relay, abort: new AbortController() };
|
||||
this.relayNamePending = pending;
|
||||
void Promise.resolve().then(() => this.fetchName(relay, pending.abort.signal)).catch(() => undefined).then(name => {
|
||||
if (this.closed || this.relayNamePending !== pending) return;
|
||||
this.relayNamePending = undefined;
|
||||
this.relayName = { relay, name };
|
||||
if (!this.closed) this.changed(this.snapshot());
|
||||
});
|
||||
}
|
||||
refresh() {
|
||||
if (this.closed) return;
|
||||
this.changed(this.snapshot());
|
||||
@@ -209,7 +235,7 @@ A Stop result is not a recent host report that confirms the agent is stopped.` }
|
||||
}
|
||||
}
|
||||
cancel() { this.generation++; this.active?.abort(); }
|
||||
close() { this.closed = true; this.cancel(); this.client?.close(); this.client = undefined; this.owner = undefined; }
|
||||
close() { this.closed = true; this.cancel(); this.relayNamePending?.abort.abort(); this.client?.close(); this.client = undefined; this.owner = undefined; }
|
||||
async request(request: ManagerRequest) {
|
||||
if (this.closed || request.id <= this.lastId || this.active) return;
|
||||
this.lastId = request.id;
|
||||
|
||||
@@ -60,7 +60,7 @@ function render() {
|
||||
if (selected === 'empty') selected = '';
|
||||
if (!selected || (scope === 0 && selected === 'missing' && rows.some(r => r.id === 'host'))) selected = rows[0]?.id ?? '';
|
||||
const vanished = !!selected && !rows.some(row => row.id === selected);
|
||||
screen.setRelay(snapshot.hostRelay ?? snapshot.routing?.relay, snapshot.service?.relay ?? 'unknown');
|
||||
screen.setRelay(snapshot.hostRelay ?? snapshot.routing?.relay, snapshot.service?.relay ?? 'unknown', snapshot.relayName);
|
||||
const configured = snapshot.local.some(r => r.id === 'host');
|
||||
const actions: ManagerAction[] = scope === 0 ? [
|
||||
{ label: 'Register agent', run: async () => {
|
||||
|
||||
@@ -110,7 +110,7 @@ export class OpenTuiScreen {
|
||||
this.footer.content = this.relayFooter + '\n' + (this.renderer.width >= 70 ? OpenTuiScreen.hintsWide : OpenTuiScreen.hintsNarrow); this.footer.height = 2;
|
||||
}
|
||||
private relayFooter = 'Relay: Not configured · disconnected';
|
||||
setRelay(url?: string, state = 'unknown') { this.relayFooter = url ? `Relay: ${clean(url)} · ${state}` : 'Relay: Not configured · disconnected'; this.resize(); }
|
||||
setRelay(url?: string, state = 'unknown', name?: string) { this.relayFooter = url ? `Relay: ${name ? `${clean(name)} · ` : ''}${clean(url)} · ${state}` : 'Relay: Not configured · disconnected'; this.resize(); }
|
||||
private heading() { this.header.content = `BEEHIVE ${this.scope === 0 ? '[Local Host]' : 'Local Host'} | ${this.scope === 1 ? '[Agents]' : 'Agents'}\n${this.scope === 0 ? 'This computer · no owner sign-in needed' : 'Owner · ' + clean(this.owner)}`; }
|
||||
setOwner(publicSuffix?: string) { this.owner = publicSuffix ? `${publicSuffix} · signed in · key saved here` : 'signed out'; this.heading(); }
|
||||
private switchScope(scope: number) {
|
||||
|
||||
68
beehive/src/relay-name.ts
Normal file
68
beehive/src/relay-name.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
|
||||
/** Optional bounded NIP-11 relay display name for the footer only. The name is
|
||||
* presentation metadata: it never proves connectivity, authorizes anything or
|
||||
* replaces the exact configured relay URL. Absence or failure yields no name. */
|
||||
|
||||
export const RELAY_NAME_TIMEOUT_MS = 4000;
|
||||
export const RELAY_NAME_DOCUMENT_BYTES = 32768;
|
||||
/** Terminal-control characters and bidi overrides are rejected, not re-displayed:
|
||||
* newlines, ANSI/CSI sequences, C1 controls, DEL and direction spoofing. */
|
||||
const TERMINAL_CONTROL = /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/;
|
||||
/** Footer display bound; longer relay names are absurd payloads, not names. */
|
||||
const RELAY_NAME_DISPLAY_MAX = 64;
|
||||
|
||||
/** Sanitized optional `name` from a NIP-11 document. Nothing is invented: absent,
|
||||
* non-string, control-bearing, spoofing, empty or absurd names return undefined. */
|
||||
export function relayNameFromDocument(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const name = (value as { name?: unknown }).name;
|
||||
if (typeof name !== 'string' || name.length > RELAY_NAME_DISPLAY_MAX) return undefined;
|
||||
if (TERMINAL_CONTROL.test(name)) return undefined;
|
||||
const sanitized = stripVTControlCharacters(name).trim();
|
||||
return sanitized ? sanitized : undefined;
|
||||
}
|
||||
|
||||
/** Relay (ws/wss) URL to its NIP-11 HTTP document URL. wss maps to https; plain ws
|
||||
* is accepted only for the loopback fixture origin, mirroring the profile lookup.
|
||||
* URLs carrying credentials or fragments never become an HTTP request. */
|
||||
export function relayNameHttpUrl(relay: string): string | undefined {
|
||||
let url: URL;
|
||||
try { url = new URL(relay); } catch { return undefined; }
|
||||
if (url.username || url.password || url.hash) return undefined;
|
||||
if (url.protocol === 'wss:') url.protocol = 'https:';
|
||||
else if (url.protocol === 'ws:' && url.hostname === '127.0.0.1') url.protocol = 'http:';
|
||||
else return undefined;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** One bounded unauthenticated document read. Resolves undefined for every
|
||||
* transport, format, size, timeout or cancellation failure — never a rejected
|
||||
* promise and never an invented name. A missing name is not a disconnection. */
|
||||
export async function fetchRelayName(
|
||||
relay: string, signal: AbortSignal, fetcher: typeof fetch = fetch,
|
||||
timeoutMs = RELAY_NAME_TIMEOUT_MS, maxBytes = RELAY_NAME_DOCUMENT_BYTES,
|
||||
): Promise<string | undefined> {
|
||||
const target = relayNameHttpUrl(relay);
|
||||
if (!target || signal.aborted) return undefined;
|
||||
const bound = new AbortController();
|
||||
const cancel = () => bound.abort();
|
||||
signal.addEventListener('abort', cancel, { once: true });
|
||||
const timer = setTimeout(cancel, timeoutMs);
|
||||
try {
|
||||
const response = await fetcher(target, { headers: { Accept: 'application/nostr+json' }, signal: bound.signal, redirect: 'error' });
|
||||
if (!response.ok || !response.body) return undefined;
|
||||
const reader = response.body.getReader(); let bytes = 0; const chunks: Uint8Array[] = [];
|
||||
try {
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
bytes += next.value.length;
|
||||
if (bytes > maxBytes) throw Error('Relay name document exceeds limit');
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} finally { await reader.cancel(); reader.releaseLock(); }
|
||||
try { return relayNameFromDocument(JSON.parse(Buffer.concat(chunks).toString())); }
|
||||
catch { return undefined; }
|
||||
} catch { return undefined; } finally { clearTimeout(timer); signal.removeEventListener('abort', cancel); }
|
||||
}
|
||||
@@ -29,7 +29,7 @@ function fixture() {
|
||||
}
|
||||
return { ok: true, secret: backend.read(credentialReference('owner',input.owner)) === null ? null : readCredential(credentialReference('owner',input.owner),backend) };
|
||||
};
|
||||
const controller = new ManagerController(home, s => { snapshot = s; }, credential, ((_root: string,_url: string,_secret: string,r: typeof receive) => { receive = r; return client; }) as any);
|
||||
const controller = new ManagerController(home, s => { snapshot = s; }, credential, ((_root: string,_url: string,_secret: string,r: typeof receive) => { receive = r; return client; }) as any, async () => undefined);
|
||||
return { controller, home, submitted, backend, get closed() { return closed; }, get snapshot() { return snapshot!; }, receive: (m: Message) => receive(m), states: (s: any[]) => { states = s; }, cleanup() { controller.close(); rmSync(home,{ recursive: true, force: true }); } };
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ registerHooks({ load(url, context, next) {
|
||||
if (url.endsWith('/src/agent-profile.ts')) return { ...result, source: "export async function fetchAgentProfile() { return {profileState:'none'}; }" };
|
||||
if (url.endsWith('/src/host-service.ts')) return { ...result, source: "export async function serviceStatus() { return {state:'stopped',relay:'disconnected'}; } export async function startService() { throw Error('Service start disabled by installed fixture'); } export async function stopService() { throw Error('Service stop disabled by installed fixture'); }" };
|
||||
if (url.endsWith('/src/settings-models.ts')) return { ...result, source: "export function detectBuzzAgent() { return process.execPath; } export async function openAIModels() { throw Error('Direct provider access disabled by installed fixture'); }" };
|
||||
if (url.endsWith('/src/relay-name.ts')) return { ...result, source: "export function relayNameFromDocument() { return undefined; } export async function fetchRelayName() { return undefined; }" };
|
||||
if (!url.endsWith('/src/manager-entry.ts')) return result;
|
||||
const source = String(result.source);
|
||||
const original = 'new ManagerController(homedir(), snapshot => send({ snapshot }))';
|
||||
|
||||
@@ -38,3 +38,20 @@ test('disabled host control and pending cancellation never run an operation; mul
|
||||
ui.mockInput.pressKey('q',{ctrl:true}); await view.done; assert.equal(await quitting,undefined);
|
||||
} finally { view.close(); }
|
||||
});
|
||||
|
||||
test('footer shows the optional sanitized relay name before the exact URL and state', async () => {
|
||||
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
|
||||
const view = new OpenTuiScreen(ui.renderer);
|
||||
try {
|
||||
view.setRelay('wss://fixture.invalid','connected','Fixture Relay'); await ui.renderOnce();
|
||||
assert.match(ui.captureCharFrame(),/Relay: Fixture Relay · wss:\/\/fixture\.invalid · connected/);
|
||||
view.setRelay('wss://fixture.invalid','disconnected','Bad\u001b[31mName'); await ui.renderOnce();
|
||||
const sanitized = ui.captureCharFrame();
|
||||
assert.match(sanitized,/Relay: BadName · wss:\/\/fixture\.invalid · disconnected/);
|
||||
assert.ok(!sanitized.includes('\u001b[31m'));
|
||||
view.setRelay('wss://fixture.invalid','connected'); await ui.renderOnce();
|
||||
assert.match(ui.captureCharFrame(),/Relay: wss:\/\/fixture\.invalid · connected/);
|
||||
view.setRelay(undefined,'unknown'); await ui.renderOnce();
|
||||
assert.match(ui.captureCharFrame(),/Relay: Not configured · disconnected/);
|
||||
} finally { view.close(); }
|
||||
});
|
||||
|
||||
182
beehive/test/relay-name.test.ts
Normal file
182
beehive/test/relay-name.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createServer } from 'node:http';
|
||||
import { ManagerController, type ManagerSnapshot } from '../src/manager-controller.ts';
|
||||
import { bootstrapHostIdentity } from '../src/host-identity.ts';
|
||||
import { createControllerConfig } from '../src/controller-config.ts';
|
||||
import { type CredentialBackend } from '../src/credential-store.ts';
|
||||
import { publicKey } from '../src/protocol.ts';
|
||||
import { fetchRelayName, relayNameFromDocument, relayNameHttpUrl } from '../src/relay-name.ts';
|
||||
|
||||
const owner = publicKey('1'.repeat(64));
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||
async function wait(fn: () => boolean) { for (let n = 0; n < 250; n++) { if (fn()) return; await sleep(20); } throw Error('Fixture wait expired'); }
|
||||
|
||||
test('relay name document yields only a bounded terminal-safe name or nothing', () => {
|
||||
assert.equal(relayNameFromDocument({ name: 'Fixture Relay' }), 'Fixture Relay');
|
||||
assert.equal(relayNameFromDocument({ name: ' Padded ' }), 'Padded');
|
||||
assert.equal(relayNameFromDocument({ name: 'Södertälje Relay' }), 'Södertälje Relay');
|
||||
assert.equal(relayNameFromDocument({ description: 'no name key' }), undefined, 'absent name is not invented');
|
||||
assert.equal(relayNameFromDocument({}), undefined);
|
||||
assert.equal(relayNameFromDocument({ name: 7 }), undefined, 'non-string name is not displayed');
|
||||
assert.equal(relayNameFromDocument('{"name":"x"}'), undefined);
|
||||
assert.equal(relayNameFromDocument([{ name: 'x' }]), undefined);
|
||||
assert.equal(relayNameFromDocument(null), undefined);
|
||||
assert.equal(relayNameFromDocument({ name: 'Row\nTwo' }), undefined, 'newline is rejected, not re-flowed');
|
||||
assert.equal(relayNameFromDocument({ name: 'Carriage\rReturn' }), undefined);
|
||||
assert.equal(relayNameFromDocument({ name: 'Color\u001b[31mName' }), undefined, 'ANSI escape is rejected');
|
||||
assert.equal(relayNameFromDocument({ name: 'CSI\u009b31mName' }), undefined, 'C1 control is rejected');
|
||||
assert.equal(relayNameFromDocument({ name: 'Rtl\u202eSpoof' }), undefined, 'bidi override is rejected');
|
||||
assert.equal(relayNameFromDocument({ name: '\u2068Isolate' }), undefined);
|
||||
assert.equal(relayNameFromDocument({ name: ' \t ' }), undefined, 'whitespace-only is not a name');
|
||||
assert.equal(relayNameFromDocument({ name: 'x'.repeat(65) }), undefined, 'absurd length is rejected');
|
||||
assert.equal(relayNameFromDocument({ name: 'x'.repeat(64) }), 'x'.repeat(64), 'bounded display length is kept');
|
||||
});
|
||||
|
||||
test('relay name HTTP URL conversion keeps the NIP-11 root and refuses non-relay or credential-bearing URLs', () => {
|
||||
assert.equal(relayNameHttpUrl('wss://relay.example'), 'https://relay.example/');
|
||||
assert.equal(relayNameHttpUrl('wss://relay.example/path'), 'https://relay.example/path');
|
||||
assert.equal(relayNameHttpUrl('ws://127.0.0.1:8080'), 'http://127.0.0.1:8080/');
|
||||
assert.equal(relayNameHttpUrl('https://plain.example'), undefined, 'already-HTTP URLs are not relay URLs');
|
||||
assert.equal(relayNameHttpUrl('ws://relay.example'), undefined, 'plain ws off loopback is not fetched');
|
||||
assert.equal(relayNameHttpUrl('wss://key:secret@relay.example'), undefined, 'credentials never become a request');
|
||||
assert.equal(relayNameHttpUrl('wss://relay.example/#fragment'), undefined);
|
||||
assert.equal(relayNameHttpUrl('not a url'), undefined);
|
||||
});
|
||||
|
||||
test('fetchRelayName sends one unauthenticated document request and every failure stays nameless', async () => {
|
||||
const seen: { url: string; accept: string | null; authorization: string | null; signal: AbortSignal }[] = [];
|
||||
const fetcher: typeof fetch = async (url, options) => {
|
||||
const headers = new Headers(options?.headers);
|
||||
seen.push({ url: String(url), accept: headers.get('accept'), authorization: headers.get('authorization'), signal: options!.signal! });
|
||||
return new Response(JSON.stringify({ name: 'Fixture Relay', description: 'unread' }), { status: 200 });
|
||||
};
|
||||
assert.equal(await fetchRelayName('wss://name.example.invalid', new AbortController().signal, fetcher), 'Fixture Relay');
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].url, 'https://name.example.invalid/');
|
||||
assert.equal(seen[0].accept, 'application/nostr+json');
|
||||
assert.equal(seen[0].authorization, null, 'no credential is attached');
|
||||
const failing: Response[] = [
|
||||
new Response('', { status: 404 }),
|
||||
new Response('not json', { status: 200 }),
|
||||
new Response('{"name":7}', { status: 200 }),
|
||||
new Response('{}', { status: 200 }),
|
||||
];
|
||||
for (const [index, response] of failing.entries()) assert.equal(await fetchRelayName('wss://fail.example.invalid', new AbortController().signal, async () => response), undefined, `case ${index} yields no name`);
|
||||
assert.equal(await fetchRelayName('wss://big.example.invalid', new AbortController().signal, async () => new Response('x'.repeat(25)), 4000, 16), undefined, 'an absurd document size yields no name');
|
||||
assert.equal(await fetchRelayName('https://not-a-relay.example.invalid', new AbortController().signal, fetcher), undefined);
|
||||
assert.equal(await fetchRelayName('wss://name.example.invalid', AbortSignal.abort(), fetcher), undefined, 'already cancelled makes no request');
|
||||
assert.equal(seen.length, 1, 'no extra requests were made');
|
||||
await assert.rejects(fetchRelayName('wss://throw.example.invalid', new AbortController().signal, async () => { throw Error('network unreachable'); }).then(() => { throw Error('resolved'); }), /resolved/);
|
||||
const aborted = new AbortController();
|
||||
const pending = fetchRelayName('wss://cancel.example.invalid', aborted.signal, (_url, options) => new Promise((_resolve, reject) => { options!.signal!.addEventListener('abort', () => reject(Error('aborted'))); }));
|
||||
await sleep(10); aborted.abort();
|
||||
assert.equal(await pending, undefined, 'caller cancellation resolves without a name');
|
||||
const start = Date.now();
|
||||
assert.equal(await fetchRelayName('wss://slow.example.invalid', new AbortController().signal, (_url, options) => new Promise((_resolve, reject) => { options!.signal!.addEventListener('abort', () => reject(Error('timeout'))); }), 40), undefined, 'bounded timeout resolves without a name');
|
||||
assert.ok(Date.now() - start < 2000, 'timeout does not wait for a slow document');
|
||||
});
|
||||
|
||||
test('real loopback fetch reads a synthetic NIP-11 document without credentials or redirect chains', async t => {
|
||||
const requests: { method?: string; url?: string; accept?: string; authorization?: string }[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push({ method: request.method, url: request.url, accept: request.headers.accept, authorization: request.headers.authorization });
|
||||
if (request.url === '/name') { response.writeHead(200, { 'Content-Type': 'application/nostr+json' }); response.end(JSON.stringify({ name: 'Loopback Fixture Relay' })); return; }
|
||||
if (request.url === '/redirect') { response.writeHead(302, { Location: 'https://elsewhere.example.invalid/' }); response.end(); return; }
|
||||
response.writeHead(500); response.end();
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise<void>(resolve => server.close(() => resolve())));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
assert.equal(await fetchRelayName(`ws://127.0.0.1:${port}/name`, new AbortController().signal), 'Loopback Fixture Relay');
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].method, 'GET');
|
||||
assert.equal(requests[0].url, '/name');
|
||||
assert.equal(requests[0].accept, 'application/nostr+json');
|
||||
assert.equal(requests[0].authorization, undefined);
|
||||
assert.equal(await fetchRelayName(`ws://127.0.0.1:${port}/redirect`, new AbortController().signal), undefined, 'redirects are not followed');
|
||||
assert.equal(await fetchRelayName(`ws://127.0.0.1:${port}/`, new AbortController().signal), undefined, 'server failure keeps the URL-only footer');
|
||||
assert.equal(requests.length, 3, 'each attempt is one request');
|
||||
});
|
||||
|
||||
function managerFixture(fetchName: (relay: string, signal: AbortSignal) => Promise<string | undefined>) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'beehive-relay-name-'));
|
||||
const secrets = new Map<string, string>();
|
||||
const backend: CredentialBackend = { read: r => secrets.get(JSON.stringify(r)) ?? null, create: (r, s) => { secrets.set(JSON.stringify(r), s); }, remove: r => { secrets.delete(JSON.stringify(r)); } };
|
||||
const snapshots: ManagerSnapshot[] = [];
|
||||
const client = { ready: Promise.resolve(), connected: true, close() {}, status: () => [], submit() {}, reconcile() {} };
|
||||
const credential = async (input: any, _signal: AbortSignal) => {
|
||||
if (input.action === 'configure') { bootstrapHostIdentity(input.directory, input.label ?? 'fixture', input.owner, input.relay, backend); return { ok: true }; }
|
||||
return { ok: true, secret: null };
|
||||
};
|
||||
const controller = new ManagerController(home, s => { snapshots.push(s); }, credential, (() => client) as any, fetchName);
|
||||
return { controller, home, snapshots, get snapshot() { return snapshots.at(-1)!; }, cleanup() { controller.close(); rmSync(home, { recursive: true, force: true }); } };
|
||||
}
|
||||
|
||||
test('controller shows the optional name for the exact configured relay; one attempt per relay URL', async () => {
|
||||
const fetches: string[] = [];
|
||||
const f = managerFixture(async relay => { fetches.push(relay); return relay === 'wss://name.example.invalid' ? 'Fixture Relay Name' : undefined; });
|
||||
try {
|
||||
assert.ok(f.snapshots.every(s => s.relayName === undefined), 'no relay name before any relay is configured');
|
||||
await f.controller.request({ id: 1, action: 'configure', values: { owner, relay: 'wss://name.example.invalid' } });
|
||||
await wait(() => f.snapshot.relayName === 'Fixture Relay Name');
|
||||
assert.equal(f.snapshot.hostRelay, 'wss://name.example.invalid');
|
||||
await wait(() => f.snapshot.service?.state === 'stopped');
|
||||
f.controller.refresh(); f.controller.refresh(); f.controller.refresh();
|
||||
await sleep(60);
|
||||
assert.equal(fetches.length, 1, 'refresh never re-fetches the same relay');
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('name fetch failure keeps the URL-only footer and never claims disconnection', async () => {
|
||||
const f = managerFixture(async () => { throw Error('network unreachable'); });
|
||||
try {
|
||||
await f.controller.request({ id: 1, action: 'configure', values: { owner, relay: 'wss://down.example.invalid' } });
|
||||
await wait(() => f.snapshot.hostRelay === 'wss://down.example.invalid');
|
||||
await sleep(40);
|
||||
assert.equal(f.snapshot.relayName, undefined, 'no name is invented on failure');
|
||||
assert.equal(f.snapshot.service?.state, 'stopped', 'transport state is unchanged by the name failure');
|
||||
assert.equal(f.snapshot.hostRelay, 'wss://down.example.invalid');
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('a relay switch never labels the new relay with the old name and late completion is discarded', async () => {
|
||||
const gates = new Map<string, (value: string | undefined) => void>();
|
||||
const aborted: string[] = [];
|
||||
const f = managerFixture((relay, signal) => new Promise(resolve => { gates.set(relay, resolve); signal.addEventListener('abort', () => { aborted.push(relay); }); }));
|
||||
try {
|
||||
createControllerConfig(join(f.home, '.beehive', 'owner'), owner, 'wss://old.example.invalid');
|
||||
f.controller.refresh();
|
||||
await wait(() => gates.has('wss://old.example.invalid'));
|
||||
await f.controller.request({ id: 1, action: 'configure', values: { owner, relay: 'wss://new.example.invalid' } });
|
||||
assert.equal(f.snapshot.hostRelay, 'wss://new.example.invalid');
|
||||
assert.equal(f.snapshot.relayName, undefined, 'the pending name is cleared when the relay changes');
|
||||
assert.ok(aborted.includes('wss://old.example.invalid'), 'the previous fetch is cancelled');
|
||||
gates.get('wss://old.example.invalid')!('Old Relay Name');
|
||||
await sleep(30);
|
||||
assert.equal(f.snapshot.relayName, undefined, 'a late completion for the previous relay is discarded');
|
||||
gates.get('wss://new.example.invalid')!('New Relay Name');
|
||||
await wait(() => f.snapshot.relayName === 'New Relay Name');
|
||||
assert.equal(f.snapshot.hostRelay, 'wss://new.example.invalid');
|
||||
assert.ok(f.snapshots.every(s => s.hostRelay !== 'wss://new.example.invalid' || s.relayName !== 'Old Relay Name'), 'the new relay is never labelled with the old name');
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('closing the manager aborts the pending name fetch and publishes nothing afterwards', async () => {
|
||||
let gate!: (value: string | undefined) => void; let signal!: AbortSignal;
|
||||
const f = managerFixture((relay, s) => { signal = s; return new Promise(resolve => { gate = resolve; }); });
|
||||
try {
|
||||
await f.controller.request({ id: 1, action: 'configure', values: { owner, relay: 'wss://late.example.invalid' } });
|
||||
await wait(() => typeof gate === 'function' && f.snapshot.service?.state === 'stopped');
|
||||
const published = f.snapshots.length;
|
||||
f.controller.close();
|
||||
assert.ok(signal.aborted, 'close cancels the in-flight document read');
|
||||
gate('Late Name');
|
||||
await sleep(30);
|
||||
assert.equal(f.snapshots.length, published, 'no snapshot is published after close');
|
||||
assert.equal(f.controller.snapshot().relayName, undefined, 'a late name is never applied after close');
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
Reference in New Issue
Block a user