mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user