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:
154
packages/shade-observer/tests/observer.test.ts
Normal file
154
packages/shade-observer/tests/observer.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user