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:
92
packages/shade-server/src/events.ts
Normal file
92
packages/shade-server/src/events.ts
Normal 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('');
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user