fix(beehive): report post-submission metadata interruption as unknown not failed

An authority-check exception after the accepted POST /events - while reading
the response or during the final latest-kind0 query - previously became the
host's generic failed receipt even though the relay may already hold the
signed event. publishPublicMetadata now maps authority-guard interruptions to
the dedicated metadataPublicationInterrupted result once a submission may be
live: after the /events response and across the final readback query entry
and post-body checks. Pre-send query checks and the /events entry remain
definitive refusals and the guard itself is unchanged. intents.ts maps the
metadata-only result to unknown, matching interrupted-reconcile-locally.

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 23:26:22 -04:00
parent e56e2434ad
commit caedde0d83
4 changed files with 129 additions and 15 deletions

View File

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

View File

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

View File

@@ -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<unknown> {
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<unknown> {
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<Event | undefined> {
const rows = await request('query', [{ authors: [input.agent], kinds: [0], limit: 1 }]);
async function latest(submitted = false): Promise<Event | undefined> {
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}`;
}

View File

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