Files
openclaw/src/shared/device-pairing-access.ts
Peter Steinberger e2ec8283c4 refactor(deadcode): trim mid-size src exports (#105888)
* refactor(deadcode): trim auto-reply and CLI exports

* refactor(deadcode): trim cron and task exports

* refactor(deadcode): trim fleet and process exports

* test(deadcode): exercise live task and process seams

* test(fleet): cover stream redaction through owner module

* refactor(security): trim dead internal exports

* refactor(secrets): trim dead internal exports

* refactor(deadcode): trim remaining src exports

* refactor(deadcode): remove test-only runtime exports

* refactor(deadcode): trim pairing test exports

* refactor(deadcode): reconcile refreshed baseline

* test(auto-reply): deduplicate queue state imports
2026-07-13 00:42:56 -07:00

114 lines
3.8 KiB
TypeScript

// Device pairing access helpers evaluate pairing scopes and role permissions.
import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { normalizeDeviceAuthScopes } from "./device-auth.js";
export type DevicePairingAccessSummary = {
/** Normalized role ids requested or approved for a device. */
roles: string[];
/** Normalized scope ids, including implied operator scopes. */
scopes: string[];
};
/** Approval classification shown when a pending pairing differs from existing grants. */
export type PendingDeviceApprovalKind =
| "new-pairing"
| "role-upgrade"
| "scope-upgrade"
| "re-approval";
type PendingDeviceApprovalState = {
kind: PendingDeviceApprovalKind;
/** Access requested by the pending pairing attempt. */
requested: DevicePairingAccessSummary;
/** Existing active access, or null for a new pairing. */
approved: DevicePairingAccessSummary | null;
};
type PendingLike = {
role?: string;
roles?: string[];
scopes?: string[];
};
type PairedLike = {
role?: string;
roles?: string[];
scopes?: string[];
tokens?:
| Array<{
role?: string;
revokedAtMs?: number | null;
}>
| Record<
string,
{
role?: string;
revokedAtMs?: number | null;
}
>;
};
function normalizeRoleList(...items: Array<string | string[] | undefined>): string[] {
const roles = new Set<string>();
for (const item of items) {
// On-disk pairing records are blind-cast, so roles/role may be non-strings; the shared
// normalizer drops them instead of crashing on .trim() (matches the scopes path + mergeRoles).
for (const role of normalizeUniqueSingleOrTrimmedStringList(item)) {
roles.add(role);
}
}
return [...roles].toSorted();
}
function includesAll(allowed: readonly string[], requested: readonly string[]): boolean {
const allowedSet = new Set(allowed);
return requested.every((value) => allowedSet.has(value));
}
/** Normalizes requested roles/scopes from pending pairing records, including legacy singular role. */
function summarizePendingDeviceAccess(request: PendingLike): DevicePairingAccessSummary {
return {
roles: normalizeRoleList(request.roles, request.role),
scopes: normalizeDeviceAuthScopes(request.scopes),
};
}
/** Summarizes currently approved device access, excluding roles whose tokens are revoked. */
function summarizeApprovedDeviceAccess(device: PairedLike): DevicePairingAccessSummary {
const approvedRoles = normalizeRoleList(device.roles, device.role);
const tokenList = Array.isArray(device.tokens)
? device.tokens
: device.tokens
? Object.values(device.tokens)
: undefined;
const activeTokenRoles =
tokenList === undefined
? approvedRoles
: normalizeRoleList(
tokenList.filter((token) => !token.revokedAtMs).flatMap((token) => token.role ?? []),
).filter((role) => approvedRoles.includes(role));
return {
roles: activeTokenRoles,
scopes: normalizeDeviceAuthScopes(device.scopes),
};
}
/** Classifies a pending pairing request as new pairing, role upgrade, scope upgrade, or re-approval. */
export function resolvePendingDeviceApprovalState(
request: PendingLike,
paired?: PairedLike,
): PendingDeviceApprovalState {
const requested = summarizePendingDeviceAccess(request);
const approved = paired ? summarizeApprovedDeviceAccess(paired) : null;
if (!approved) {
return { kind: "new-pairing", requested, approved: null };
}
if (!includesAll(approved.roles, requested.roles)) {
return { kind: "role-upgrade", requested, approved };
}
if (!includesAll(approved.scopes, requested.scopes)) {
return { kind: "scope-upgrade", requested, approved };
}
return { kind: "re-approval", requested, approved };
}