feat: add device token auth and devices cli

This commit is contained in:
Peter Steinberger
2026-01-20 10:29:13 +00:00
parent 1c02de1309
commit d88b239d3c
27 changed files with 1055 additions and 71 deletions

View File

@@ -75,6 +75,7 @@ public struct HelloOk: Codable, Sendable {
public let features: [String: AnyCodable]
public let snapshot: Snapshot
public let canvashosturl: String?
public let auth: [String: AnyCodable]?
public let policy: [String: AnyCodable]
public init(
@@ -84,6 +85,7 @@ public struct HelloOk: Codable, Sendable {
features: [String: AnyCodable],
snapshot: Snapshot,
canvashosturl: String?,
auth: [String: AnyCodable]?,
policy: [String: AnyCodable]
) {
self.type = type
@@ -92,6 +94,7 @@ public struct HelloOk: Codable, Sendable {
self.features = features
self.snapshot = snapshot
self.canvashosturl = canvashosturl
self.auth = auth
self.policy = policy
}
private enum CodingKeys: String, CodingKey {
@@ -101,6 +104,7 @@ public struct HelloOk: Codable, Sendable {
case features
case snapshot
case canvashosturl = "canvasHostUrl"
case auth
case policy
}
}
@@ -1950,6 +1954,44 @@ public struct DevicePairRejectParams: Codable, Sendable {
}
}
public struct DeviceTokenRotateParams: Codable, Sendable {
public let deviceid: String
public let role: String
public let scopes: [String]?
public init(
deviceid: String,
role: String,
scopes: [String]?
) {
self.deviceid = deviceid
self.role = role
self.scopes = scopes
}
private enum CodingKeys: String, CodingKey {
case deviceid = "deviceId"
case role
case scopes
}
}
public struct DeviceTokenRevokeParams: Codable, Sendable {
public let deviceid: String
public let role: String
public init(
deviceid: String,
role: String
) {
self.deviceid = deviceid
self.role = role
}
private enum CodingKeys: String, CodingKey {
case deviceid = "deviceId"
case role
}
}
public struct DevicePairRequestedEvent: Codable, Sendable {
public let requestid: String
public let deviceid: String

66
docs/cli/devices.md Normal file
View File

@@ -0,0 +1,66 @@
---
summary: "CLI reference for `clawdbot devices` (device pairing + token rotation/revocation)"
read_when:
- You are approving device pairing requests
- You need to rotate or revoke device tokens
---
# `clawdbot devices`
Manage device pairing requests and device-scoped tokens.
## Commands
### `clawdbot devices list`
List pending pairing requests and paired devices.
```
clawdbot devices list
clawdbot devices list --json
```
### `clawdbot devices approve <requestId>`
Approve a pending device pairing request.
```
clawdbot devices approve <requestId>
```
### `clawdbot devices reject <requestId>`
Reject a pending device pairing request.
```
clawdbot devices reject <requestId>
```
### `clawdbot devices rotate --device <id> --role <role> [--scope <scope...>]`
Rotate a device token for a specific role (optionally updating scopes).
```
clawdbot devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write
```
### `clawdbot devices revoke --device <id> --role <role>`
Revoke a device token for a specific role.
```
clawdbot devices revoke --device <deviceId> --role node
```
## Common options
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
- `--token <token>`: Gateway token (if required).
- `--password <password>`: Gateway password (password auth).
- `--timeout <ms>`: RPC timeout.
- `--json`: JSON output (recommended for scripting).
## Notes
- Token rotation returns a new token (sensitive). Treat it like a secret.
- These commands require `operator.pairing` (or `operator.admin`) scope.

View File

@@ -116,17 +116,18 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
## Discover gateways (Bonjour)
`gateway discover` scans for Gateway bridge beacons (`_clawdbot-bridge._tcp`).
`gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`).
- Multicast DNS-SD: `local.`
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
Only gateways with the **bridge enabled** will advertise the discovery beacon.
Only gateways with Bonjour discovery enabled (default) advertise the beacon.
Wide-Area discovery records include (TXT):
- `gatewayPort` (WebSocket port, usually `18789`)
- `sshPort` (SSH port; defaults to `22` if not present)
- `tailnetDns` (MagicDNS hostname, when available)
- `gatewayTls` / `gatewayTlsSha256` (TLS enabled + cert fingerprint)
- `cliPath` (optional hint for remote installs)
### `gateway discover`

View File

@@ -34,6 +34,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`models`](/cli/models)
- [`memory`](/cli/memory)
- [`nodes`](/cli/nodes)
- [`devices`](/cli/devices)
- [`node`](/cli/node)
- [`approvals`](/cli/approvals)
- [`sandbox`](/cli/sandbox)
@@ -188,6 +189,7 @@ clawdbot [--dev] [--profile <name>] <command>
runs
run
nodes
devices
node
start
daemon

View File

@@ -2677,7 +2677,7 @@ Notes:
Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
@@ -2686,6 +2686,9 @@ Auth and Tailscale:
`true`, Serve requests do not need a token/password; set `false` to require
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
auth mode is not `password`.
- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes.
These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them
instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.

View File

@@ -59,6 +59,18 @@ Gateway → Client:
}
```
When a device token is issued, `hello-ok` also includes:
```json
{
"auth": {
"deviceToken": "…",
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
```
### Node example
```json
@@ -141,6 +153,11 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token`
must match or the socket is closed.
- After pairing, the Gateway issues a **device token** scoped to the connection
role + scopes. It is returned in `hello-ok.auth.deviceToken` and should be
persisted by the client for future connects.
- Device tokens can be rotated/revoked via `device.token.rotate` and
`device.token.revoke` (requires `operator.pairing` scope).
## Device identity + pairing

225
src/cli/devices-cli.ts Normal file
View File

@@ -0,0 +1,225 @@
import type { Command } from "commander";
import { callGateway } from "../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { defaultRuntime } from "./runtime.js";
import { withProgress } from "./progress.js";
type DevicesRpcOpts = {
url?: string;
token?: string;
password?: string;
timeout?: string;
json?: boolean;
device?: string;
role?: string;
scope?: string[];
};
type DeviceTokenSummary = {
role: string;
scopes?: string[];
revokedAtMs?: number;
};
type PendingDevice = {
requestId: string;
deviceId: string;
displayName?: string;
role?: string;
remoteIp?: string;
isRepair?: boolean;
ts?: number;
};
type PairedDevice = {
deviceId: string;
displayName?: string;
roles?: string[];
scopes?: string[];
remoteIp?: string;
tokens?: DeviceTokenSummary[];
createdAtMs?: number;
approvedAtMs?: number;
};
type DevicePairingList = {
pending?: PendingDevice[];
paired?: PairedDevice[];
};
function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const d = Math.floor(h / 24);
return `${d}d`;
}
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
cmd
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", String(defaults?.timeoutMs ?? 10_000))
.option("--json", "Output JSON", false);
const callGatewayCli = async (method: string, opts: DevicesRpcOpts, params?: unknown) =>
withProgress(
{
label: `Devices ${method}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
url: opts.url,
token: opts.token,
password: opts.password,
method,
params,
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
}),
);
function parseDevicePairingList(value: unknown): DevicePairingList {
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
return {
pending: Array.isArray(obj.pending) ? (obj.pending as PendingDevice[]) : [],
paired: Array.isArray(obj.paired) ? (obj.paired as PairedDevice[]) : [],
};
}
function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) {
if (!tokens || tokens.length === 0) return "tokens: none";
const parts = tokens
.map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`)
.sort((a, b) => a.localeCompare(b));
return `tokens: ${parts.join(", ")}`;
}
export function registerDevicesCli(program: Command) {
const devices = program.command("devices").description("Device pairing and auth tokens");
devicesCallOpts(
devices
.command("list")
.description("List pending and paired devices")
.action(async (opts: DevicesRpcOpts) => {
const result = await callGatewayCli("device.pair.list", opts, {});
const list = parseDevicePairingList(result);
if (opts.json) {
defaultRuntime.log(JSON.stringify(list, null, 2));
return;
}
if (list.pending?.length) {
defaultRuntime.log("Pending:");
for (const req of list.pending) {
const name = req.displayName || req.deviceId;
const repair = req.isRepair ? " (repair)" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
const age = typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : "";
const role = req.role ? ` · role: ${req.role}` : "";
defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`);
}
}
if (list.paired?.length) {
defaultRuntime.log("Paired:");
for (const device of list.paired) {
const name = device.displayName || device.deviceId;
const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -";
const scopes = device.scopes?.length ? `scopes: ${device.scopes.join(", ")}` : "scopes: -";
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
const tokens = formatTokenSummary(device.tokens);
defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`);
}
}
if (!list.pending?.length && !list.paired?.length) {
defaultRuntime.log("No device pairing entries.");
}
}),
);
devicesCallOpts(
devices
.command("approve")
.description("Approve a pending device pairing request")
.argument("<requestId>", "Pending request id")
.action(async (requestId: string, opts: DevicesRpcOpts) => {
const result = await callGatewayCli("device.pair.approve", opts, { requestId });
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
defaultRuntime.log(`device approved: ${deviceId ?? "ok"}`);
}),
);
devicesCallOpts(
devices
.command("reject")
.description("Reject a pending device pairing request")
.argument("<requestId>", "Pending request id")
.action(async (requestId: string, opts: DevicesRpcOpts) => {
const result = await callGatewayCli("device.pair.reject", opts, { requestId });
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const deviceId = (result as { deviceId?: string })?.deviceId;
defaultRuntime.log(`device rejected: ${deviceId ?? "ok"}`);
}),
);
devicesCallOpts(
devices
.command("rotate")
.description("Rotate a device token for a role")
.requiredOption("--device <id>", "Device id")
.requiredOption("--role <role>", "Role name")
.option("--scope <scope...>", "Scopes to attach to the token (repeatable)")
.action(async (opts: DevicesRpcOpts) => {
const deviceId = String(opts.device ?? "").trim();
const role = String(opts.role ?? "").trim();
if (!deviceId || !role) {
defaultRuntime.error("--device and --role required");
defaultRuntime.exit(1);
return;
}
const result = await callGatewayCli("device.token.rotate", opts, {
deviceId,
role,
scopes: Array.isArray(opts.scope) ? opts.scope : undefined,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
}),
);
devicesCallOpts(
devices
.command("revoke")
.description("Revoke a device token for a role")
.requiredOption("--device <id>", "Device id")
.requiredOption("--role <role>", "Role name")
.action(async (opts: DevicesRpcOpts) => {
const deviceId = String(opts.device ?? "").trim();
const role = String(opts.role ?? "").trim();
if (!deviceId || !role) {
defaultRuntime.error("--device and --role required");
defaultRuntime.exit(1);
return;
}
const result = await callGatewayCli("device.token.revoke", opts, {
deviceId,
role,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
}),
);
}

View File

@@ -64,7 +64,8 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
const host = pickBeaconHost(beacon);
const gatewayPort = pickGatewayPort(beacon);
const wsUrl = host ? `ws://${host}:${gatewayPort}` : null;
const scheme = beacon.gatewayTls ? "wss" : "ws";
const wsUrl = host ? `${scheme}://${host}:${gatewayPort}` : null;
const lines = [`- ${title} ${domain}`];
@@ -81,6 +82,12 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
if (wsUrl) {
lines.push(` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`);
}
if (beacon.gatewayTls) {
const fingerprint = beacon.gatewayTlsFingerprintSha256
? `sha256 ${beacon.gatewayTlsFingerprintSha256}`
: "enabled";
lines.push(` ${colorize(rich, theme.muted, "tls")}: ${fingerprint}`);
}
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`);

View File

@@ -94,6 +94,14 @@ const entries: SubCliEntry[] = [
mod.registerNodesCli(program);
},
},
{
name: "devices",
description: "Device pairing + token management",
register: async (program) => {
const mod = await import("../devices-cli.js");
mod.registerDevicesCli(program);
},
},
{
name: "node",
description: "Node control",

View File

@@ -12,7 +12,7 @@ export type ResolvedGatewayAuth = {
export type GatewayAuthResult = {
ok: boolean;
method?: "none" | "token" | "password" | "tailscale";
method?: "none" | "token" | "password" | "tailscale" | "device-token";
user?: string;
reason?: string;
};

View File

@@ -8,6 +8,7 @@ import {
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { loadDeviceAuthToken, storeDeviceAuthToken } from "../infra/device-auth-store.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
@@ -146,36 +147,39 @@ export class GatewayClient {
}
private sendConnect() {
const role = this.opts.role ?? "operator";
const storedToken = this.opts.deviceIdentity
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
: null;
const authToken = this.opts.token ?? storedToken ?? undefined;
const auth =
this.opts.token || this.opts.password
authToken || this.opts.password
? {
token: this.opts.token,
token: authToken,
password: this.opts.password,
}
: undefined;
const signedAtMs = Date.now();
const role = this.opts.role ?? "operator";
const scopes = this.opts.scopes ?? ["operator.admin"];
const identity = this.opts.deviceIdentity;
if (!identity) {
throw new Error("gateway client requires a device identity");
}
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
role,
scopes,
signedAtMs,
token: this.opts.token ?? null,
});
const signature = signDevicePayload(identity.privateKeyPem, payload);
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature,
signedAt: signedAtMs,
};
const device = (() => {
if (!this.opts.deviceIdentity) return undefined;
const payload = buildDeviceAuthPayload({
deviceId: this.opts.deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
role,
scopes,
signedAtMs,
token: authToken ?? null,
});
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
return {
id: this.opts.deviceIdentity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem),
signature,
signedAt: signedAtMs,
};
})();
const params: ConnectParams = {
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
@@ -201,6 +205,15 @@ export class GatewayClient {
void this.request<HelloOk>("connect", params)
.then((helloOk) => {
const authInfo = helloOk?.auth;
if (authInfo?.deviceToken && this.opts.deviceIdentity) {
storeDeviceAuthToken({
deviceId: this.opts.deviceIdentity.deviceId,
role: authInfo.role ?? role,
token: authInfo.deviceToken,
scopes: authInfo.scopes ?? [],
});
}
this.backoffMs = 1000;
this.tickIntervalMs =
typeof helloOk.policy?.tickIntervalMs === "number"

View File

@@ -62,6 +62,10 @@ import {
DevicePairListParamsSchema,
type DevicePairRejectParams,
DevicePairRejectParamsSchema,
type DeviceTokenRevokeParams,
DeviceTokenRevokeParamsSchema,
type DeviceTokenRotateParams,
DeviceTokenRotateParamsSchema,
type ExecApprovalsGetParams,
ExecApprovalsGetParamsSchema,
type ExecApprovalsNodeGetParams,
@@ -270,6 +274,12 @@ export const validateDevicePairApproveParams = ajv.compile<DevicePairApprovePara
export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>(
DevicePairRejectParamsSchema,
);
export const validateDeviceTokenRotateParams = ajv.compile<DeviceTokenRotateParams>(
DeviceTokenRotateParamsSchema,
);
export const validateDeviceTokenRevokeParams = ajv.compile<DeviceTokenRevokeParams>(
DeviceTokenRevokeParamsSchema,
);
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
ExecApprovalsGetParamsSchema,
);

View File

@@ -14,6 +14,23 @@ export const DevicePairRejectParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const DeviceTokenRotateParamsSchema = Type.Object(
{
deviceId: NonEmptyString,
role: NonEmptyString,
scopes: Type.Optional(Type.Array(NonEmptyString)),
},
{ additionalProperties: false },
);
export const DeviceTokenRevokeParamsSchema = Type.Object(
{
deviceId: NonEmptyString,
role: NonEmptyString,
},
{ additionalProperties: false },
);
export const DevicePairRequestedEventSchema = Type.Object(
{
requestId: NonEmptyString,

View File

@@ -85,6 +85,17 @@ export const HelloOkSchema = Type.Object(
),
snapshot: SnapshotSchema,
canvasHostUrl: Type.Optional(NonEmptyString),
auth: Type.Optional(
Type.Object(
{
deviceToken: NonEmptyString,
role: NonEmptyString,
scopes: Type.Array(NonEmptyString),
issuedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
),
policy: Type.Object(
{
maxPayload: Type.Integer({ minimum: 1 }),

View File

@@ -64,6 +64,8 @@ import {
DevicePairRejectParamsSchema,
DevicePairRequestedEventSchema,
DevicePairResolvedEventSchema,
DeviceTokenRevokeParamsSchema,
DeviceTokenRotateParamsSchema,
} from "./devices.js";
import {
ConnectParamsSchema,
@@ -206,6 +208,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
DevicePairListParams: DevicePairListParamsSchema,
DevicePairApproveParams: DevicePairApproveParamsSchema,
DevicePairRejectParams: DevicePairRejectParamsSchema,
DeviceTokenRotateParams: DeviceTokenRotateParamsSchema,
DeviceTokenRevokeParams: DeviceTokenRevokeParamsSchema,
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
ChatHistoryParams: ChatHistoryParamsSchema,

View File

@@ -60,6 +60,8 @@ import type {
DevicePairApproveParamsSchema,
DevicePairListParamsSchema,
DevicePairRejectParamsSchema,
DeviceTokenRevokeParamsSchema,
DeviceTokenRotateParamsSchema,
} from "./devices.js";
import type {
ConnectParamsSchema,
@@ -195,6 +197,8 @@ export type ExecApprovalResolveParams = Static<typeof ExecApprovalResolveParamsS
export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>;
export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>;
export type DevicePairRejectParams = Static<typeof DevicePairRejectParamsSchema>;
export type DeviceTokenRotateParams = Static<typeof DeviceTokenRotateParamsSchema>;
export type DeviceTokenRevokeParams = Static<typeof DeviceTokenRevokeParamsSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
export type ChatEvent = Static<typeof ChatEventSchema>;

View File

@@ -49,6 +49,8 @@ const BASE_METHODS = [
"device.pair.list",
"device.pair.approve",
"device.pair.reject",
"device.token.rotate",
"device.token.revoke",
"node.rename",
"node.list",
"node.describe",

View File

@@ -41,6 +41,8 @@ const PAIRING_METHODS = new Set([
"device.pair.list",
"device.pair.approve",
"device.pair.reject",
"device.token.rotate",
"device.token.revoke",
"node.rename",
]);
const ADMIN_METHOD_PREFIXES = ["exec.approvals."];

View File

@@ -1,7 +1,11 @@
import {
approveDevicePairing,
listDevicePairing,
type DeviceAuthToken,
rejectDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
summarizeDeviceTokens,
} from "../../infra/device-pairing.js";
import {
ErrorCodes,
@@ -10,9 +14,19 @@ import {
validateDevicePairApproveParams,
validateDevicePairListParams,
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, ...rest } = device;
return {
...rest,
tokens: summarizeDeviceTokens(tokens),
};
}
export const deviceHandlers: GatewayRequestHandlers = {
"device.pair.list": async ({ params, respond }) => {
if (!validateDevicePairListParams(params)) {
@@ -29,7 +43,14 @@ export const deviceHandlers: GatewayRequestHandlers = {
return;
}
const list = await listDevicePairing();
respond(true, list, undefined);
respond(
true,
{
pending: list.pending,
paired: list.paired.map((device) => redactPairedDevice(device)),
},
undefined,
);
},
"device.pair.approve": async ({ params, respond, context }) => {
if (!validateDevicePairApproveParams(params)) {
@@ -58,10 +79,10 @@ export const deviceHandlers: GatewayRequestHandlers = {
deviceId: approved.device.deviceId,
decision: "approved",
ts: Date.now(),
},
},
{ dropIfSlow: true },
);
respond(true, approved, undefined);
respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined);
},
"device.pair.reject": async ({ params, respond, context }) => {
if (!validateDevicePairRejectParams(params)) {
@@ -95,4 +116,66 @@ export const deviceHandlers: GatewayRequestHandlers = {
);
respond(true, rejected, undefined);
},
"device.token.rotate": async ({ params, respond }) => {
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 entry = await rotateDeviceToken({ deviceId, role, scopes });
if (!entry) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
return;
}
respond(
true,
{
deviceId,
role: entry.role,
token: entry.token,
scopes: entry.scopes,
rotatedAtMs: entry.rotatedAtMs ?? entry.createdAtMs,
},
undefined,
);
},
"device.token.revoke": async ({ params, respond }) => {
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;
}
respond(
true,
{ deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() },
undefined,
);
},
};

View File

@@ -73,7 +73,7 @@ describe("gateway server auth/connect", () => {
});
test("rejects invalid token", async () => {
const { server, ws, prevToken } = await startServerWithClient("secret");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, { token: "wrong" });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("unauthorized");
@@ -100,6 +100,81 @@ describe("gateway server auth/connect", () => {
await server.close();
});
test("accepts device token auth for paired device", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } = await import(
"../infra/device-pairing.js"
);
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, { token: "secret" });
if (!res.ok) {
const list = await listDevicePairing();
const pending = list.pending.at(0);
expect(pending?.requestId).toBeDefined();
if (pending?.requestId) {
await approveDevicePairing(pending.requestId);
}
}
const identity = loadOrCreateDeviceIdentity();
const paired = await getPairedDevice(identity.deviceId);
const deviceToken = paired?.tokens?.operator?.token;
expect(deviceToken).toBeDefined();
ws.close();
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws2.once("open", resolve));
const res2 = await connectReq(ws2, { token: deviceToken });
expect(res2.ok).toBe(true);
ws2.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("rejects revoked device token", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing, revokeDeviceToken } =
await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, { token: "secret" });
if (!res.ok) {
const list = await listDevicePairing();
const pending = list.pending.at(0);
expect(pending?.requestId).toBeDefined();
if (pending?.requestId) {
await approveDevicePairing(pending.requestId);
}
}
const identity = loadOrCreateDeviceIdentity();
const paired = await getPairedDevice(identity.deviceId);
const deviceToken = paired?.tokens?.operator?.token;
expect(deviceToken).toBeDefined();
await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" });
ws.close();
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws2.once("open", resolve));
const res2 = await connectReq(ws2, { token: deviceToken });
expect(res2.ok).toBe(false);
ws2.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("rejects invalid password", async () => {
testState.gatewayAuth = { mode: "password", password: "secret" };
const port = await getFreePort();

View File

@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { emitAgentEvent } from "../infra/agent-events.js";
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectOk,
@@ -223,8 +224,9 @@ describe("gateway server health/presence", () => {
);
const presenceRes = await presenceP;
const identity = loadOrCreateDeviceIdentity();
const entries = presenceRes.payload as Array<Record<string, unknown>>;
const clientEntry = entries.find((e) => e.instanceId === "abc");
const clientEntry = entries.find((e) => e.instanceId === identity.deviceId);
expect(clientEntry?.host).toBe(GATEWAY_CLIENT_NAMES.FINGERPRINT);
expect(clientEntry?.version).toBe("9.9.9");
expect(clientEntry?.mode).toBe("ui");

View File

@@ -58,6 +58,7 @@ describe("gateway node command allowlist", () => {
role: "node",
client: {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
displayName: "node-empty",
version: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
@@ -94,6 +95,7 @@ describe("gateway node command allowlist", () => {
role: "node",
client: {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
displayName: "node-allowed",
version: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,

View File

@@ -9,9 +9,11 @@ import {
} from "../../../infra/device-identity.js";
import {
approveDevicePairing,
ensureDeviceToken,
getPairedDevice,
requestDevicePairing,
updatePairedDeviceMetadata,
verifyDeviceToken,
} from "../../../infra/device-pairing.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
@@ -218,42 +220,6 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
});
if (!authResult.ok) {
setHandshakeState("failed");
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
);
const authProvided = connectParams.auth?.token
? "token"
: connectParams.auth?.password
? "password"
: "none";
setCloseCause("unauthorized", {
authMode: resolvedAuth.mode,
authProvided,
authReason: authResult.reason,
allowTailscale: resolvedAuth.allowTailscale,
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
});
close(1008, "unauthorized");
return;
}
const authMethod = authResult.method ?? "none";
const roleRaw = connectParams.role ?? "operator";
const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null;
if (!role) {
@@ -385,6 +351,55 @@ export function attachGatewayWsMessageHandler(params: {
}
}
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
if (!authOk && connectParams.auth?.token) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,
token: connectParams.auth.token,
role,
scopes,
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "device-token";
}
}
if (!authOk) {
setHandshakeState("failed");
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
);
const authProvided = connectParams.auth?.token
? "token"
: connectParams.auth?.password
? "password"
: "none";
setCloseCause("unauthorized", {
authMode: resolvedAuth.mode,
authProvided,
authReason: authResult.reason,
allowTailscale: resolvedAuth.allowTailscale,
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
});
close(1008, "unauthorized");
return;
}
if (device && devicePublicKey) {
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
const pairing = await requestDevicePairing({
@@ -445,7 +460,11 @@ export function attachGatewayWsMessageHandler(params: {
if (!ok) return;
} else {
const allowedRoles = new Set(
Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : [],
Array.isArray(paired.roles)
? paired.roles
: paired.role
? [paired.role]
: [],
);
if (allowedRoles.size === 0) {
const ok = await requirePairing("role-upgrade", paired);
@@ -482,6 +501,10 @@ export function attachGatewayWsMessageHandler(params: {
}
}
const deviceToken = device
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
: null;
if (role === "node") {
const cfg = loadConfig();
const allowlist = resolveNodeCommandAllowlist(cfg, {
@@ -552,6 +575,14 @@ export function attachGatewayWsMessageHandler(params: {
features: { methods: gatewayMethods, events },
snapshot,
canvasHostUrl,
auth: deviceToken
? {
deviceToken: deviceToken.token,
role: deviceToken.role,
scopes: deviceToken.scopes,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
}
: undefined,
policy: {
maxPayload: MAX_PAYLOAD_BYTES,
maxBufferedBytes: MAX_BUFFERED_BYTES,

View File

@@ -0,0 +1,104 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
export type DeviceAuthEntry = {
token: string;
role: string;
scopes: string[];
updatedAtMs: number;
};
type DeviceAuthStore = {
version: 1;
deviceId: string;
tokens: Record<string, DeviceAuthEntry>;
};
const DEVICE_AUTH_FILE = "device-auth.json";
function resolveDeviceAuthPath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateDir(env), "identity", DEVICE_AUTH_FILE);
}
function normalizeRole(role: string): string {
return role.trim();
}
function normalizeScopes(scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) return [];
const out = new Set<string>();
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) out.add(trimmed);
}
return [...out].sort();
}
function readStore(filePath: string): DeviceAuthStore | null {
try {
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as DeviceAuthStore;
if (parsed?.version !== 1 || typeof parsed.deviceId !== "string") return null;
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
return parsed;
} catch {
return null;
}
}
function writeStore(filePath: string, store: DeviceAuthStore): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
}
export function loadDeviceAuthToken(params: {
deviceId: string;
role: string;
env?: NodeJS.ProcessEnv;
}): DeviceAuthEntry | null {
const filePath = resolveDeviceAuthPath(params.env);
const store = readStore(filePath);
if (!store) return null;
if (store.deviceId !== params.deviceId) return null;
const role = normalizeRole(params.role);
const entry = store.tokens[role];
if (!entry || typeof entry.token !== "string") return null;
return entry;
}
export function storeDeviceAuthToken(params: {
deviceId: string;
role: string;
token: string;
scopes?: string[];
env?: NodeJS.ProcessEnv;
}): DeviceAuthEntry {
const filePath = resolveDeviceAuthPath(params.env);
const existing = readStore(filePath);
const role = normalizeRole(params.role);
const next: DeviceAuthStore = {
version: 1,
deviceId: params.deviceId,
tokens:
existing && existing.deviceId === params.deviceId && existing.tokens
? { ...existing.tokens }
: {},
};
const entry: DeviceAuthEntry = {
token: params.token,
role,
scopes: normalizeScopes(params.scopes),
updatedAtMs: Date.now(),
};
next.tokens[role] = entry;
writeStore(filePath, next);
return entry;
}

View File

@@ -0,0 +1,43 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
rotateDeviceToken,
} from "./device-pairing.js";
describe("device pairing tokens", () => {
test("preserves existing token scopes when rotating without scopes", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "clawdbot-device-pairing-"));
const request = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.admin"],
},
baseDir,
);
await approveDevicePairing(request.request.requestId, baseDir);
await rotateDeviceToken({
deviceId: "device-1",
role: "operator",
scopes: ["operator.read"],
baseDir,
});
let paired = await getPairedDevice("device-1", baseDir);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
await rotateDeviceToken({
deviceId: "device-1",
role: "operator",
baseDir,
});
paired = await getPairedDevice("device-1", baseDir);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
});
});

View File

@@ -20,6 +20,25 @@ export type DevicePairingPendingRequest = {
ts: number;
};
export type DeviceAuthToken = {
token: string;
role: string;
scopes: string[];
createdAtMs: number;
rotatedAtMs?: number;
revokedAtMs?: number;
lastUsedAtMs?: number;
};
export type DeviceAuthTokenSummary = {
role: string;
scopes: string[];
createdAtMs: number;
rotatedAtMs?: number;
revokedAtMs?: number;
lastUsedAtMs?: number;
};
export type PairedDevice = {
deviceId: string;
publicKey: string;
@@ -31,6 +50,7 @@ export type PairedDevice = {
roles?: string[];
scopes?: string[];
remoteIp?: string;
tokens?: Record<string, DeviceAuthToken>;
createdAtMs: number;
approvedAtMs: number;
};
@@ -136,6 +156,11 @@ function normalizeDeviceId(deviceId: string) {
return deviceId.trim();
}
function normalizeRole(role: string | undefined): string | null {
const trimmed = role?.trim();
return trimmed ? trimmed : null;
}
function mergeRoles(...items: Array<string | string[] | undefined>): string[] | undefined {
const roles = new Set<string>();
for (const item of items) {
@@ -167,6 +192,27 @@ function mergeScopes(...items: Array<string[] | undefined>): string[] | undefine
return [...scopes];
}
function normalizeScopes(scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) return [];
const out = new Set<string>();
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) out.add(trimmed);
}
return [...out].sort();
}
function scopesAllow(requested: string[], allowed: string[]): boolean {
if (requested.length === 0) return true;
if (allowed.length === 0) return false;
const allowedSet = new Set(allowed);
return requested.every((scope) => allowedSet.has(scope));
}
function newToken() {
return randomUUID().replaceAll("-", "");
}
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
const state = await loadState(baseDir);
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
@@ -237,6 +283,22 @@ export async function approveDevicePairing(
const existing = state.pairedByDeviceId[pending.deviceId];
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
const scopes = mergeScopes(existing?.scopes, pending.scopes);
const tokens = existing?.tokens ? { ...existing.tokens } : {};
const roleForToken = normalizeRole(pending.role);
if (roleForToken) {
const nextScopes = normalizeScopes(pending.scopes);
const existingToken = tokens[roleForToken];
const now = Date.now();
tokens[roleForToken] = {
token: newToken(),
role: roleForToken,
scopes: nextScopes,
createdAtMs: existingToken?.createdAtMs ?? now,
rotatedAtMs: existingToken ? now : undefined,
revokedAtMs: undefined,
lastUsedAtMs: existingToken?.lastUsedAtMs,
};
}
const device: PairedDevice = {
deviceId: pending.deviceId,
publicKey: pending.publicKey,
@@ -248,6 +310,7 @@ export async function approveDevicePairing(
roles,
scopes,
remoteIp: pending.remoteIp,
tokens,
createdAtMs: existing?.createdAtMs ?? now,
approvedAtMs: now,
};
@@ -296,3 +359,142 @@ export async function updatePairedDeviceMetadata(
await persistState(state, baseDir);
});
}
export function summarizeDeviceTokens(
tokens: Record<string, DeviceAuthToken> | undefined,
): DeviceAuthTokenSummary[] | undefined {
if (!tokens) return undefined;
const summaries = Object.values(tokens)
.map((token) => ({
role: token.role,
scopes: token.scopes,
createdAtMs: token.createdAtMs,
rotatedAtMs: token.rotatedAtMs,
revokedAtMs: token.revokedAtMs,
lastUsedAtMs: token.lastUsedAtMs,
}))
.sort((a, b) => a.role.localeCompare(b.role));
return summaries.length > 0 ? summaries : undefined;
}
export async function verifyDeviceToken(params: {
deviceId: string;
token: string;
role: string;
scopes: string[];
baseDir?: string;
}): Promise<{ ok: boolean; reason?: string }> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
if (!device) return { ok: false, reason: "device-not-paired" };
const role = normalizeRole(params.role);
if (!role) return { ok: false, reason: "role-missing" };
const entry = device.tokens?.[role];
if (!entry) return { ok: false, reason: "token-missing" };
if (entry.revokedAtMs) return { ok: false, reason: "token-revoked" };
if (entry.token !== params.token) return { ok: false, reason: "token-mismatch" };
const requestedScopes = normalizeScopes(params.scopes);
if (!scopesAllow(requestedScopes, entry.scopes)) {
return { ok: false, reason: "scope-mismatch" };
}
entry.lastUsedAtMs = Date.now();
device.tokens = { ...(device.tokens ?? {}), [role]: entry };
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, params.baseDir);
return { ok: true };
});
}
export async function ensureDeviceToken(params: {
deviceId: string;
role: string;
scopes: string[];
baseDir?: string;
}): Promise<DeviceAuthToken | null> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
if (!device) return null;
const role = normalizeRole(params.role);
if (!role) return null;
const requestedScopes = normalizeScopes(params.scopes);
const tokens = device.tokens ? { ...device.tokens } : {};
const existing = tokens[role];
if (existing && !existing.revokedAtMs) {
if (scopesAllow(requestedScopes, existing.scopes)) {
return existing;
}
}
const now = Date.now();
const next: DeviceAuthToken = {
token: newToken(),
role,
scopes: requestedScopes,
createdAtMs: existing?.createdAtMs ?? now,
rotatedAtMs: existing ? now : undefined,
revokedAtMs: undefined,
lastUsedAtMs: existing?.lastUsedAtMs,
};
tokens[role] = next;
device.tokens = tokens;
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, params.baseDir);
return next;
});
}
export async function rotateDeviceToken(params: {
deviceId: string;
role: string;
scopes?: string[];
baseDir?: string;
}): Promise<DeviceAuthToken | null> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
if (!device) return null;
const role = normalizeRole(params.role);
if (!role) return null;
const tokens = device.tokens ? { ...device.tokens } : {};
const existing = tokens[role];
const requestedScopes = normalizeScopes(params.scopes ?? existing?.scopes ?? device.scopes);
const now = Date.now();
const next: DeviceAuthToken = {
token: newToken(),
role,
scopes: requestedScopes,
createdAtMs: existing?.createdAtMs ?? now,
rotatedAtMs: now,
revokedAtMs: undefined,
lastUsedAtMs: existing?.lastUsedAtMs,
};
tokens[role] = next;
device.tokens = tokens;
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, params.baseDir);
return next;
});
}
export async function revokeDeviceToken(params: {
deviceId: string;
role: string;
baseDir?: string;
}): Promise<DeviceAuthToken | null> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
if (!device) return null;
const role = normalizeRole(params.role);
if (!role) return null;
if (!device.tokens?.[role]) return null;
const tokens = { ...device.tokens };
const entry = { ...tokens[role], revokedAtMs: Date.now() };
tokens[role] = entry;
device.tokens = tokens;
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, params.baseDir);
return entry;
});
}

View File

@@ -1,4 +1,5 @@
import { lookup } from "node:dns/promises";
import { logWarn } from "../logger.js";
type CanvasModule = typeof import("@napi-rs/canvas");
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
@@ -289,7 +290,14 @@ async function extractPdfContent(params: {
return { text, images: [] };
}
const { createCanvas } = await loadCanvasModule();
let canvasModule: CanvasModule;
try {
canvasModule = await loadCanvasModule();
} catch (err) {
logWarn(`media: PDF image extraction skipped; ${String(err)}`);
return { text, images: [] };
}
const { createCanvas } = canvasModule;
const images: InputImageContent[] = [];
for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) {
const page = await pdf.getPage(pageNum);