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 });
|
||||
});
|
||||
|
||||
|
||||
142
packages/shade-server/tests/events.test.ts
Normal file
142
packages/shade-server/tests/events.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { createPrekeyServer, MemoryPrekeyStore, PrekeyServerEvents, signPayload } from '../src/index.js';
|
||||
import type { PrekeyServerEvent } from '../src/index.js';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { generateIdentityKeyPair } from '@shade/core';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
function b64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
function randBytes(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe('PrekeyServerEvents integration', () => {
|
||||
test('emits events for register, fetch, replenish, delete', async () => {
|
||||
const events = new PrekeyServerEvents();
|
||||
const received: PrekeyServerEvent[] = [];
|
||||
events.on((e) => received.push(e));
|
||||
|
||||
const store = new MemoryPrekeyStore();
|
||||
const app = createPrekeyServer({ crypto, store, disableRateLimit: true, events });
|
||||
|
||||
const alice = await generateIdentityKeyPair(crypto);
|
||||
|
||||
// Register
|
||||
const regBody = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
address: 'alice',
|
||||
identitySigningKey: b64(alice.signingPublicKey),
|
||||
identityDHKey: b64(alice.dhPublicKey),
|
||||
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
|
||||
oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }],
|
||||
});
|
||||
await app.request('/v1/keys/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(regBody),
|
||||
});
|
||||
|
||||
// Fetch bundle
|
||||
await app.request('/v1/keys/bundle/alice');
|
||||
|
||||
// Replenish
|
||||
const replenishBody = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
address: 'alice',
|
||||
oneTimePreKeys: [
|
||||
{ keyId: 200, publicKey: b64(randBytes(32)) },
|
||||
{ keyId: 201, publicKey: b64(randBytes(32)) },
|
||||
],
|
||||
});
|
||||
await app.request('/v1/keys/replenish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(replenishBody),
|
||||
});
|
||||
|
||||
// Delete
|
||||
const delBody = await signPayload(crypto, alice.signingPrivateKey, { address: 'alice' });
|
||||
await app.request('/v1/keys/alice', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(delBody),
|
||||
});
|
||||
|
||||
const names = received.map((e) => e.name);
|
||||
expect(names).toContain('server.identity_registered');
|
||||
expect(names).toContain('server.bundle_fetched');
|
||||
expect(names).toContain('server.prekeys_replenished');
|
||||
expect(names).toContain('server.identity_deleted');
|
||||
|
||||
// Verify hadOneTimePreKey is true on the fetch event
|
||||
const fetchEvent = received.find((e) => e.name === 'server.bundle_fetched');
|
||||
expect((fetchEvent!.data as any).hadOneTimePreKey).toBe(true);
|
||||
|
||||
// Verify replenish reports the right count
|
||||
const replenishEvent = received.find((e) => e.name === 'server.prekeys_replenished');
|
||||
expect((replenishEvent!.data as any).count).toBe(2);
|
||||
expect((replenishEvent!.data as any).totalAfter).toBe(2); // 1 - 1 (consumed) + 2 = 2
|
||||
});
|
||||
|
||||
test('emits server.rate_limited when limits trip', async () => {
|
||||
const events = new PrekeyServerEvents();
|
||||
const received: PrekeyServerEvent[] = [];
|
||||
events.on((e) => received.push(e));
|
||||
|
||||
// Rate limit ENABLED for this test
|
||||
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), events });
|
||||
|
||||
// Burn the register limit (5/hour)
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
const body = await signPayload(crypto, id.signingPrivateKey, {
|
||||
address: `user${i}`,
|
||||
identitySigningKey: b64(id.signingPublicKey),
|
||||
identityDHKey: b64(id.dhPublicKey),
|
||||
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
|
||||
});
|
||||
await app.request('/v1/keys/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.99' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitedEvents = received.filter((e) => e.name === 'server.rate_limited');
|
||||
expect(rateLimitedEvents.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('SECURITY: no key material in server event payloads', async () => {
|
||||
const events = new PrekeyServerEvents();
|
||||
const received: PrekeyServerEvent[] = [];
|
||||
events.on((e) => received.push(e));
|
||||
|
||||
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, events });
|
||||
const alice = await generateIdentityKeyPair(crypto);
|
||||
|
||||
const regBody = await signPayload(crypto, alice.signingPrivateKey, {
|
||||
address: 'alice',
|
||||
identitySigningKey: b64(alice.signingPublicKey),
|
||||
identityDHKey: b64(alice.dhPublicKey),
|
||||
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
|
||||
oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }],
|
||||
});
|
||||
await app.request('/v1/keys/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(regBody),
|
||||
});
|
||||
await app.request('/v1/keys/bundle/alice');
|
||||
|
||||
const json = JSON.stringify(received);
|
||||
// Same regex as core: no 32+ byte base64 or 32+ char hex
|
||||
const longBase64 = /[A-Za-z0-9+/]{43,}={0,2}/g;
|
||||
const longHex = /[0-9a-f]{32,}/gi;
|
||||
expect(json.match(longBase64) ?? []).toEqual([]);
|
||||
expect(json.match(longHex) ?? []).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user