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:
47
packages/shade-observer/src/auth.ts
Normal file
47
packages/shade-observer/src/auth.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user