feat(gateway): add node capability negotiation foundation
This commit is contained in:
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user