feat(gateway): add node capability negotiation foundation
This commit is contained in:
@@ -138,6 +138,33 @@ describe('configSchema — server', () => {
|
||||
expect(result.server.discovery.txt).toEqual({});
|
||||
});
|
||||
|
||||
it('defaults node policy settings', () => {
|
||||
const result = configSchema.parse(minimalConfig);
|
||||
expect(result.server.nodes.enabled).toBe(false);
|
||||
expect(result.server.nodes.allowed_roles).toEqual(['companion']);
|
||||
expect(result.server.nodes.feature_gates).toEqual({});
|
||||
});
|
||||
|
||||
it('accepts custom node policy settings', () => {
|
||||
const result = configSchema.parse({
|
||||
...minimalConfig,
|
||||
server: {
|
||||
nodes: {
|
||||
enabled: true,
|
||||
allowed_roles: ['companion', 'observer'],
|
||||
feature_gates: {
|
||||
'ui.canvas': true,
|
||||
'fs.sync': false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.server.nodes.enabled).toBe(true);
|
||||
expect(result.server.nodes.allowed_roles).toEqual(['companion', 'observer']);
|
||||
expect(result.server.nodes.feature_gates['ui.canvas']).toBe(true);
|
||||
expect(result.server.nodes.feature_gates['fs.sync']).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts custom discovery settings', () => {
|
||||
const result = configSchema.parse({
|
||||
...minimalConfig,
|
||||
|
||||
@@ -79,6 +79,15 @@ const serverDiscoverySchema = z.object({
|
||||
txt: z.record(z.string(), z.string()).default({}),
|
||||
}).default({});
|
||||
|
||||
const serverNodePolicySchema = z.object({
|
||||
/** Enable node registration/capability RPC surface. */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Allowed node roles for node.register. */
|
||||
allowed_roles: z.array(z.string().min(1)).default(['companion']),
|
||||
/** Optional feature gates exposed via system/node capability APIs. */
|
||||
feature_gates: z.record(z.string(), z.boolean()).default({}),
|
||||
}).default({});
|
||||
|
||||
const serverSchema = z.object({
|
||||
tailscale: tailscaleSchema,
|
||||
localhost: z.boolean().default(true),
|
||||
@@ -97,6 +106,8 @@ const serverSchema = z.object({
|
||||
ws_rate_limit: wsRateLimitSchema,
|
||||
/** Per-session gateway lane queue behavior. */
|
||||
queue: laneQueueSchema,
|
||||
/** Optional companion-node registration/capability settings. */
|
||||
nodes: serverNodePolicySchema,
|
||||
/** Optional Bonjour/mDNS advertisement settings. */
|
||||
discovery: serverDiscoverySchema,
|
||||
});
|
||||
|
||||
@@ -354,6 +354,11 @@ export function createGateway(deps: GatewayDeps): GatewayServer {
|
||||
),
|
||||
},
|
||||
},
|
||||
nodes: {
|
||||
enabled: config.server.nodes.enabled,
|
||||
allowedRoles: config.server.nodes.allowed_roles,
|
||||
featureGates: config.server.nodes.feature_gates,
|
||||
},
|
||||
discovery: {
|
||||
enabled: config.server.discovery.enabled,
|
||||
serviceName: config.server.discovery.service_name,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { authenticateRequest } from './auth.js';
|
||||
import { authenticateRequest, authorizeNodeMethod } from './auth.js';
|
||||
import type { IncomingMessage } from 'http';
|
||||
|
||||
function mockRequest(headers: Record<string, string> = {}): IncomingMessage {
|
||||
@@ -127,3 +127,55 @@ describe('authenticateRequest', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizeNodeMethod', () => {
|
||||
it('allows non-node methods', () => {
|
||||
const result = authorizeNodeMethod({ enabled: false, method: 'system.health' });
|
||||
expect(result.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks node methods when node RPC is disabled', () => {
|
||||
const result = authorizeNodeMethod({ enabled: false, method: 'node.capabilities.get' });
|
||||
expect(result.authenticated).toBe(false);
|
||||
expect(result.error).toContain('disabled');
|
||||
});
|
||||
|
||||
it('allows node.register without prior registration', () => {
|
||||
const result = authorizeNodeMethod({ enabled: true, method: 'node.register' });
|
||||
expect(result.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('requires role for scoped node methods', () => {
|
||||
const result = authorizeNodeMethod({ enabled: true, method: 'node.capabilities.get' });
|
||||
expect(result.authenticated).toBe(false);
|
||||
expect(result.error).toContain('not registered');
|
||||
});
|
||||
|
||||
it('enforces allowed role list and method scopes', () => {
|
||||
const deniedRole = authorizeNodeMethod({
|
||||
enabled: true,
|
||||
method: 'node.capabilities.get',
|
||||
nodeRole: 'observer',
|
||||
allowedRoles: ['companion'],
|
||||
});
|
||||
expect(deniedRole.authenticated).toBe(false);
|
||||
|
||||
const deniedMethod = authorizeNodeMethod({
|
||||
enabled: true,
|
||||
method: 'node.admin.reset',
|
||||
nodeRole: 'companion',
|
||||
allowedRoles: ['companion'],
|
||||
roleScopes: { companion: ['node.capabilities.get'] },
|
||||
});
|
||||
expect(deniedMethod.authenticated).toBe(false);
|
||||
|
||||
const allowed = authorizeNodeMethod({
|
||||
enabled: true,
|
||||
method: 'node.capabilities.get',
|
||||
nodeRole: 'companion',
|
||||
allowedRoles: ['companion'],
|
||||
roleScopes: { companion: ['node.capabilities.get'] },
|
||||
});
|
||||
expect(allowed.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,14 @@ export interface AuthResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface NodeAuthScopeConfig {
|
||||
enabled: boolean;
|
||||
method: string;
|
||||
nodeRole?: string;
|
||||
allowedRoles?: string[];
|
||||
roleScopes?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates a WebSocket upgrade request or HTTP request.
|
||||
*
|
||||
@@ -69,6 +77,38 @@ export function authenticateRequest(req: IncomingMessage, config: AuthConfig): A
|
||||
return { authenticated: true, identity: 'anonymous' };
|
||||
}
|
||||
|
||||
export function authorizeNodeMethod(config: NodeAuthScopeConfig): AuthResult {
|
||||
if (!config.method.startsWith('node.')) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
|
||||
if (!config.enabled) {
|
||||
return { authenticated: false, error: 'Node RPC is disabled' };
|
||||
}
|
||||
|
||||
if (config.method === 'node.register') {
|
||||
return { authenticated: true };
|
||||
}
|
||||
|
||||
if (!config.nodeRole) {
|
||||
return { authenticated: false, error: 'Node not registered for this connection' };
|
||||
}
|
||||
|
||||
const allowedRoles = config.allowedRoles ?? [];
|
||||
if (allowedRoles.length > 0 && !allowedRoles.includes(config.nodeRole)) {
|
||||
return { authenticated: false, error: `Node role '${config.nodeRole}' is not allowed` };
|
||||
}
|
||||
|
||||
const defaultScopes = ['node.capabilities.get'];
|
||||
const roleScopes = config.roleScopes ?? {};
|
||||
const permitted = roleScopes[config.nodeRole] ?? defaultScopes;
|
||||
if (!permitted.includes(config.method)) {
|
||||
return { authenticated: false, error: `Method '${config.method}' is not permitted for node role '${config.nodeRole}'` };
|
||||
}
|
||||
|
||||
return { authenticated: true };
|
||||
}
|
||||
|
||||
function extractQueryToken(req: IncomingMessage): string | undefined {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
|
||||
@@ -17,3 +17,5 @@ export { createRoutingHandlers } from './routing.js';
|
||||
export type { RoutingHandlerDeps } from './routing.js';
|
||||
export { createHistoryHandlers } from './history.js';
|
||||
export type { HistoryHandlerDeps } from './history.js';
|
||||
export { createNodeHandlers } from './node.js';
|
||||
export type { NodeHandlerDeps, NodeRegistration, NodeConnectionState } from './node.js';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createNodeHandlers, type NodeConnectionState } from './node.js';
|
||||
|
||||
describe('node handlers', () => {
|
||||
it('registers node and returns negotiated capabilities', async () => {
|
||||
const states = new Map<string, NodeConnectionState>([['conn-1', {}]]);
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: { 'ui.canvas': true, 'dangerous.write': false },
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: (connectionId, registration) => {
|
||||
const prior = states.get(connectionId) ?? {};
|
||||
states.set(connectionId, { ...prior, node: registration });
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handlers['node.register']({
|
||||
id: 1,
|
||||
method: 'node.register',
|
||||
params: {
|
||||
connectionId: 'conn-1',
|
||||
nodeId: 'node-a',
|
||||
role: 'companion',
|
||||
protocolVersion: 1,
|
||||
capabilities: ['ui.canvas', 'dangerous.write'],
|
||||
},
|
||||
});
|
||||
|
||||
expect((result as { result: { registered: boolean } }).result.registered).toBe(true);
|
||||
const caps = (result as { result: { capabilities: { enabled: string[] } } }).result.capabilities.enabled;
|
||||
expect(caps).toEqual(['ui.canvas']);
|
||||
expect(states.get('conn-1')?.node?.role).toBe('companion');
|
||||
});
|
||||
|
||||
it('denies registration for disallowed roles', async () => {
|
||||
const states = new Map<string, NodeConnectionState>([['conn-1', {}]]);
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: {},
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: () => {},
|
||||
});
|
||||
|
||||
const result = await handlers['node.register']({
|
||||
id: 2,
|
||||
method: 'node.register',
|
||||
params: {
|
||||
connectionId: 'conn-1',
|
||||
nodeId: 'node-a',
|
||||
role: 'observer',
|
||||
protocolVersion: 1,
|
||||
capabilities: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect((result as { error: { message: string } }).error.message).toContain('not allowed');
|
||||
});
|
||||
|
||||
it('returns capabilities for registered node connections', async () => {
|
||||
const states = new Map<string, NodeConnectionState>([['conn-1', {
|
||||
node: {
|
||||
nodeId: 'node-a',
|
||||
role: 'companion',
|
||||
protocolVersion: 1,
|
||||
capabilities: ['ui.canvas'],
|
||||
registeredAt: Date.now(),
|
||||
},
|
||||
}]]);
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: { 'ui.canvas': true },
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: () => {},
|
||||
});
|
||||
|
||||
const result = await handlers['node.capabilities.get']({
|
||||
id: 3,
|
||||
method: 'node.capabilities.get',
|
||||
params: { connectionId: 'conn-1' },
|
||||
});
|
||||
|
||||
const enabled = (result as { result: { capabilities: { enabled: string[] } } }).result.capabilities.enabled;
|
||||
expect(enabled).toEqual(['ui.canvas']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { makeError, makeResponse, ErrorCode, GATEWAY_PROTOCOL_VERSION, parseNodeRegisterParams } from '../protocol.js';
|
||||
|
||||
export interface NodeRegistration {
|
||||
nodeId: string;
|
||||
role: string;
|
||||
protocolVersion: number;
|
||||
capabilities: string[];
|
||||
registeredAt: number;
|
||||
}
|
||||
|
||||
export interface NodeConnectionState {
|
||||
identity?: string;
|
||||
node?: NodeRegistration;
|
||||
}
|
||||
|
||||
export interface NodeHandlerDeps {
|
||||
enabled: boolean;
|
||||
allowedRoles: string[];
|
||||
featureGates: Record<string, boolean>;
|
||||
getConnectionState: (connectionId: string) => NodeConnectionState | undefined;
|
||||
setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void;
|
||||
}
|
||||
|
||||
export function createNodeHandlers(deps: NodeHandlerDeps) {
|
||||
return {
|
||||
'node.register': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.enabled) {
|
||||
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
|
||||
}
|
||||
|
||||
const parsed = parseNodeRegisterParams(request.params);
|
||||
if (!parsed) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.register params');
|
||||
}
|
||||
|
||||
if (deps.allowedRoles.length > 0 && !deps.allowedRoles.includes(parsed.role)) {
|
||||
return makeError(request.id, ErrorCode.AuthFailed, `Node role '${parsed.role}' is not allowed`);
|
||||
}
|
||||
|
||||
const negotiatedVersion = Math.min(GATEWAY_PROTOCOL_VERSION, parsed.protocolVersion);
|
||||
if (negotiatedVersion < 1) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'Unsupported protocolVersion');
|
||||
}
|
||||
|
||||
const dedupedCapabilities = Array.from(new Set(parsed.capabilities.map((entry) => entry.trim()).filter(Boolean)));
|
||||
deps.setNodeRegistration(parsed.connectionId, {
|
||||
nodeId: parsed.nodeId,
|
||||
role: parsed.role,
|
||||
protocolVersion: parsed.protocolVersion,
|
||||
capabilities: dedupedCapabilities,
|
||||
registeredAt: Date.now(),
|
||||
});
|
||||
|
||||
const enabledCapabilities = dedupedCapabilities.filter((capability) => deps.featureGates[capability] !== false);
|
||||
return makeResponse(request.id, {
|
||||
registered: true,
|
||||
node: {
|
||||
id: parsed.nodeId,
|
||||
role: parsed.role,
|
||||
},
|
||||
protocol: {
|
||||
serverVersion: GATEWAY_PROTOCOL_VERSION,
|
||||
clientVersion: parsed.protocolVersion,
|
||||
negotiatedVersion,
|
||||
},
|
||||
capabilities: {
|
||||
declared: dedupedCapabilities,
|
||||
enabled: enabledCapabilities,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
'node.capabilities.get': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.enabled) {
|
||||
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
|
||||
}
|
||||
|
||||
const params = request.params as { connectionId?: string } | undefined;
|
||||
const connectionId = params?.connectionId;
|
||||
if (!connectionId) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'connectionId is required');
|
||||
}
|
||||
|
||||
const state = deps.getConnectionState(connectionId);
|
||||
if (!state?.node) {
|
||||
return makeError(request.id, ErrorCode.AuthFailed, 'Node is not registered for this connection');
|
||||
}
|
||||
|
||||
const enabledCapabilities = state.node.capabilities.filter((capability) => deps.featureGates[capability] !== false);
|
||||
return makeResponse(request.id, {
|
||||
protocol: {
|
||||
serverVersion: GATEWAY_PROTOCOL_VERSION,
|
||||
nodeVersion: state.node.protocolVersion,
|
||||
negotiatedVersion: Math.min(GATEWAY_PROTOCOL_VERSION, state.node.protocolVersion),
|
||||
},
|
||||
node: {
|
||||
id: state.node.nodeId,
|
||||
role: state.node.role,
|
||||
registeredAt: state.node.registeredAt,
|
||||
},
|
||||
capabilities: {
|
||||
declared: state.node.capabilities,
|
||||
enabled: enabledCapabilities,
|
||||
featureGates: deps.featureGates,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
'system.capabilities': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const params = request.params as { connectionId?: string } | undefined;
|
||||
const connectionId = params?.connectionId;
|
||||
const state = connectionId ? deps.getConnectionState(connectionId) : undefined;
|
||||
return makeResponse(request.id, {
|
||||
protocol: {
|
||||
version: GATEWAY_PROTOCOL_VERSION,
|
||||
},
|
||||
nodes: {
|
||||
enabled: deps.enabled,
|
||||
allowedRoles: deps.allowedRoles,
|
||||
registered: Boolean(state?.node),
|
||||
role: state?.node?.role,
|
||||
nodeId: state?.node?.nodeId,
|
||||
},
|
||||
featureGates: deps.featureGates,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isValidRequest,
|
||||
parseMessage,
|
||||
parseNodeRegisterParams,
|
||||
makeResponse,
|
||||
makeError,
|
||||
makeEvent,
|
||||
@@ -60,6 +61,49 @@ describe('protocol', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNodeRegisterParams', () => {
|
||||
it('parses valid node registration params', () => {
|
||||
const parsed = parseNodeRegisterParams({
|
||||
connectionId: 'conn-1',
|
||||
nodeId: 'node-a',
|
||||
role: 'companion',
|
||||
protocolVersion: 1,
|
||||
capabilities: ['ui.canvas', 'notifications'],
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
connectionId: 'conn-1',
|
||||
nodeId: 'node-a',
|
||||
role: 'companion',
|
||||
protocolVersion: 1,
|
||||
capabilities: ['ui.canvas', 'notifications'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid node registration params', () => {
|
||||
expect(parseNodeRegisterParams({
|
||||
connectionId: 'conn-1',
|
||||
nodeId: '',
|
||||
role: 'companion',
|
||||
protocolVersion: 1,
|
||||
capabilities: [],
|
||||
})).toBeNull();
|
||||
expect(parseNodeRegisterParams({
|
||||
connectionId: 'conn-1',
|
||||
nodeId: 'node',
|
||||
role: 'companion',
|
||||
protocolVersion: 0,
|
||||
capabilities: [],
|
||||
})).toBeNull();
|
||||
expect(parseNodeRegisterParams({
|
||||
connectionId: 'conn-1',
|
||||
nodeId: 'node',
|
||||
role: 'companion',
|
||||
protocolVersion: 1,
|
||||
capabilities: [1],
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeResponse', () => {
|
||||
it('creates a response message', () => {
|
||||
expect(makeResponse(1, { status: 'ok' })).toEqual({
|
||||
|
||||
@@ -8,6 +8,16 @@ export interface GatewayRequest {
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const GATEWAY_PROTOCOL_VERSION = 1;
|
||||
|
||||
export interface NodeRegisterParams {
|
||||
connectionId: string;
|
||||
nodeId: string;
|
||||
role: string;
|
||||
protocolVersion: number;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
// ── Server → Client ────────────────────────────────────────────
|
||||
|
||||
export interface GatewayResponse {
|
||||
@@ -129,6 +139,37 @@ export function parseMessage(raw: string): GatewayRequest | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseNodeRegisterParams(params: unknown): NodeRegisterParams | null {
|
||||
if (!params || typeof params !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const p = params as Record<string, unknown>;
|
||||
if (typeof p.connectionId !== 'string' || !p.connectionId.trim()) {
|
||||
return null;
|
||||
}
|
||||
if (typeof p.nodeId !== 'string' || !p.nodeId.trim()) {
|
||||
return null;
|
||||
}
|
||||
if (typeof p.role !== 'string' || !p.role.trim()) {
|
||||
return null;
|
||||
}
|
||||
if (typeof p.protocolVersion !== 'number' || !Number.isFinite(p.protocolVersion) || p.protocolVersion < 1) {
|
||||
return null;
|
||||
}
|
||||
const capabilitiesRaw = p.capabilities;
|
||||
if (!Array.isArray(capabilitiesRaw) || !capabilitiesRaw.every((entry) => typeof entry === 'string')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
connectionId: p.connectionId,
|
||||
nodeId: p.nodeId,
|
||||
role: p.role,
|
||||
protocolVersion: Math.floor(p.protocolVersion),
|
||||
capabilities: capabilitiesRaw,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeResponse(id: number, result: unknown): GatewayResponse {
|
||||
return { id, result };
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export class Router {
|
||||
}
|
||||
|
||||
async dispatch(request: GatewayRequest, send: SendFn): Promise<OutboundMessage | void> {
|
||||
if (request.method.startsWith('node.') && !request.params) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'params are required for node methods');
|
||||
}
|
||||
const handler = this.handlers.get(request.method);
|
||||
if (!handler) {
|
||||
return makeError(request.id, ErrorCode.MethodNotFound, `Unknown method: ${request.method}`);
|
||||
|
||||
@@ -577,3 +577,86 @@ describe('GatewayServer WebSocket ingress rate limiting', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GatewayServer node registration and capability negotiation', () => {
|
||||
const NODE_PORT = 18894;
|
||||
let nodeServer: GatewayServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!LISTEN_ALLOWED) {
|
||||
return;
|
||||
}
|
||||
nodeServer = new GatewayServer({
|
||||
port: NODE_PORT,
|
||||
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
|
||||
modelClient: mockModelClient,
|
||||
systemPrompt: 'Test prompt',
|
||||
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
|
||||
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
|
||||
uiDir: resolve(import.meta.dirname, 'ui'),
|
||||
nodes: {
|
||||
enabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: { 'ui.canvas': true },
|
||||
},
|
||||
});
|
||||
await nodeServer.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!LISTEN_ALLOWED) {
|
||||
return;
|
||||
}
|
||||
await nodeServer.stop();
|
||||
});
|
||||
|
||||
it('enforces role allow/deny and node registration lifecycle', async () => {
|
||||
if (!LISTEN_ALLOWED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
|
||||
c.on('open', () => resolve(c));
|
||||
c.on('error', reject);
|
||||
});
|
||||
|
||||
try {
|
||||
const beforeRegister = await sendAndReceive(ws, { id: 1, method: 'node.capabilities.get', params: {} });
|
||||
expect((beforeRegister as GatewayError).error.code).toBe(ErrorCode.AuthFailed);
|
||||
|
||||
const badRole = await sendAndReceive(ws, {
|
||||
id: 2,
|
||||
method: 'node.register',
|
||||
params: {
|
||||
nodeId: 'node-bad',
|
||||
role: 'observer',
|
||||
protocolVersion: 1,
|
||||
capabilities: ['ui.canvas'],
|
||||
},
|
||||
});
|
||||
expect((badRole as GatewayError).error.code).toBe(ErrorCode.AuthFailed);
|
||||
|
||||
const registered = await sendAndReceive(ws, {
|
||||
id: 3,
|
||||
method: 'node.register',
|
||||
params: {
|
||||
nodeId: 'node-good',
|
||||
role: 'companion',
|
||||
protocolVersion: 1,
|
||||
capabilities: ['ui.canvas'],
|
||||
},
|
||||
});
|
||||
expect((registered as GatewayResponse).id).toBe(3);
|
||||
expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);
|
||||
|
||||
const capabilities = await sendAndReceive(ws, { id: 4, method: 'node.capabilities.get', params: {} });
|
||||
expect((capabilities as GatewayResponse).id).toBe(4);
|
||||
expect(((capabilities as GatewayResponse).result as { node: { role: string } }).node.role).toBe('companion');
|
||||
} finally {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+49
-2
@@ -8,7 +8,7 @@ import type { SessionBridgeConfig } from './session-bridge.js';
|
||||
import { LaneQueue } from './lane-queue.js';
|
||||
import type { LaneQueueConfig } from './lane-queue.js';
|
||||
import { MetricsCollector } from './metrics.js';
|
||||
import { authenticateRequest } from './auth.js';
|
||||
import { authenticateRequest, authorizeNodeMethod } from './auth.js';
|
||||
import type { AuthConfig } from './auth.js';
|
||||
import { startGatewayDiscovery, type GatewayDiscoveryHandle } from './discovery.js';
|
||||
import {
|
||||
@@ -28,9 +28,11 @@ import {
|
||||
createIntentHandlers,
|
||||
createRoutingHandlers,
|
||||
createHistoryHandlers,
|
||||
createNodeHandlers,
|
||||
} from './handlers/index.js';
|
||||
import { discoverServices } from './handlers/services.js';
|
||||
import type { TokenUsageEntry } from './handlers/system.js';
|
||||
import type { NodeConnectionState } from './handlers/node.js';
|
||||
import type { SessionManager } from '../session/manager.js';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { ToolRegistry } from '../tools/registry.js';
|
||||
@@ -91,6 +93,11 @@ export interface GatewayServerConfig {
|
||||
sessions?: Record<string, Partial<LaneQueueConfig>>;
|
||||
};
|
||||
};
|
||||
nodes?: {
|
||||
enabled: boolean;
|
||||
allowedRoles: string[];
|
||||
featureGates: Record<string, boolean>;
|
||||
};
|
||||
/** Optional pairing manager for DM pairing code management via gateway. */
|
||||
pairingManager?: PairingManager;
|
||||
memoryStore?: MemoryStore;
|
||||
@@ -134,6 +141,7 @@ export class GatewayServer {
|
||||
violations: number;
|
||||
windowStartMs: number;
|
||||
}> = new Map();
|
||||
private connectionStateMap: Map<string, NodeConnectionState> = new Map();
|
||||
private config: GatewayServerConfig;
|
||||
private startTime: number = Date.now();
|
||||
|
||||
@@ -269,6 +277,23 @@ export class GatewayServer {
|
||||
routingPolicy: this.config.routingPolicy,
|
||||
});
|
||||
|
||||
const nodeHandlers = createNodeHandlers({
|
||||
enabled: this.config.nodes?.enabled ?? false,
|
||||
allowedRoles: this.config.nodes?.allowedRoles ?? [],
|
||||
featureGates: this.config.nodes?.featureGates ?? {},
|
||||
getConnectionState: (connectionId) => this.connectionStateMap.get(connectionId),
|
||||
setNodeRegistration: (connectionId, registration) => {
|
||||
const existing = this.connectionStateMap.get(connectionId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
this.connectionStateMap.set(connectionId, {
|
||||
...existing,
|
||||
node: registration,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Config handlers (only if config object is provided)
|
||||
if (this.config.config) {
|
||||
const configHandlers = createConfigHandlers({
|
||||
@@ -310,6 +335,9 @@ export class GatewayServer {
|
||||
for (const [method, handler] of Object.entries(routingHandlers)) {
|
||||
this.router.register(method, handler);
|
||||
}
|
||||
for (const [method, handler] of Object.entries(nodeHandlers)) {
|
||||
this.router.register(method, handler);
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -370,6 +398,7 @@ export class GatewayServer {
|
||||
ws.close(1001, 'Server shutting down');
|
||||
}
|
||||
this.connectionMap.clear();
|
||||
this.connectionStateMap.clear();
|
||||
|
||||
// Close WSS first, then the underlying HTTP server
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -395,7 +424,7 @@ export class GatewayServer {
|
||||
});
|
||||
}
|
||||
|
||||
private handleConnection(ws: WebSocket, _identity?: string): void {
|
||||
private handleConnection(ws: WebSocket, identity?: string): void {
|
||||
// Gateway lock — reject if another client is already connected
|
||||
if (this.config.lock && this.connectionMap.size > 0) {
|
||||
ws.close(4003, 'Gateway locked — another client is already connected');
|
||||
@@ -405,6 +434,7 @@ export class GatewayServer {
|
||||
const connectionId = randomUUID();
|
||||
this.sessionBridge.connect(connectionId);
|
||||
this.connectionMap.set(ws, connectionId);
|
||||
this.connectionStateMap.set(connectionId, { identity });
|
||||
this.connectionRateMap.set(connectionId, {
|
||||
tokens: this.getWsRateConfig().capacity,
|
||||
lastRefillMs: Date.now(),
|
||||
@@ -429,6 +459,7 @@ export class GatewayServer {
|
||||
this.sessionBridge.disconnect(connectionId);
|
||||
this.connectionMap.delete(ws);
|
||||
this.connectionRateMap.delete(connectionId);
|
||||
this.connectionStateMap.delete(connectionId);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
@@ -598,6 +629,22 @@ export class GatewayServer {
|
||||
if (!request.params) {request.params = {};}
|
||||
request.params.connectionId = connectionId;
|
||||
|
||||
const nodeAuth = authorizeNodeMethod({
|
||||
enabled: this.config.nodes?.enabled ?? false,
|
||||
method: request.method,
|
||||
nodeRole: this.connectionStateMap.get(connectionId)?.node?.role,
|
||||
allowedRoles: this.config.nodes?.allowedRoles ?? [],
|
||||
roleScopes: {
|
||||
companion: ['node.capabilities.get'],
|
||||
observer: ['node.capabilities.get'],
|
||||
automation: ['node.capabilities.get'],
|
||||
},
|
||||
});
|
||||
if (!nodeAuth.authenticated) {
|
||||
this.send(ws, makeError(request.id, ErrorCode.AuthFailed, nodeAuth.error ?? 'Node authorization failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const send = (msg: OutboundMessage) => this.send(ws, msg);
|
||||
const response = await this.router.dispatch(request, send);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user