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,154 @@
import { describe, test, expect } from 'bun:test';
import { createObserver, StateAggregator } from '../src/index.js';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import { ShadeSessionManager, ShadeEventEmitter } from '@shade/core';
import { PrekeyServerEvents } from '@shade/server';
const crypto = new SubtleCryptoProvider();
const TEST_TOKEN = 'test-token-must-be-at-least-16-chars';
describe('StateAggregator', () => {
test('aggregates client events into snapshot', async () => {
const events = new ShadeEventEmitter();
const agg = new StateAggregator(events);
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
await alice.initialize();
await alice.generateOneTimePreKeys(10);
const snap = agg.snapshot();
expect(snap.identity.fingerprint).toBeTruthy();
expect(snap.identity.registrationId).toBeGreaterThan(0);
expect(snap.prekeys.oneTimeRemaining).toBe(10);
expect(snap.prekeys.lastGenerated).toBeGreaterThan(0);
});
test('tracks sessions across encrypt/decrypt', async () => {
const events = new ShadeEventEmitter();
const agg = new StateAggregator(events);
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
await alice.initialize();
await bob.initialize();
const otpks = await bob.generateOneTimePreKeys(5);
const bundle = await bob.createPreKeyBundle();
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
await alice.initSessionFromBundle('bob', bundle);
const env = await alice.encrypt('bob', 'hello');
await bob.decrypt('alice', env);
const snap = agg.snapshot();
const aliceToBob = snap.sessions.find((s) => s.address === 'bob');
expect(aliceToBob).toBeDefined();
expect(aliceToBob!.messageCountSent).toBe(1);
});
test('tracks server events', () => {
const serverEvents = new PrekeyServerEvents();
const agg = new StateAggregator(undefined, serverEvents);
serverEvents.emit('server.identity_registered', { address: 'alice', identityKeyHash: 'abc' });
serverEvents.emit('server.identity_registered', { address: 'bob', identityKeyHash: 'def' });
serverEvents.emit('server.bundle_fetched', { address: 'alice', hadOneTimePreKey: true });
serverEvents.emit('server.bundle_fetched', { address: 'alice', hadOneTimePreKey: false });
serverEvents.emit('server.identity_deleted', { address: 'alice' });
const snap = agg.snapshot();
expect(snap.server.registeredIdentities.has('bob')).toBe(true);
expect(snap.server.registeredIdentities.has('alice')).toBe(false);
expect(snap.server.totalBundleFetches).toBe(2);
expect(snap.server.totalDeleted).toBe(1);
});
});
describe('Observer routes', () => {
test('refuses requests without token', async () => {
const events = new ShadeEventEmitter();
const observer = createObserver({ token: TEST_TOKEN, clientEvents: events });
const res = await observer.request('/api/state');
expect(res.status).toBe(401);
});
test('accepts requests with valid bearer token', async () => {
const events = new ShadeEventEmitter();
const observer = createObserver({ token: TEST_TOKEN, clientEvents: events });
const mgr = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
await mgr.initialize();
const res = await observer.request('/api/state', {
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.identity.fingerprint).toBeTruthy();
});
test('refuses requests with wrong token', async () => {
const events = new ShadeEventEmitter();
const observer = createObserver({ token: TEST_TOKEN, clientEvents: events });
const res = await observer.request('/api/state', {
headers: { Authorization: 'Bearer wrong-token-also-long-enough' },
});
expect(res.status).toBe(401);
});
test('accepts token via query string for SSE', async () => {
const events = new ShadeEventEmitter();
const observer = createObserver({ token: TEST_TOKEN, clientEvents: events });
// Just check that the auth middleware accepts the query token
const res = await observer.request(`/api/state?token=${TEST_TOKEN}`);
expect(res.status).toBe(200);
});
test('refuses startup with too-short token', () => {
expect(() => createObserver({ token: 'short' })).toThrow();
});
test('health endpoint works without auth', async () => {
const observer = createObserver({ token: TEST_TOKEN });
const res = await observer.request('/health');
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('ok');
});
test('snapshot reflects state after operations', async () => {
const events = new ShadeEventEmitter();
const observer = createObserver({ token: TEST_TOKEN, clientEvents: events });
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
await alice.initialize();
await bob.initialize();
const otpks = await bob.generateOneTimePreKeys(3);
const bundle = await bob.createPreKeyBundle();
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
await alice.initSessionFromBundle('bob', bundle);
const env = await alice.encrypt('bob', 'hi');
await bob.decrypt('alice', env);
const res = await observer.request('/api/state', {
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
});
const body = await res.json();
expect(body.sessions.length).toBeGreaterThan(0);
// Bob started with 3 OTPKs; Alice consumed one via X3DH PreKeyMessage decrypt
expect(body.prekeys.oneTimeRemaining).toBe(2);
});
test('placeholder dashboard renders when no dist', async () => {
const observer = createObserver({ token: TEST_TOKEN });
const res = await observer.request('/dashboard/');
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('Shade Observer');
});
});