feat(observer): M-Obs 1-3 — event bus, server hooks, observer backend

M-Obs 1: Event bus in @shade/core
- ShadeEventEmitter with typed event union, ring buffer for replay
- 12 event types covering session lifecycle, ratchet operations,
  prekey changes, identity rotation, trust changes
- Wired into ShadeSessionManager (zero overhead when not enabled)
- shortHash helper for safe display of public keys
- Security test: regex-checks event payloads contain no key material

M-Obs 2: Prekey server event hooks
- PrekeyServerEvents emitter mirroring core's pattern
- 5 server event types: registered, fetched, replenished, deleted, rate_limited
- Wired into all routes including the rate-limit error handler
- shortHash helper using crypto.subtle directly (no provider dep)

M-Obs 3: @shade/observer package
- StateAggregator subscribes to client + server events, builds rolling snapshot
- Hono routes: GET /api/state (snapshot), GET /api/events (SSE stream)
- Bearer token auth via SHADE_OBSERVER_TOKEN, query string for SSE
- Refuses to start with token < 16 chars (ConfigurationError)
- Static file serving for bundled dashboard at /dashboard/
- Placeholder dashboard renders when no built SPA present

220 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 18:49:51 +02:00
parent 75008b623a
commit b014f9b44c
17 changed files with 1364 additions and 5 deletions

View File

@@ -0,0 +1,92 @@
/**
* Prekey server event emitter.
*
* Mirrors @shade/core's ShadeEventEmitter for the server side. Emits
* structural facts only — no key material, no signatures, no plaintext.
*/
export interface PrekeyServerEventBase {
seq: number;
timestamp: number;
}
export interface PrekeyServerEventMap {
'server.identity_registered': { address: string; identityKeyHash: string };
'server.bundle_fetched': { address: string; hadOneTimePreKey: boolean };
'server.prekeys_replenished': { address: string; count: number; totalAfter: number };
'server.identity_deleted': { address: string };
'server.rate_limited': { route: string; key: string };
}
export type PrekeyServerEventName = keyof PrekeyServerEventMap;
export type PrekeyServerEvent = {
[K in PrekeyServerEventName]: PrekeyServerEventBase & { name: K; data: PrekeyServerEventMap[K] };
}[PrekeyServerEventName];
export type PrekeyServerEventListener = (event: PrekeyServerEvent) => void;
export class PrekeyServerEvents {
private listeners = new Set<PrekeyServerEventListener>();
private nextSeq = 1;
private buffer: PrekeyServerEvent[] = [];
private readonly maxBuffer: number;
constructor(options: { bufferSize?: number } = {}) {
this.maxBuffer = options.bufferSize ?? 1000;
}
on(listener: PrekeyServerEventListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
off(listener: PrekeyServerEventListener): void {
this.listeners.delete(listener);
}
emit<K extends PrekeyServerEventName>(name: K, data: PrekeyServerEventMap[K]): void {
const event = {
seq: this.nextSeq++,
timestamp: Date.now(),
name,
data,
} as PrekeyServerEvent;
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] Server event listener threw:', err);
}
}
}
getBufferedSince(since: number): PrekeyServerEvent[] {
return this.buffer.filter((e) => e.seq > since);
}
getRecent(n: number): PrekeyServerEvent[] {
return this.buffer.slice(-n);
}
get currentSeq(): number {
return this.nextSeq - 1;
}
}
/**
* Compute a short display hash from a public key.
* Identical algorithm to @shade/core/shortHash but inlined here to
* avoid circular dependency on CryptoProvider.
*
* Uses SHA-256 via crypto.subtle directly.
*/
export async function shortHash(key: Uint8Array): Promise<string> {
const buf = await globalThis.crypto.subtle.digest('SHA-256', key);
const arr = new Uint8Array(buf).slice(0, 8);
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
}

View File

@@ -3,6 +3,7 @@ import type { CryptoProvider } from '@shade/core';
import { createPrekeyRoutes } from './routes.js';
import { MemoryPrekeyStore } from './memory-store.js';
import type { PrekeyStore } from './store.js';
import type { PrekeyServerEvents } from './events.js';
export { createPrekeyRoutes } from './routes.js';
export { MemoryPrekeyStore } from './memory-store.js';
@@ -27,10 +28,16 @@ export function createPrekeyServer(options: {
crypto: CryptoProvider;
store?: PrekeyStore;
disableRateLimit?: boolean;
events?: PrekeyServerEvents;
}): Hono {
const store = options.store ?? new MemoryPrekeyStore();
return createPrekeyRoutes(store, options.crypto, { disableRateLimit: options.disableRateLimit });
return createPrekeyRoutes(store, options.crypto, {
disableRateLimit: options.disableRateLimit,
events: options.events,
});
}
export { RateLimiter, MemoryRateLimitStore } from './rate-limit.js';
export type { RateLimitStore, RateLimitConfig } from './rate-limit.js';
export { PrekeyServerEvents, shortHash as serverShortHash } from './events.js';
export type { PrekeyServerEvent, PrekeyServerEventName, PrekeyServerEventMap, PrekeyServerEventListener } from './events.js';

View File

@@ -1,9 +1,10 @@
import { Hono } from 'hono';
import type { CryptoProvider } from '@shade/core';
import { fromBase64, errorToHttpStatus, ShadeError, ValidationError } from '@shade/core';
import { fromBase64, errorToHttpStatus, ShadeError, ValidationError, RateLimitError } from '@shade/core';
import type { PrekeyStore } from './store.js';
import { verifyPayload, validateAddress } from './auth.js';
import { RateLimiter, MemoryRateLimitStore, REGISTER_LIMIT, FETCH_LIMIT, REPLENISH_LIMIT, DELETE_LIMIT } from './rate-limit.js';
import { PrekeyServerEvents, shortHash } from './events.js';
/** Max POST body size in bytes (64KB) */
const MAX_BODY_SIZE = 64 * 1024;
@@ -23,6 +24,8 @@ const MAX_BODY_SIZE = 64 * 1024;
export interface PrekeyRoutesOptions {
/** Disable rate limiting (for tests). Default: enabled. */
disableRateLimit?: boolean;
/** Optional event emitter for observability. */
events?: PrekeyServerEvents;
}
export function createPrekeyRoutes(
@@ -31,6 +34,7 @@ export function createPrekeyRoutes(
options: PrekeyRoutesOptions = {},
): Hono {
const app = new Hono();
const events = options.events;
// Rate limiters (one per route, per IP or per identity)
const rlStore = new MemoryRateLimitStore();
@@ -51,6 +55,13 @@ export function createPrekeyRoutes(
// Global error handler — maps ShadeError to HTTP status
app.onError((err, c) => {
if (err instanceof RateLimitError) {
// Emit rate-limited event before responding
events?.emit('server.rate_limited', {
route: c.req.routePath ?? c.req.path,
key: getClientIp(c),
});
}
if (err instanceof ShadeError) {
const status = errorToHttpStatus(err);
const body: any = err.toJSON();
@@ -101,6 +112,11 @@ export function createPrekeyRoutes(
await store.saveOneTimePreKeys(addr, keys);
}
if (events) {
const hash = await shortHash(signingKey);
events.emit('server.identity_registered', { address: addr, identityKeyHash: hash });
}
return c.json({ ok: true });
});
@@ -138,6 +154,11 @@ export function createPrekeyRoutes(
};
}
events?.emit('server.bundle_fetched', {
address,
hadOneTimePreKey: oneTimePreKey != null,
});
return c.json(bundle);
});
@@ -170,6 +191,11 @@ export function createPrekeyRoutes(
await store.saveOneTimePreKeys(addr, keys);
const count = await store.getOneTimePreKeyCount(addr);
events?.emit('server.prekeys_replenished', {
address: addr,
count: keys.length,
totalAfter: count,
});
return c.json({ ok: true, remaining: count });
});
@@ -195,6 +221,7 @@ export function createPrekeyRoutes(
await verifyPayload(crypto, identity.identitySigningKey, { ...body, address });
await store.deleteAll(address);
events?.emit('server.identity_deleted', { address });
return c.json({ ok: true });
});