feat(gateway): add WebSocket gateway with JSON-RPC protocol and auth
Phase 2 of the Flynn roadmap. Adds a WebSocket gateway server that starts alongside the Telegram bot, providing real-time API access to the agent, sessions, and tools. Protocol: JSON-RPC-like (request/response/event) over WebSocket. 8 methods: agent.send, agent.cancel, sessions.list, sessions.history, sessions.create, tools.list, tools.invoke, system.health. Auth: Bearer token + Tailscale identity header support. Session bridge: per-connection agent instances with shared model router. New files: src/gateway/ (protocol, router, server, auth, session-bridge, handlers for agent/sessions/tools/system). 57 new tests (181 total), typecheck clean.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import type { IncomingMessage } from 'http';
|
||||
|
||||
export interface AuthConfig {
|
||||
/** Static bearer token. If set, all connections must provide it. */
|
||||
token?: string;
|
||||
/** Trust Tailscale-User-Login header for identity. */
|
||||
tailscaleIdentity?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
authenticated: boolean;
|
||||
identity?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates a WebSocket upgrade request.
|
||||
*
|
||||
* Auth is checked in this order:
|
||||
* 1. If token is configured, validate Authorization header (Bearer token)
|
||||
* 2. If tailscaleIdentity is enabled, extract identity from Tailscale-User-Login header
|
||||
* 3. If no auth is configured, allow all connections
|
||||
*/
|
||||
export function authenticateRequest(req: IncomingMessage, config: AuthConfig): AuthResult {
|
||||
// If token auth is configured, it's required
|
||||
if (config.token) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader) {
|
||||
return { authenticated: false, error: 'Authorization header required' };
|
||||
}
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return { authenticated: false, error: 'Invalid Authorization format (expected: Bearer <token>)' };
|
||||
}
|
||||
|
||||
if (parts[1] !== config.token) {
|
||||
return { authenticated: false, error: 'Invalid token' };
|
||||
}
|
||||
|
||||
// Token is valid — check for Tailscale identity too
|
||||
const identity = extractTailscaleIdentity(req, config);
|
||||
return { authenticated: true, identity: identity ?? 'token-user' };
|
||||
}
|
||||
|
||||
// If Tailscale identity is configured (no token), use it
|
||||
if (config.tailscaleIdentity) {
|
||||
const identity = extractTailscaleIdentity(req, config);
|
||||
if (identity) {
|
||||
return { authenticated: true, identity };
|
||||
}
|
||||
// Tailscale identity configured but header not present — still allow (might be local)
|
||||
return { authenticated: true, identity: 'anonymous' };
|
||||
}
|
||||
|
||||
// No auth configured — allow all
|
||||
return { authenticated: true, identity: 'anonymous' };
|
||||
}
|
||||
|
||||
function extractTailscaleIdentity(req: IncomingMessage, config: AuthConfig): string | undefined {
|
||||
if (!config.tailscaleIdentity) return undefined;
|
||||
const header = req.headers['tailscale-user-login'];
|
||||
if (typeof header === 'string' && header.length > 0) {
|
||||
return header;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user