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:
Logan Johnson
2026-09-12 11:44:03 -04:00
parent 2a3fbf9078
commit dfa54c7b8d
8 changed files with 302 additions and 8 deletions

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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
View 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); }
}

View File

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

View File

@@ -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 }))';

View File

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

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