/** * Inbox server event emitter. * * Mirrors `PrekeyServerEvents`. Emits structural facts only — no plaintext, * no signatures, no key material. Used by the observer dashboard and * operator metrics. */ export interface InboxServerEventBase { seq: number; timestamp: number; } export interface InboxServerEventMap { 'inbox.address_registered': { address: string; signingKeyHash: string }; 'inbox.address_deleted': { address: string }; 'inbox.blob_stored': { address: string; msgId: string; bytes: number; ttlSeconds: number }; 'inbox.blob_idempotent_replay': { address: string; msgId: string }; 'inbox.blob_fetched': { address: string; count: number; bytes: number }; 'inbox.blob_acked': { address: string; msgId: string }; 'inbox.expired_purged': { count: number }; 'inbox.rate_limited': { route: string; key: string }; 'inbox.quota_rejected': { address: string; reason: 'address-quota' | 'sender-quota' | 'body-too-large' }; // V4.7 — bridge presence transitions. Emitted on the 0↔1 boundary // across tracked transports for a given address. Long-poll is // intentionally NOT tracked: an LP client toggles in/out of a request // every few seconds, and the resulting flapping would dominate the // event stream. Push transports (WS, SSE) are also the only ones // where the ~50ms revoke window for `BroadcastChannel.removeMember` // matters — long-poll users are already on a slow path. 'inbox.peer_connected': { address: string; bridgeKind: 'ws' | 'sse' }; 'inbox.peer_disconnected': { address: string; bridgeKind: 'ws' | 'sse'; reason: 'closed' | 'error'; }; } export type InboxServerEventName = keyof InboxServerEventMap; export type InboxServerEvent = { [K in InboxServerEventName]: InboxServerEventBase & { name: K; data: InboxServerEventMap[K] }; }[InboxServerEventName]; export type InboxServerEventListener = (event: InboxServerEvent) => void; export class InboxServerEvents { private listeners = new Set(); private nextSeq = 1; private buffer: InboxServerEvent[] = []; private readonly maxBuffer: number; constructor(options: { bufferSize?: number } = {}) { this.maxBuffer = options.bufferSize ?? 1000; } on(listener: InboxServerEventListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } off(listener: InboxServerEventListener): void { this.listeners.delete(listener); } emit(name: K, data: InboxServerEventMap[K]): void { const event = { seq: this.nextSeq++, timestamp: Date.now(), name, data, } as InboxServerEvent; this.buffer.push(event); if (this.buffer.length > this.maxBuffer) this.buffer.shift(); for (const listener of this.listeners) { try { listener(event); } catch (err) { console.error('[Shade] Inbox event listener threw:', err); } } } getBufferedSince(since: number): InboxServerEvent[] { return this.buffer.filter((e) => e.seq > since); } getRecent(n: number): InboxServerEvent[] { return this.buffer.slice(-n); } get currentSeq(): number { return this.nextSeq - 1; } } export async function shortHash(key: Uint8Array): Promise { const buf = await globalThis.crypto.subtle.digest('SHA-256', key as unknown as ArrayBuffer); const arr = new Uint8Array(buf).slice(0, 8); return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join(''); }