fix(shared): model pairing approval state from effective access

This commit is contained in:
Ayaan Zaidi
2026-04-20 13:05:31 +05:30
parent 4e01916a7e
commit 9de39accdb
2 changed files with 174 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { resolvePendingDeviceApprovalState } from "./device-pairing-access.js";
describe("resolvePendingDeviceApprovalState", () => {
it("treats legacy singular approved role fields as approved access", () => {
expect(
resolvePendingDeviceApprovalState(
{
role: "operator",
scopes: ["operator.read"],
},
{
role: "operator",
scopes: ["operator.read"],
},
),
).toEqual({
kind: "re-approval",
requested: {
roles: ["operator"],
scopes: ["operator.read"],
},
approved: {
roles: ["operator"],
scopes: ["operator.read"],
},
});
});
it("treats revoked approved-role tokens as a role upgrade", () => {
expect(
resolvePendingDeviceApprovalState(
{
role: "operator",
scopes: ["operator.read"],
},
{
role: "operator",
scopes: ["operator.read"],
tokens: {
operator: {
role: "operator",
revokedAtMs: Date.now(),
},
},
},
),
).toEqual({
kind: "role-upgrade",
requested: {
roles: ["operator"],
scopes: ["operator.read"],
},
approved: {
roles: [],
scopes: ["operator.read"],
},
});
});
});

View File

@@ -0,0 +1,114 @@
import { normalizeDeviceAuthScopes } from "./device-auth.js";
export type DevicePairingAccessSummary = {
roles: string[];
scopes: string[];
};
export type PendingDeviceApprovalKind =
| "new-pairing"
| "role-upgrade"
| "scope-upgrade"
| "re-approval";
export type PendingDeviceApprovalState = {
kind: PendingDeviceApprovalKind;
requested: DevicePairingAccessSummary;
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) {
if (!item) {
continue;
}
if (Array.isArray(item)) {
for (const role of item) {
const trimmed = role.trim();
if (trimmed) {
roles.add(trimmed);
}
}
continue;
}
const trimmed = item.trim();
if (trimmed) {
roles.add(trimmed);
}
}
return [...roles].toSorted();
}
function includesAll(allowed: readonly string[], requested: readonly string[]): boolean {
const allowedSet = new Set(allowed);
return requested.every((value) => allowedSet.has(value));
}
export function summarizePendingDeviceAccess(request: PendingLike): DevicePairingAccessSummary {
return {
roles: normalizeRoleList(request.roles, request.role),
scopes: normalizeDeviceAuthScopes(request.scopes),
};
}
export 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),
};
}
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 };
}