This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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<NotificationReceipt> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user