feat(gateway): add node capability negotiation foundation

This commit is contained in:
William Valentin
2026-02-16 12:14:25 -08:00
parent de0c1f41b3
commit d9f7807ab2
17 changed files with 675 additions and 7 deletions
+49 -2
View File
@@ -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);