diff --git a/beehive/PUBLIC_METADATA.md b/beehive/PUBLIC_METADATA.md index e870b84774..8b9acc7594 100644 --- a/beehive/PUBLIC_METADATA.md +++ b/beehive/PUBLIC_METADATA.md @@ -65,8 +65,15 @@ kind0 by the agent pubkey. Public readback never replaces the owner's draft. operations never repeat the public side effect, including after a host crash. If interruption loses the terminal observation, status remains unknown; this bounded slice does not automatically republish or turn queue persistence into -success. Check the desired draft, wait for fresh host state, then deliberately -submit a new publish attempt if needed. A same-second retained signed attempt or same-second/future-dated latest kind0 +success. An authority interruption (host close, revision/assignment change or +persistence failure) **after the signed event POST** — while reading the /events +response or during the final latest-kind0 query — reports the dedicated +`metadata publication interrupted after submission; outcome unknown; reconcile +locally` result with status **unknown**, never a definitive failure, because the +relay may already hold the accepted event; the durable candidate and operation +ID are retained. The same interruption before submission or at the /events entry +is a definitive refusal. Check the desired draft, wait for fresh host state, then +deliberately submit a new publish attempt if needed. A same-second retained signed attempt or same-second/future-dated latest kind0 is refused rather than inventing a timestamp or gambling on Nostr event-ID tie ordering; wait until its timestamp is past before retrying. Stale host revisions and old operation IDs cannot overwrite a newer accepted edit. ACK refusal, @@ -92,6 +99,12 @@ Beehive likewise never derives about from private behavior text. consumer and signature-enforcing loopback HTTP consumer with synthetic keys and an explicitly isolated credential adapter. It checks stopped publication with nonexistent ACP/provider executables, negative ACK/readback, wrong owner/relay, -malformed public payload, stale revision, missing key and timestamp ordering. -This is source-shaped relay evidence, not a production relay, OS credential, +malformed public payload, stale revision, missing key and timestamp ordering, +plus pre-send and post-submission host authority interruption: a preliminary-query +or /events-entry close is a definitive refusal with no event POST, while a close +after the accepted event POST leaves status unknown with the retained candidate, +exactly one POST, and no duplicate POST or credential read across host reopen and +owner reconcile. A phase-indexed direct publisher case covers the /events +post-body, final-query entry and final-query post-body boundaries. This is +source-shaped relay evidence, not a production relay, OS credential, installed-native or live-person profile publication claim. diff --git a/beehive/src/intents.ts b/beehive/src/intents.ts index 59e7851e81..04d9b531c3 100644 --- a/beehive/src/intents.ts +++ b/beehive/src/intents.ts @@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, lstatSync } from 'node:fs import { join } from 'node:path'; import { connect, validateRelayURL } from './client.ts'; import { digest, fields, message, object, open, publicKey, seal, type Envelope, type Message } from './protocol.ts'; +import { metadataPublicationInterrupted } from './public-metadata.ts'; import { readPrivate, writePrivate } from './storage.ts'; type Intent = { envelope: Envelope; request: Message; receipt?: Message; published: boolean; blocked?: boolean }; @@ -129,7 +130,7 @@ export function managementClient(root: string, url: string, secret: string, rece }, status() { return [...intents.values()].map(i => ({ request: structuredClone(i.request), - state: i.request.type === 'profile' && i.published ? 'completed' : i.receipt ? ((['accepted','saved; running configuration unchanged'].includes(String(i.receipt.body.result)) || (i.request.type === 'metadata' && /^published; signed latest kind0 [a-f0-9]{64}$/.test(String(i.receipt.body.result)))) ? 'completed' : i.receipt.body.result === 'interrupted-reconcile-locally' ? 'unknown' : 'failed') : i.blocked ? 'unknown' : 'pending', + state: i.request.type === 'profile' && i.published ? 'completed' : i.receipt ? ((['accepted','saved; running configuration unchanged'].includes(String(i.receipt.body.result)) || (i.request.type === 'metadata' && /^published; signed latest kind0 [a-f0-9]{64}$/.test(String(i.receipt.body.result)))) ? 'completed' : (i.receipt.body.result === 'interrupted-reconcile-locally' || (i.request.type === 'metadata' && i.receipt.body.result === metadataPublicationInterrupted)) ? 'unknown' : 'failed') : i.blocked ? 'unknown' : 'pending', retryAvailable: !i.receipt && !(i.request.type === 'profile' && i.published) && !!i.blocked, publication: i.request.type === 'profile' && i.published ? 'immutable publication observed; no agent application' : i.receipt ? 'host terminal receipt' : i.blocked ? 'relay policy failure; automatic retry disabled; reconcile, then retry after policy repair' : i.published ? 'relay observed; not host admission' : 'unconfirmed', result: i.receipt?.body.result })); diff --git a/beehive/src/public-metadata.ts b/beehive/src/public-metadata.ts index 88a008b57f..7f56e4197c 100644 --- a/beehive/src/public-metadata.ts +++ b/beehive/src/public-metadata.ts @@ -41,6 +41,11 @@ export function metadataRelay(relay: string): string { u.protocol = u.protocol === 'wss:' ? 'https:' : 'http:'; return u.href.replace(/\/$/, ''); } +/** Post-submission authority interruption cannot observe the relay outcome. This + * dedicated metadata-only result keeps management states truthful: UNKNOWN, never + * a definitive failure. Pre-send and /events-entry refusals stay definitive. + */ +export const metadataPublicationInterrupted = 'metadata publication interrupted after submission; outcome unknown; reconcile locally'; /** Publish only after governing consumer authorization. Query before signing to * avoid kind0 same-second tie ordering; exact signed readback is separate from ACK. * No retry loop, provider, ACP, or credential backend is opened here. @@ -49,8 +54,11 @@ export async function publishPublicMetadata(input: { relay: string; agent: strin const value = publicMetadata(input.value), base = metadataRelay(input.relay); if (publicKey(input.secret) !== input.agent) throw Error('Agent signer mismatch'); const tags = metadataAuthorization(input.authTag, input.agent, input.owner, Math.floor(Date.now() / 1000)); - async function request(path: string, body: unknown): Promise { - input.check(); + /** Guard unchanged. An interruption while a submission may already be live on the + * relay is an unknown outcome; anything before that is a definitive refusal. */ + const guard = (submissionLive: boolean) => { try { input.check(); } catch (error) { throw submissionLive ? Error(metadataPublicationInterrupted, { cause: error }) : error; } }; + async function request(path: string, body: unknown, submitted = false): Promise { + guard(submitted); const content = JSON.stringify(body), url = `${base}/${path}`, now = Math.floor(Date.now() / 1000); metadataAuthorization(input.authTag, input.agent, input.owner, now); const auth = finalizeEvent({ kind: 27235, created_at: now, tags: [['u', url], ['method', 'POST'], ['payload', digest(content).toString('hex')], ['nonce', randomUUID()]], content: '' }, Buffer.from(input.secret, 'hex')); @@ -59,10 +67,11 @@ export async function publishPublicMetadata(input: { relay: string; agent: strin const reader = response.body?.getReader(); if (!reader) throw Error('Missing public relay response'); const chunks: Uint8Array[] = []; let size = 0; try { for (;;) { const { done, value: part } = await reader.read(); if (done) break; size += part.length; if (size > 65536) throw Error('Public relay response too large'); chunks.push(part); } } finally { await reader.cancel(); } - input.check(); return JSON.parse(Buffer.concat(chunks).toString()); + guard(submitted || path === 'events'); // Reading the /events response means this submission may be live. + return JSON.parse(Buffer.concat(chunks).toString()); } - async function latest(): Promise { - const rows = await request('query', [{ authors: [input.agent], kinds: [0], limit: 1 }]); + async function latest(submitted = false): Promise { + const rows = await request('query', [{ authors: [input.agent], kinds: [0], limit: 1 }], submitted); if (!Array.isArray(rows) || rows.length > 1) throw Error('Invalid public metadata readback'); if (!rows.length) return undefined; const event = rows[0] as Event; @@ -77,7 +86,7 @@ export async function publishPublicMetadata(input: { relay: string; agent: strin input.retain(event); // Durable exact candidate before network effect, including unknown outcome. const ack = object(await request('events', event)); if (ack.accepted !== true || ack.event_id !== event.id) throw Error('Public relay did not acknowledge this event; publication unknown'); - const observed = await latest(); + const observed = await latest(true); if (observed?.id !== event.id) throw Error('Public event submitted but latest readback differs; publication unknown'); return `published; signed latest kind0 ${event.id}`; } diff --git a/beehive/test/public-metadata.test.ts b/beehive/test/public-metadata.test.ts index cd429e0e96..fdae90d171 100644 --- a/beehive/test/public-metadata.test.ts +++ b/beehive/test/public-metadata.test.ts @@ -8,7 +8,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import { schnorr } from '@noble/curves/secp256k1'; import { finalizeEvent, verifyEvent, type Event } from 'nostr-tools/pure'; import { digest, newKey, publicKey, message, type Message } from '../src/protocol.ts'; -import { publicMetadata, metadataRevision, metadataAuthorization } from '../src/public-metadata.ts'; +import { publicMetadata, metadataRevision, metadataAuthorization, publishPublicMetadata, metadataPublicationInterrupted } from '../src/public-metadata.ts'; import { metadataDrafts } from '../src/metadata-drafts.ts'; import { profile, profileRevision } from '../src/profiles.ts'; import { profileDrafts } from '../src/profile-drafts.ts'; @@ -60,7 +60,8 @@ test('private owner authorizes stopped-agent kind0 with agent NIP98, exact readb const owner = newKey(), hostSecret = newKey(), agentSecret = newKey(), siblingSecret = newKey(), stranger = newKey(); const ownerKey = publicKey(owner), hostKey = publicKey(hostSecret), agent = publicKey(agentSecret), sibling = publicKey(siblingSecret); const authTag = association(owner, agent); const httpErrors: unknown[] = [], events: Event[] = [], authIds = new Set(); - let url = '', latest: Event | undefined, deny = false, hideReadback = false, reads = 0; + let url = '', latest: Event | undefined, deny = false, hideReadback = false, reads = 0, closeOnQuery = false, closeOnEvents = false; + let hostClosing: Promise | undefined; const relay = await nostrFixture(ownerKey, new Set([ownerKey, hostKey, publicKey(stranger)]), {}, (req, res) => { if (req.method !== 'POST') return false; let body = ''; req.on('data', b => body += b); @@ -74,13 +75,13 @@ test('private owner authorizes stopped-agent kind0 with agent NIP98, exact readb assert(auth.tags.some(t => t[0] === 'payload' && t[1] === digest(body).toString('hex'))); assert.equal(req.headers['x-auth-tag'], authTag); assert(schnorr.verify(JSON.parse(authTag)[3], digest(`nostr:agent-auth:${agent}:`), ownerKey)); metadataAuthorization(authTag, agent, ownerKey, auth.created_at); res.setHeader('Content-Type', 'application/json'); - if (req.url === '/query') { assert.deepEqual(JSON.parse(body), [{ authors: [agent], kinds: [0], limit: 1 }]); res.end(JSON.stringify(latest && !hideReadback ? [latest] : [])); } + if (req.url === '/query') { assert.deepEqual(JSON.parse(body), [{ authors: [agent], kinds: [0], limit: 1 }]); if (closeOnQuery) hostClosing = service?.close(); res.end(JSON.stringify(latest && !hideReadback ? [latest] : [])); } else { assert.equal(req.url, '/events'); const e = JSON.parse(body) as Event; assert(verifyEvent(e)); assert.equal(e.kind, 0); assert.equal(e.pubkey, agent); assert.deepEqual(e.tags, [JSON.parse(authTag)]); assert.deepEqual(Object.keys(JSON.parse(e.content)).sort(), ['about', 'display_name', 'picture']); assert(!body.includes('PRIVATE INSTRUCTIONS')); if (deny) { res.end(JSON.stringify({ event_id: e.id, accepted: false, message: 'refused' })); return; } - latest = e; events.push(e); res.end(JSON.stringify({ event_id: e.id, accepted: true, message: 'saved' })); + latest = e; events.push(e); if (closeOnEvents) hostClosing = service?.close(); res.end(JSON.stringify({ event_id: e.id, accepted: true, message: 'saved' })); } } catch (e) { httpErrors.push(e); res.writeHead(400).end('{}'); } }); return true; @@ -157,6 +158,38 @@ test('private owner authorizes stopped-agent kind0 with agent NIP98, exact readb await service.close(); service = undefined; service = await host(directory, url, undefined, privateHostTransport(pairing, hostSecret), credentials); assert.deepEqual(state().publicMetadata, persisted); assert.equal(events.length, 2, 'host reopen never silently retries unknown public side effects'); + // Authority interruption while a submission may be live is UNKNOWN, never a + // definitive failure: the relay may already hold the accepted kind0 event. + await until(() => Math.floor(Date.now() / 1000) > state().publicMetadata.event.created_at); + hideReadback = false; closeOnQuery = true; + const refused = op({ ...value, about: 'closed-before-send' }); + client.submit(refused); + await until(() => hostClosing !== undefined); await hostClosing; hostClosing = undefined; + closeOnQuery = false; + service = await host(directory, url, undefined, privateHostTransport(pairing, hostSecret), credentials); + client.reconcile(); // Fresh <=6s host availability: reconcile immediately after reopen. + await until(() => client.status().some(s => s.request.id === refused.id && s.result === 'Metadata authority invalidated')); + const refusedRow = client.status().find(s => s.request.id === refused.id)!; + assert.equal(refusedRow.state, 'failed', 'pre-send close stays a definitive refusal'); + assert.equal(events.length, 2, 'no event POST before the authority close'); + // Accepted/stored event, then the host closes while reading the /events + // response: the receipt reports the dedicated unknown result, not failure. + await until(() => Math.floor(Date.now() / 1000) > state().publicMetadata.event.created_at); + closeOnEvents = true; + const interrupted = op({ ...value, about: 'interrupted-after-send' }); + client.submit(interrupted); + await until(() => hostClosing !== undefined); await hostClosing; hostClosing = undefined; + closeOnEvents = false; + service = await host(directory, url, undefined, privateHostTransport(pairing, hostSecret), credentials); + reads = 0; // The reopened host already hydrated credentials during startup. + client.reconcile(); + await until(() => client.status().some(s => s.request.id === interrupted.id && s.result === metadataPublicationInterrupted)); + const interruptedRow = client.status().find(s => s.request.id === interrupted.id)!; + assert.equal(interruptedRow.state, 'unknown', 'post-send authority interruption must not be reported failed'); + assert.equal(events.length, 3, 'exactly one event POST for the interrupted attempt'); + assert.equal(state().publicMetadata.event.id, events[2]!.id, 'interrupted candidate retained durably'); + assert.equal(state().publicMetadata.operation, interrupted.id); + assert.equal(reads, 0, 'replay/reconcile never duplicates the POST or the credential read'); assert.deepEqual(httpErrors, []); assert.equal(readFileSync(journalPath(sibling), 'utf8'), other); assert.equal(metadataDrafts(root).list()[0]!.value.about, value.about); } finally { client.close(); attacker.close(); await service?.close(); await relay.close(); rmSync(root, { recursive: true, force: true }); } @@ -170,3 +203,61 @@ test('existing OA association rejects wrong owner, agent, conditions and forged assert.throws(() => metadataAuthorization(association(owner, agent, 'kind=0'), agent, publicKey(owner), now)); assert.throws(() => metadataAuthorization(association(owner, agent, 'created_at<1'), agent, publicKey(owner), now)); }); + +test('authority interruption is unknown only once a submission may be live, definitive before it', async () => { + const owner = newKey(), agentSecret = newKey(); const ownerKey = publicKey(owner), agent = publicKey(agentSecret); + const authTag = association(owner, agent); + const posted: Event[] = [], httpErrors: unknown[] = []; + let latest: Event | undefined; + const relay = await nostrFixture(ownerKey, new Set([ownerKey]), {}, (req, res) => { + if (req.method !== 'POST') return false; + let body = ''; req.on('data', b => body += b); + req.on('end', () => { + try { + const auth = JSON.parse(Buffer.from(String(req.headers.authorization).slice(6), 'base64').toString()) as Event; + assert(verifyEvent(auth)); assert.equal(auth.pubkey, agent); assert.equal(auth.kind, 27235); + assert.equal(req.headers['x-auth-tag'], authTag); + metadataAuthorization(authTag, agent, ownerKey, auth.created_at); + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/query') { assert.deepEqual(JSON.parse(body), [{ authors: [agent], kinds: [0], limit: 1 }]); res.end(JSON.stringify(latest ? [latest] : [])); } + else { + assert.equal(req.url, '/events'); const e = JSON.parse(body) as Event; + assert(verifyEvent(e)); assert.equal(e.kind, 0); assert.equal(e.pubkey, agent); assert.deepEqual(e.tags, [JSON.parse(authTag)]); + latest = e; posted.push(e); res.end(JSON.stringify({ event_id: e.id, accepted: true, message: 'saved' })); + } + } catch (e) { httpErrors.push(e); res.writeHead(400).end('{}'); } + }); return true; + }); + try { + /** Deterministic phase-indexed host guard: invalidates at exactly the Nth check + * (1 query entry, 2 query post-body, 3 pre-sign, 4 events entry, 5 events + * post-body, 6 final-query entry, 7 final-query post-body). */ + async function attempt(invalidatedAt: number): Promise<[string, Event | undefined]> { + let checks = 0; let retained: Event | undefined; + let outcome: string; + try { + await publishPublicMetadata({ relay: relay.url, agent, owner: ownerKey, secret: agentSecret, authTag, value, + check: () => { if (++checks >= invalidatedAt) throw Error('Metadata authority invalidated'); }, + retain: event => { retained = event; } }); + outcome = 'published'; + } catch (error) { outcome = (error as Error).message; } + return [outcome, retained]; + } + // Pre-send query checks and the /events entry are definitive refusals: no POST. + for (const invalidatedAt of [2, 4]) { + assert.equal((await attempt(invalidatedAt))[0], 'Metadata authority invalidated'); + assert.equal(posted.length, 0, 'definitive refusal never reaches the event POST'); + } + // After the accepted /events response — its post-body check, the final-query + // entry check and the final-query post-body check — the relay outcome is + // unobserved: the dedicated unknown result, never a definitive failure. + for (const invalidatedAt of [5, 6, 7]) { + const [outcome, retained] = await attempt(invalidatedAt); + assert.equal(outcome, metadataPublicationInterrupted); + assert.equal(posted.length, invalidatedAt - 4, 'exactly one event POST per interrupted attempt'); + assert.equal(retained!.id, posted.at(-1)!.id, 'durable candidate is the submitted event'); + await until(() => Math.floor(Date.now() / 1000) > latest!.created_at); + } + assert.deepEqual(httpErrors, []); + } finally { await relay.close(); } +});