312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|