From dfa54c7b8df037db3255f78599dc8f178da1d0b4 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 12 Sep 2026 11:44:03 -0400 Subject: [PATCH] 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 --- beehive/src/manager-controller.ts | 36 ++++- beehive/src/manager-view.ts | 2 +- beehive/src/opentui-screen.ts | 2 +- beehive/src/relay-name.ts | 68 +++++++++ beehive/test/manager-controller.test.ts | 2 +- beehive/test/manager-installed-loader.ts | 1 + beehive/test/opentui-screen.bun.ts | 17 +++ beehive/test/relay-name.test.ts | 182 +++++++++++++++++++++++ 8 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 beehive/src/relay-name.ts create mode 100644 beehive/test/relay-name.test.ts diff --git a/beehive/src/manager-controller.ts b/beehive/src/manager-controller.ts index 8391a9ca3d..4a56f0be92 100644 --- a/beehive/src/manager-controller.ts +++ b/beehive/src/manager-controller.ts @@ -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; target?: string; revision?: number }; export type ManagerItem = { id: string; label: string; detail: string; evidence?: string; disabled?: Record }; -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; diff --git a/beehive/src/manager-view.ts b/beehive/src/manager-view.ts index b5a6467469..627648f96f 100644 --- a/beehive/src/manager-view.ts +++ b/beehive/src/manager-view.ts @@ -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 () => { diff --git a/beehive/src/opentui-screen.ts b/beehive/src/opentui-screen.ts index c63869f0df..97c83cfd44 100644 --- a/beehive/src/opentui-screen.ts +++ b/beehive/src/opentui-screen.ts @@ -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) { diff --git a/beehive/src/relay-name.ts b/beehive/src/relay-name.ts new file mode 100644 index 0000000000..7dd2a92cb6 --- /dev/null +++ b/beehive/src/relay-name.ts @@ -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 { + 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); } +} diff --git a/beehive/test/manager-controller.test.ts b/beehive/test/manager-controller.test.ts index 725a70d0e9..81d17d6c46 100644 --- a/beehive/test/manager-controller.test.ts +++ b/beehive/test/manager-controller.test.ts @@ -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 }); } }; } diff --git a/beehive/test/manager-installed-loader.ts b/beehive/test/manager-installed-loader.ts index 30be41a241..a83fea5fec 100644 --- a/beehive/test/manager-installed-loader.ts +++ b/beehive/test/manager-installed-loader.ts @@ -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 }))'; diff --git a/beehive/test/opentui-screen.bun.ts b/beehive/test/opentui-screen.bun.ts index 0c6bd0ea5f..823c3062e2 100644 --- a/beehive/test/opentui-screen.bun.ts +++ b/beehive/test/opentui-screen.bun.ts @@ -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(); } +}); diff --git a/beehive/test/relay-name.test.ts b/beehive/test/relay-name.test.ts new file mode 100644 index 0000000000..06207b8223 --- /dev/null +++ b/beehive/test/relay-name.test.ts @@ -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(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise(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) { + const home = mkdtempSync(join(tmpdir(), 'beehive-relay-name-')); + const secrets = new Map(); + 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 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(); } +});