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,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { authenticateRequest } from './auth.js';
|
||||
import type { IncomingMessage } from 'http';
|
||||
|
||||
function mockRequest(headers: Record<string, string> = {}): IncomingMessage {
|
||||
return { headers } as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
describe('authenticateRequest', () => {
|
||||
describe('no auth configured', () => {
|
||||
it('allows all connections', () => {
|
||||
const result = authenticateRequest(mockRequest(), {});
|
||||
expect(result.authenticated).toBe(true);
|
||||
expect(result.identity).toBe('anonymous');
|
||||
});
|
||||
});
|
||||
|
||||
describe('token auth', () => {
|
||||
const config = { token: 'secret-token-123' };
|
||||
|
||||
it('accepts valid Bearer token', () => {
|
||||
const result = authenticateRequest(
|
||||
mockRequest({ authorization: 'Bearer secret-token-123' }),
|
||||
config,
|
||||
);
|
||||
expect(result.authenticated).toBe(true);
|
||||
expect(result.identity).toBe('token-user');
|
||||
});
|
||||
|
||||
it('rejects missing Authorization header', () => {
|
||||
const result = authenticateRequest(mockRequest(), config);
|
||||
expect(result.authenticated).toBe(false);
|
||||
expect(result.error).toContain('Authorization header required');
|
||||
});
|
||||
|
||||
it('rejects invalid token', () => {
|
||||
const result = authenticateRequest(
|
||||
mockRequest({ authorization: 'Bearer wrong-token' }),
|
||||
config,
|
||||
);
|
||||
expect(result.authenticated).toBe(false);
|
||||
expect(result.error).toContain('Invalid token');
|
||||
});
|
||||
|
||||
it('rejects non-Bearer format', () => {
|
||||
const result = authenticateRequest(
|
||||
mockRequest({ authorization: 'Basic dXNlcjpwYXNz' }),
|
||||
config,
|
||||
);
|
||||
expect(result.authenticated).toBe(false);
|
||||
expect(result.error).toContain('Invalid Authorization format');
|
||||
});
|
||||
|
||||
it('uses Tailscale identity when both token and tailscale are configured', () => {
|
||||
const result = authenticateRequest(
|
||||
mockRequest({
|
||||
authorization: 'Bearer secret-token-123',
|
||||
'tailscale-user-login': 'will@example.com',
|
||||
}),
|
||||
{ token: 'secret-token-123', tailscaleIdentity: true },
|
||||
);
|
||||
expect(result.authenticated).toBe(true);
|
||||
expect(result.identity).toBe('will@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale identity', () => {
|
||||
const config = { tailscaleIdentity: true };
|
||||
|
||||
it('extracts identity from Tailscale-User-Login header', () => {
|
||||
const result = authenticateRequest(
|
||||
mockRequest({ 'tailscale-user-login': 'will@example.com' }),
|
||||
config,
|
||||
);
|
||||
expect(result.authenticated).toBe(true);
|
||||
expect(result.identity).toBe('will@example.com');
|
||||
});
|
||||
|
||||
it('allows connections without Tailscale header (local access)', () => {
|
||||
const result = authenticateRequest(mockRequest(), config);
|
||||
expect(result.authenticated).toBe(true);
|
||||
expect(result.identity).toBe('anonymous');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user