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:
William Valentin
2026-02-05 19:11:25 -08:00
parent ad7fc241f1
commit f30a8bc318
21 changed files with 1878 additions and 2 deletions
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import {
isValidRequest,
parseMessage,
makeResponse,
makeError,
makeEvent,
ErrorCode,
} from './protocol.js';
describe('protocol', () => {
describe('isValidRequest', () => {
it('accepts valid request with params', () => {
expect(isValidRequest({ id: 1, method: 'agent.send', params: { message: 'hello' } })).toBe(true);
});
it('accepts valid request without params', () => {
expect(isValidRequest({ id: 1, method: 'system.health' })).toBe(true);
});
it('rejects missing id', () => {
expect(isValidRequest({ method: 'test' })).toBe(false);
});
it('rejects missing method', () => {
expect(isValidRequest({ id: 1 })).toBe(false);
});
it('rejects non-object', () => {
expect(isValidRequest('not an object')).toBe(false);
expect(isValidRequest(null)).toBe(false);
expect(isValidRequest(42)).toBe(false);
});
it('rejects non-numeric id', () => {
expect(isValidRequest({ id: 'abc', method: 'test' })).toBe(false);
});
it('rejects non-string method', () => {
expect(isValidRequest({ id: 1, method: 42 })).toBe(false);
});
it('rejects non-object params', () => {
expect(isValidRequest({ id: 1, method: 'test', params: 'bad' })).toBe(false);
});
});
describe('parseMessage', () => {
it('parses valid JSON into GatewayRequest', () => {
const msg = parseMessage('{"id":1,"method":"agent.send","params":{"message":"hi"}}');
expect(msg).toEqual({ id: 1, method: 'agent.send', params: { message: 'hi' } });
});
it('returns null for invalid JSON', () => {
expect(parseMessage('not json')).toBeNull();
});
it('returns null for valid JSON that is not a valid request', () => {
expect(parseMessage('{"method":"test"}')).toBeNull();
});
});
describe('makeResponse', () => {
it('creates a response message', () => {
expect(makeResponse(1, { status: 'ok' })).toEqual({
id: 1,
result: { status: 'ok' },
});
});
});
describe('makeError', () => {
it('creates an error message', () => {
expect(makeError(1, ErrorCode.MethodNotFound, 'Not found')).toEqual({
id: 1,
error: { code: -3, message: 'Not found' },
});
});
});
describe('makeEvent', () => {
it('creates an event message', () => {
expect(makeEvent(1, 'content', { text: 'hello' })).toEqual({
id: 1,
event: 'content',
data: { text: 'hello' },
});
});
});
});