import { createHash, randomUUID } from 'node:crypto'; import { schnorr } from '@noble/curves/secp256k1.js'; import { finalizeEvent, getPublicKey, nip19, type Event, verifyEvent } from 'nostr-tools'; import { NotificationDeliveryError, type Notification, type NotificationEnvelope, type NotificationReceipt, type Notifier, } from './notifier'; interface BuzzNotifierOptions { relayUrl: string; privateKey: string; authTag?: string; fetchImpl?: typeof fetch; now?: () => Date; nonce?: () => string; } interface BuzzRelayResponse { event_id?: string; accepted?: boolean; } export class BuzzNotifier implements Notifier { readonly provider = 'buzz'; readonly relayUrl: string; readonly workspaceId: string; private readonly eventsUrl: string; private readonly secretKey: Uint8Array; private readonly publicKey: string; private readonly authTag: string[] | null; private readonly authTagJson: string | null; private readonly fetchImpl: typeof fetch; private readonly now: () => Date; private readonly nonce: () => string; constructor(options: BuzzNotifierOptions) { this.relayUrl = normaliseBuzzRelayUrl(options.relayUrl); this.workspaceId = new URL(this.relayUrl).host; this.eventsUrl = `${this.relayUrl}/events`; this.secretKey = parseBuzzPrivateKey(options.privateKey); this.publicKey = getPublicKey(this.secretKey); const parsedAuth = options.authTag ? parseAndVerifyBuzzAuthTag(options.authTag, this.publicKey) : null; this.authTag = parsedAuth; this.authTagJson = parsedAuth ? JSON.stringify(parsedAuth) : null; this.fetchImpl = options.fetchImpl ?? fetch; this.now = options.now ?? (() => new Date()); this.nonce = options.nonce ?? randomUUID; } async send( envelope: NotificationEnvelope, signal?: AbortSignal, ): Promise { if (envelope.workspaceId && envelope.workspaceId !== this.workspaceId) { throw new NotificationDeliveryError('buzz_workspace_mismatch', false); } if (!isUuid(envelope.destination)) { throw new NotificationDeliveryError('invalid_buzz_channel', false); } const createdAt = notificationTimestamp(envelope.notification); if (this.authTag && !buzzAuthConditionsAllow(this.authTag[2]!, 9, createdAt)) { throw new NotificationDeliveryError('buzz_auth_tag_conditions', false); } const event = finalizeEvent( { kind: 9, created_at: createdAt, tags: [ ['h', envelope.destination], [ 'client', 'PIG', createHash('sha256').update(envelope.idempotencyKey).digest('hex'), ], ...(this.authTag ? [this.authTag] : []), ], content: formatBuzzNotification(envelope.notification), }, this.secretKey, ); const body = JSON.stringify(event); const authorization = this.createNip98Authorization(body); let response: Response; try { response = await this.fetchImpl(this.eventsUrl, { method: 'POST', headers: { authorization, 'content-type': 'application/json', ...(this.authTagJson ? { 'x-auth-tag': this.authTagJson } : {}), }, body, signal, }); } catch { // The event id is content-addressed and remains stable on a retry, so an // ambiguous network outcome cannot create a second Buzz message. throw new NotificationDeliveryError('buzz_network_error', true); } if (response.status === 429) { throw new NotificationDeliveryError( 'buzz_rate_limited', true, retryAfterMs(response.headers.get('retry-after')), ); } if (response.status === 408 || response.status >= 500) { throw new NotificationDeliveryError('buzz_unavailable', true); } if (!response.ok) { throw new NotificationDeliveryError(`buzz_http_${response.status}`, false); } let result: BuzzRelayResponse; try { result = (await response.json()) as BuzzRelayResponse; } catch { throw new NotificationDeliveryError('invalid_buzz_response', true); } if (result.accepted !== true || !isEventId(result.event_id)) { throw new NotificationDeliveryError('buzz_rejected', false); } if (result.event_id !== event.id) { throw new NotificationDeliveryError('buzz_event_id_mismatch', false); } return { externalId: event.id }; } private createNip98Authorization(body: string): string { const authEvent = finalizeEvent( { kind: 27235, created_at: Math.floor(this.now().getTime() / 1_000), tags: [ ['u', this.eventsUrl], ['method', 'POST'], ['nonce', this.nonce()], ['payload', createHash('sha256').update(body).digest('hex')], ], content: '', }, this.secretKey, ); return `Nostr ${Buffer.from(JSON.stringify(authEvent), 'utf8').toString('base64')}`; } } export function normaliseBuzzRelayUrl(value: string): string { const trimmed = value.trim().replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:'); let url: URL; try { url = new URL(trimmed); } catch { throw new Error('BUZZ_RELAY_URL must be a valid http(s) or ws(s) URL.'); } if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { throw new Error('BUZZ_RELAY_URL must be a credential-free http(s) or ws(s) URL.'); } url.pathname = url.pathname.replace(/\/+$/, ''); url.search = ''; url.hash = ''; return url.toString().replace(/\/$/, ''); } export function parseBuzzPrivateKey(value: string): Uint8Array { const trimmed = value.trim(); let key: Uint8Array; try { if (trimmed.startsWith('nsec1')) { const decoded = nip19.decode(trimmed); if (decoded.type !== 'nsec') throw new Error('wrong key type'); key = decoded.data; } else { if (!/^[a-f0-9]{64}$/i.test(trimmed)) throw new Error('invalid hex'); key = Uint8Array.from(Buffer.from(trimmed, 'hex')); } getPublicKey(key); } catch { throw new Error('BUZZ_PRIVATE_KEY must be a valid 32-byte hex or nsec private key.'); } return key; } export function parseAndVerifyBuzzAuthTag(value: string, agentPublicKey: string): string[] { let tag: unknown; try { tag = JSON.parse(value); } catch { throw new Error('BUZZ_AUTH_TAG must be a valid JSON array.'); } if ( !Array.isArray(tag) || tag.length !== 4 || tag.some((part) => typeof part !== 'string') || tag[0] !== 'auth' || !/^[a-f0-9]{64}$/.test(tag[1] as string) || !/^[a-f0-9]{128}$/.test(tag[3] as string) ) { throw new Error('BUZZ_AUTH_TAG has an invalid NIP-OA structure.'); } const parts = tag as string[]; validateBuzzAuthConditions(parts[2]!); if (parts[1] === agentPublicKey) { throw new Error('BUZZ_AUTH_TAG must be signed by an owner distinct from the agent.'); } const digest = createHash('sha256') .update(`nostr:agent-auth:${agentPublicKey}:${parts[2]}`) .digest(); let verified = false; try { verified = schnorr.verify(hexBytes(parts[3]!), digest, hexBytes(parts[1]!)); } catch { verified = false; } if (!verified) throw new Error('BUZZ_AUTH_TAG signature does not authorize this agent key.'); return parts; } function notificationTimestamp(notification: Notification): number { const value = notification.kind === 'stage_change' ? notification.changedAt : notification.observedAt; const milliseconds = Date.parse(value); if (!Number.isFinite(milliseconds)) { throw new NotificationDeliveryError('invalid_notification_timestamp', false); } return Math.floor(milliseconds / 1_000); } function formatBuzzNotification(notification: Notification): string { if (notification.kind === 'stage_change') { return [ `**${notification.dealName}** moved from \`${notification.fromStage}\` to \`${notification.toStage}\`.`, `${notification.dealSide === 'demand' ? 'Demand' : 'Supply'} pipeline stage changed in PIG.`, ].join('\n'); } return [ `**Idle capacity: ${notification.commitmentName}**`, `${notification.gpuType} has ${formatNumber(notification.idleGpuHours)} unsold GPU-hours (${Math.round(notification.utilisation * 100)}% utilised).`, `Idle committed cost: ${formatMoney(notification.idleCostCents)}.`, ].join('\n'); } function formatNumber(value: number): string { return new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }).format(value); } function formatMoney(cents: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0, }).format(cents / 100); } function validateBuzzAuthConditions(conditions: string): void { if (conditions === '') return; for (const clause of conditions.split('&')) { const match = /^(kind=|created_at<|created_at>)(0|[1-9]\d*)$/.exec(clause); if (!match) throw new Error('BUZZ_AUTH_TAG contains invalid NIP-OA conditions.'); const value = Number(match[2]); const maximum = match[1] === 'kind=' ? 65_535 : 4_294_967_295; if (!Number.isSafeInteger(value) || value > maximum) { throw new Error('BUZZ_AUTH_TAG contains out-of-range NIP-OA conditions.'); } } } function buzzAuthConditionsAllow(conditions: string, kind: number, createdAt: number): boolean { if (conditions === '') return true; return conditions.split('&').every((clause) => { if (clause.startsWith('kind=')) return kind === Number(clause.slice(5)); if (clause.startsWith('created_at<')) return createdAt < Number(clause.slice(11)); if (clause.startsWith('created_at>')) return createdAt > Number(clause.slice(11)); return false; }); } function retryAfterMs(value: string | null): number | undefined { const seconds = Number(value); return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds, 3_600) * 1_000 : undefined; } function hexBytes(value: string): Uint8Array { return Uint8Array.from(Buffer.from(value, 'hex')); } function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } function isEventId(value: string | undefined): value is string { return Boolean(value && /^[a-f0-9]{64}$/.test(value)); } export function decodeBuzzAuthorization(value: string): Event | null { if (!value.startsWith('Nostr ')) return null; try { const event = JSON.parse(Buffer.from(value.slice(6), 'base64').toString('utf8')) as Event; return verifyEvent(event) ? event : null; } catch { return null; } }