import { approveNodePairing, listNodePairing, rejectNodePairing, renamePairedNode, requestNodePairing, verifyNodeToken, } from "../../infra/node-pairing.js"; import { ErrorCodes, errorShape, validateNodeDescribeParams, validateNodeInvokeParams, validateNodeListParams, validateNodePairApproveParams, validateNodePairListParams, validateNodePairRejectParams, validateNodePairRequestParams, validateNodePairVerifyParams, validateNodeRenameParams, } from "../protocol/index.js"; import { respondInvalidParams, respondUnavailableOnThrow, safeParseJson, uniqueSortedStrings, } from "./nodes.helpers.js"; import type { GatewayRequestHandlers } from "./types.js"; export const nodeHandlers: GatewayRequestHandlers = { "node.pair.request": async ({ params, respond, context }) => { if (!validateNodePairRequestParams(params)) { respondInvalidParams({ respond, method: "node.pair.request", validator: validateNodePairRequestParams, }); return; } const p = params as { nodeId: string; displayName?: string; platform?: string; version?: string; deviceFamily?: string; modelIdentifier?: string; caps?: string[]; commands?: string[]; remoteIp?: string; silent?: boolean; }; await respondUnavailableOnThrow(respond, async () => { const result = await requestNodePairing({ nodeId: p.nodeId, displayName: p.displayName, platform: p.platform, version: p.version, deviceFamily: p.deviceFamily, modelIdentifier: p.modelIdentifier, caps: p.caps, commands: p.commands, remoteIp: p.remoteIp, silent: p.silent, }); if (result.status === "pending" && result.created) { context.broadcast("node.pair.requested", result.request, { dropIfSlow: true, }); } respond(true, result, undefined); }); }, "node.pair.list": async ({ params, respond }) => { if (!validateNodePairListParams(params)) { respondInvalidParams({ respond, method: "node.pair.list", validator: validateNodePairListParams, }); return; } await respondUnavailableOnThrow(respond, async () => { const list = await listNodePairing(); respond(true, list, undefined); }); }, "node.pair.approve": async ({ params, respond, context }) => { if (!validateNodePairApproveParams(params)) { respondInvalidParams({ respond, method: "node.pair.approve", validator: validateNodePairApproveParams, }); return; } const { requestId } = params as { requestId: string }; await respondUnavailableOnThrow(respond, async () => { const approved = await approveNodePairing(requestId); if (!approved) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } context.broadcast( "node.pair.resolved", { requestId, nodeId: approved.node.nodeId, decision: "approved", ts: Date.now(), }, { dropIfSlow: true }, ); respond(true, approved, undefined); }); }, "node.pair.reject": async ({ params, respond, context }) => { if (!validateNodePairRejectParams(params)) { respondInvalidParams({ respond, method: "node.pair.reject", validator: validateNodePairRejectParams, }); return; } const { requestId } = params as { requestId: string }; await respondUnavailableOnThrow(respond, async () => { const rejected = await rejectNodePairing(requestId); if (!rejected) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } context.broadcast( "node.pair.resolved", { requestId, nodeId: rejected.nodeId, decision: "rejected", ts: Date.now(), }, { dropIfSlow: true }, ); respond(true, rejected, undefined); }); }, "node.pair.verify": async ({ params, respond }) => { if (!validateNodePairVerifyParams(params)) { respondInvalidParams({ respond, method: "node.pair.verify", validator: validateNodePairVerifyParams, }); return; } const { nodeId, token } = params as { nodeId: string; token: string; }; await respondUnavailableOnThrow(respond, async () => { const result = await verifyNodeToken(nodeId, token); respond(true, result, undefined); }); }, "node.rename": async ({ params, respond }) => { if (!validateNodeRenameParams(params)) { respondInvalidParams({ respond, method: "node.rename", validator: validateNodeRenameParams, }); return; } const { nodeId, displayName } = params as { nodeId: string; displayName: string; }; await respondUnavailableOnThrow(respond, async () => { const trimmed = displayName.trim(); if (!trimmed) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "displayName required")); return; } const updated = await renamePairedNode(nodeId, trimmed); if (!updated) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId")); return; } respond(true, { nodeId: updated.nodeId, displayName: updated.displayName }, undefined); }); }, "node.list": async ({ params, respond, context }) => { if (!validateNodeListParams(params)) { respondInvalidParams({ respond, method: "node.list", validator: validateNodeListParams, }); return; } await respondUnavailableOnThrow(respond, async () => { const list = await listNodePairing(); const pairedById = new Map(list.paired.map((n) => [n.nodeId, n])); const connected = context.bridge?.listConnected?.() ?? []; const connectedById = new Map(connected.map((n) => [n.nodeId, n])); const nodeIds = new Set([...pairedById.keys(), ...connectedById.keys()]); const nodes = [...nodeIds].map((nodeId) => { const paired = pairedById.get(nodeId); const live = connectedById.get(nodeId); const caps = uniqueSortedStrings([...(live?.caps ?? paired?.caps ?? [])]); const commands = uniqueSortedStrings([...(live?.commands ?? paired?.commands ?? [])]); return { nodeId, displayName: live?.displayName ?? paired?.displayName, platform: live?.platform ?? paired?.platform, version: live?.version ?? paired?.version, deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier, remoteIp: live?.remoteIp ?? paired?.remoteIp, caps, commands, permissions: live?.permissions ?? paired?.permissions, paired: Boolean(paired), connected: Boolean(live), }; }); nodes.sort((a, b) => { if (a.connected !== b.connected) return a.connected ? -1 : 1; const an = (a.displayName ?? a.nodeId).toLowerCase(); const bn = (b.displayName ?? b.nodeId).toLowerCase(); if (an < bn) return -1; if (an > bn) return 1; return a.nodeId.localeCompare(b.nodeId); }); respond(true, { ts: Date.now(), nodes }, undefined); }); }, "node.describe": async ({ params, respond, context }) => { if (!validateNodeDescribeParams(params)) { respondInvalidParams({ respond, method: "node.describe", validator: validateNodeDescribeParams, }); return; } const { nodeId } = params as { nodeId: string }; const id = String(nodeId ?? "").trim(); if (!id) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); return; } await respondUnavailableOnThrow(respond, async () => { const list = await listNodePairing(); const paired = list.paired.find((n) => n.nodeId === id); const connected = context.bridge?.listConnected?.() ?? []; const live = connected.find((n) => n.nodeId === id); if (!paired && !live) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId")); return; } const caps = uniqueSortedStrings([...(live?.caps ?? paired?.caps ?? [])]); const commands = uniqueSortedStrings([...(live?.commands ?? paired?.commands ?? [])]); respond( true, { ts: Date.now(), nodeId: id, displayName: live?.displayName ?? paired?.displayName, platform: live?.platform ?? paired?.platform, version: live?.version ?? paired?.version, deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier, remoteIp: live?.remoteIp ?? paired?.remoteIp, caps, commands, permissions: live?.permissions ?? paired?.permissions, paired: Boolean(paired), connected: Boolean(live), }, undefined, ); }); }, "node.invoke": async ({ params, respond, context }) => { if (!validateNodeInvokeParams(params)) { respondInvalidParams({ respond, method: "node.invoke", validator: validateNodeInvokeParams, }); return; } const bridge = context.bridge; if (!bridge) { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running")); return; } const p = params as { nodeId: string; command: string; params?: unknown; timeoutMs?: number; idempotencyKey: string; }; const nodeId = String(p.nodeId ?? "").trim(); const command = String(p.command ?? "").trim(); if (!nodeId || !command) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId and command required"), ); return; } await respondUnavailableOnThrow(respond, async () => { const paramsJSON = "params" in p && p.params !== undefined ? JSON.stringify(p.params) : null; const res = await bridge.invoke({ nodeId, command, paramsJSON, timeoutMs: p.timeoutMs, }); if (!res.ok) { respond( false, undefined, errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", { details: { nodeError: res.error ?? null }, }), ); return; } const payload = safeParseJson(res.payloadJSON ?? null); respond( true, { ok: true, nodeId, command, payload, payloadJSON: res.payloadJSON ?? null, }, undefined, ); }); }, };