import type { GatewayRequest, OutboundMessage } from '../protocol.js'; import { makeError, makeResponse, ErrorCode, GATEWAY_PROTOCOL_VERSION, parseNodeRegisterParams, parseNodeLocationSetParams, parseNodeLocationGetParams, parseNodeStatusSetParams, parseNodePushTokenSetParams, } from '../protocol.js'; export interface NodeRegistration { nodeId: string; role: string; protocolVersion: number; capabilities: string[]; registeredAt: number; } export interface NodeConnectionState { identity?: string; node?: NodeRegistration; location?: NodeLocation; status?: NodeStatus; pushToken?: NodePushToken; } export interface NodeLocation { latitude: number; longitude: number; accuracyMeters?: number; altitudeMeters?: number; headingDegrees?: number; speedMps?: number; source: 'gps' | 'network' | 'manual' | 'unknown'; capturedAt: number; receivedAt: number; } export interface NodeStatus { platform: 'macos' | 'ios' | 'android' | 'linux' | 'windows' | 'unknown'; appVersion?: string; deviceName?: string; statusText?: string; batteryPct?: number; powerSource: 'ac' | 'battery' | 'unknown'; reportedAt: number; } export interface NodePushToken { provider: 'apns'; token: string; topic?: string; environment: 'sandbox' | 'production'; registeredAt: number; } export interface NodeHandlerDeps { enabled: boolean; locationEnabled: boolean; pushEnabled: boolean; allowedRoles: string[]; featureGates: Record; getConnectionState: (connectionId: string) => NodeConnectionState | undefined; setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void; setNodeLocation: (connectionId: string, location: NodeLocation) => void; setNodeStatus: (connectionId: string, status: NodeStatus) => void; setNodePushToken: (connectionId: string, pushToken: NodePushToken) => void; } export function createNodeHandlers(deps: NodeHandlerDeps) { return { 'node.register': async (request: GatewayRequest): Promise => { 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 => { 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, }, }); }, 'node.location.set': async (request: GatewayRequest): Promise => { if (!deps.enabled) { return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled'); } if (!deps.locationEnabled) { return makeError(request.id, ErrorCode.AuthFailed, 'Node location access is disabled'); } const parsed = parseNodeLocationSetParams(request.params); if (!parsed) { return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.location.set params'); } const state = deps.getConnectionState(parsed.connectionId); if (!state?.node) { return makeError(request.id, ErrorCode.AuthFailed, 'Node is not registered for this connection'); } const location: NodeLocation = { latitude: parsed.latitude, longitude: parsed.longitude, accuracyMeters: parsed.accuracyMeters, altitudeMeters: parsed.altitudeMeters, headingDegrees: parsed.headingDegrees, speedMps: parsed.speedMps, source: parsed.source ?? 'unknown', capturedAt: parsed.capturedAt ?? Date.now(), receivedAt: Date.now(), }; deps.setNodeLocation(parsed.connectionId, location); return makeResponse(request.id, { updated: true, node: { id: state.node.nodeId, role: state.node.role, }, location, }); }, 'node.location.get': async (request: GatewayRequest): Promise => { if (!deps.enabled) { return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled'); } if (!deps.locationEnabled) { return makeError(request.id, ErrorCode.AuthFailed, 'Node location access is disabled'); } const parsed = parseNodeLocationGetParams(request.params); if (!parsed) { return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.location.get params'); } const state = deps.getConnectionState(parsed.connectionId); if (!state?.node) { return makeError(request.id, ErrorCode.AuthFailed, 'Node is not registered for this connection'); } return makeResponse(request.id, { node: { id: state.node.nodeId, role: state.node.role, }, location: state.location ?? null, }); }, 'node.status.set': async (request: GatewayRequest): Promise => { if (!deps.enabled) { return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled'); } const parsed = parseNodeStatusSetParams(request.params); if (!parsed) { return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.status.set params'); } const state = deps.getConnectionState(parsed.connectionId); if (!state?.node) { return makeError(request.id, ErrorCode.AuthFailed, 'Node is not registered for this connection'); } const status: NodeStatus = { platform: parsed.platform, appVersion: parsed.appVersion?.trim() || undefined, deviceName: parsed.deviceName?.trim() || undefined, statusText: parsed.statusText?.trim() || undefined, batteryPct: parsed.batteryPct, powerSource: parsed.powerSource ?? 'unknown', reportedAt: Date.now(), }; deps.setNodeStatus(parsed.connectionId, status); return makeResponse(request.id, { updated: true, node: { id: state.node.nodeId, role: state.node.role, }, status, }); }, 'node.push_token.set': async (request: GatewayRequest): Promise => { if (!deps.enabled) { return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled'); } if (!deps.pushEnabled) { return makeError(request.id, ErrorCode.AuthFailed, 'Node push token registration is disabled'); } const parsed = parseNodePushTokenSetParams(request.params); if (!parsed) { return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.push_token.set params'); } const state = deps.getConnectionState(parsed.connectionId); if (!state?.node) { return makeError(request.id, ErrorCode.AuthFailed, 'Node is not registered for this connection'); } const pushToken: NodePushToken = { provider: 'apns', token: parsed.token, topic: parsed.topic || undefined, environment: parsed.environment ?? 'production', registeredAt: Date.now(), }; deps.setNodePushToken(parsed.connectionId, pushToken); return makeResponse(request.id, { updated: true, node: { id: state.node.nodeId, role: state.node.role, }, push: { provider: pushToken.provider, tokenPreview: maskToken(pushToken.token), topic: pushToken.topic, environment: pushToken.environment, registeredAt: pushToken.registeredAt, }, }); }, 'system.capabilities': async (request: GatewayRequest): Promise => { 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, locationEnabled: deps.locationEnabled, pushEnabled: deps.pushEnabled, allowedRoles: deps.allowedRoles, registered: Boolean(state?.node), role: state?.node?.role, nodeId: state?.node?.nodeId, }, featureGates: deps.featureGates, }); }, }; } function maskToken(token: string): string { if (token.length <= 8) { return '****'; } const suffix = token.slice(-8); return `***${suffix}`; }