| import { |
| approveDevicePairing, |
| getPairedDevice, |
| listDevicePairing, |
| removePairedDevice, |
| type DeviceAuthToken, |
| rejectDevicePairing, |
| revokeDeviceToken, |
| rotateDeviceToken, |
| summarizeDeviceTokens, |
| } from "../../infra/device-pairing.js"; |
| import { normalizeDeviceAuthScopes } from "../../shared/device-auth.js"; |
| import { roleScopesAllow } from "../../shared/operator-scope-compat.js"; |
| import { |
| ErrorCodes, |
| errorShape, |
| formatValidationErrors, |
| validateDevicePairApproveParams, |
| validateDevicePairListParams, |
| validateDevicePairRemoveParams, |
| validateDevicePairRejectParams, |
| validateDeviceTokenRevokeParams, |
| validateDeviceTokenRotateParams, |
| } from "../protocol/index.js"; |
| import type { GatewayRequestHandlers } from "./types.js"; |
|
|
| function redactPairedDevice( |
| device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>, |
| ) { |
| const { tokens, approvedScopes: _approvedScopes, ...rest } = device; |
| return { |
| ...rest, |
| tokens: summarizeDeviceTokens(tokens), |
| }; |
| } |
|
|
| function resolveMissingRequestedScope(params: { |
| role: string; |
| requestedScopes: readonly string[]; |
| callerScopes: readonly string[]; |
| }): string | null { |
| for (const scope of params.requestedScopes) { |
| if ( |
| !roleScopesAllow({ |
| role: params.role, |
| requestedScopes: [scope], |
| allowedScopes: params.callerScopes, |
| }) |
| ) { |
| return scope; |
| } |
| } |
| return null; |
| } |
|
|
| export const deviceHandlers: GatewayRequestHandlers = { |
| "device.pair.list": async ({ params, respond }) => { |
| if (!validateDevicePairListParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid device.pair.list params: ${formatValidationErrors( |
| validateDevicePairListParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
| const list = await listDevicePairing(); |
| respond( |
| true, |
| { |
| pending: list.pending, |
| paired: list.paired.map((device) => redactPairedDevice(device)), |
| }, |
| undefined, |
| ); |
| }, |
| "device.pair.approve": async ({ params, respond, context }) => { |
| if (!validateDevicePairApproveParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid device.pair.approve params: ${formatValidationErrors( |
| validateDevicePairApproveParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
| const { requestId } = params as { requestId: string }; |
| const approved = await approveDevicePairing(requestId); |
| if (!approved) { |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); |
| return; |
| } |
| context.logGateway.info( |
| `device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, |
| ); |
| context.broadcast( |
| "device.pair.resolved", |
| { |
| requestId, |
| deviceId: approved.device.deviceId, |
| decision: "approved", |
| ts: Date.now(), |
| }, |
| { dropIfSlow: true }, |
| ); |
| respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined); |
| }, |
| "device.pair.reject": async ({ params, respond, context }) => { |
| if (!validateDevicePairRejectParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid device.pair.reject params: ${formatValidationErrors( |
| validateDevicePairRejectParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
| const { requestId } = params as { requestId: string }; |
| const rejected = await rejectDevicePairing(requestId); |
| if (!rejected) { |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); |
| return; |
| } |
| context.broadcast( |
| "device.pair.resolved", |
| { |
| requestId, |
| deviceId: rejected.deviceId, |
| decision: "rejected", |
| ts: Date.now(), |
| }, |
| { dropIfSlow: true }, |
| ); |
| respond(true, rejected, undefined); |
| }, |
| "device.pair.remove": async ({ params, respond, context }) => { |
| if (!validateDevicePairRemoveParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid device.pair.remove params: ${formatValidationErrors( |
| validateDevicePairRemoveParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
| const { deviceId } = params as { deviceId: string }; |
| const removed = await removePairedDevice(deviceId); |
| if (!removed) { |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId")); |
| return; |
| } |
| context.logGateway.info(`device pairing removed device=${removed.deviceId}`); |
| respond(true, removed, undefined); |
| }, |
| "device.token.rotate": async ({ params, respond, context, client }) => { |
| if (!validateDeviceTokenRotateParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid device.token.rotate params: ${formatValidationErrors( |
| validateDeviceTokenRotateParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
| const { deviceId, role, scopes } = params as { |
| deviceId: string; |
| role: string; |
| scopes?: string[]; |
| }; |
| const pairedDevice = await getPairedDevice(deviceId); |
| if (!pairedDevice) { |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role")); |
| return; |
| } |
| const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; |
| const requestedScopes = normalizeDeviceAuthScopes( |
| scopes ?? pairedDevice.tokens?.[role.trim()]?.scopes ?? pairedDevice.scopes, |
| ); |
| const missingScope = resolveMissingRequestedScope({ |
| role, |
| requestedScopes, |
| callerScopes, |
| }); |
| if (missingScope) { |
| respond( |
| false, |
| undefined, |
| errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${missingScope}`), |
| ); |
| return; |
| } |
| const entry = await rotateDeviceToken({ deviceId, role, scopes }); |
| if (!entry) { |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role")); |
| return; |
| } |
| context.logGateway.info( |
| `device token rotated device=${deviceId} role=${entry.role} scopes=${entry.scopes.join(",")}`, |
| ); |
| respond( |
| true, |
| { |
| deviceId, |
| role: entry.role, |
| token: entry.token, |
| scopes: entry.scopes, |
| rotatedAtMs: entry.rotatedAtMs ?? entry.createdAtMs, |
| }, |
| undefined, |
| ); |
| }, |
| "device.token.revoke": async ({ params, respond, context }) => { |
| if (!validateDeviceTokenRevokeParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid device.token.revoke params: ${formatValidationErrors( |
| validateDeviceTokenRevokeParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
| const { deviceId, role } = params as { deviceId: string; role: string }; |
| const entry = await revokeDeviceToken({ deviceId, role }); |
| if (!entry) { |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role")); |
| return; |
| } |
| context.logGateway.info(`device token revoked device=${deviceId} role=${entry.role}`); |
| respond( |
| true, |
| { deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() }, |
| undefined, |
| ); |
| }, |
| }; |
|
|