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>
48 lines
1.5 KiB
TypeScript
48 lines
1.5 KiB
TypeScript
import { UnauthorizedError, ConfigurationError } from '@shade/core';
|
|
import type { Context, Next } from 'hono';
|
|
|
|
/**
|
|
* Bearer token middleware for the observer.
|
|
*
|
|
* Reads token from `Authorization: Bearer <token>` header.
|
|
* For SSE endpoints (where browsers can't set headers), also accepts
|
|
* `?token=<token>` query parameter.
|
|
*
|
|
* Throws ConfigurationError if SHADE_OBSERVER_TOKEN is empty (refuses to start).
|
|
*/
|
|
export function createAuthMiddleware(token: string) {
|
|
if (!token || token.length < 16) {
|
|
throw new ConfigurationError(
|
|
'SHADE_OBSERVER_TOKEN must be set and at least 16 characters. Refusing to start.',
|
|
);
|
|
}
|
|
|
|
return async (c: Context, next: Next) => {
|
|
const header = c.req.header('Authorization');
|
|
let provided: string | null = null;
|
|
|
|
if (header && header.startsWith('Bearer ')) {
|
|
provided = header.slice(7);
|
|
} else {
|
|
// Allow query string for SSE (EventSource can't set headers)
|
|
provided = c.req.query('token') ?? null;
|
|
}
|
|
|
|
if (!provided || !constantTimeStringEqual(provided, token)) {
|
|
throw new UnauthorizedError('Invalid or missing observer token');
|
|
}
|
|
|
|
await next();
|
|
};
|
|
}
|
|
|
|
/** Constant-time string comparison (avoids timing attacks on token check) */
|
|
function constantTimeStringEqual(a: string, b: string): boolean {
|
|
if (a.length !== b.length) return false;
|
|
let diff = 0;
|
|
for (let i = 0; i < a.length; i++) {
|
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
}
|
|
return diff === 0;
|
|
}
|