feat(device-core): add control resource ledger
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
DEVICE_BINDING_CAPABILITIES,
|
||||
assertSafeProjection,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
export const DEVICE_CONTROL_RESOURCE_COMMAND_KINDS = Object.freeze([
|
||||
"device_binding.ensure",
|
||||
"device_binding.revoke",
|
||||
"device_configuration_revision.create",
|
||||
"device_configuration_desired.set",
|
||||
]);
|
||||
|
||||
const commandKindSet = new Set(DEVICE_CONTROL_RESOURCE_COMMAND_KINDS);
|
||||
const bindingCapabilitySet = new Set(DEVICE_BINDING_CAPABILITIES);
|
||||
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const tokenPattern = /^[a-z][a-z0-9._:-]{1,63}$/;
|
||||
const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/;
|
||||
const targetRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
|
||||
const configurationKeyPattern = /^[a-z][a-z0-9._-]{0,63}$/;
|
||||
const secretReferencePattern = /^(?:ndc-credref:|(?:bearer|basic)\s)|[?&](?:token|secret|password|api[_-]?key)=/i;
|
||||
|
||||
export function isControlResourceManagementCommand(kind) {
|
||||
return commandKindSet.has(kind);
|
||||
}
|
||||
|
||||
export function normalizeControlResourceManagementCommand(kind, input) {
|
||||
if (!commandKindSet.has(kind)) {
|
||||
throw new TypeError("device_control_resource_command_kind_invalid");
|
||||
}
|
||||
assertPlainObject(input, "device_control_resource_command_invalid");
|
||||
|
||||
if (kind === "device_binding.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"bindingKey",
|
||||
"displayName",
|
||||
"source",
|
||||
"targetKind",
|
||||
"targetRef",
|
||||
"capabilities",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
bindingKey: normalizePattern(
|
||||
input.bindingKey,
|
||||
keyPattern,
|
||||
"device_binding_key_invalid",
|
||||
),
|
||||
displayName: normalizeDisplayText(
|
||||
input.displayName,
|
||||
160,
|
||||
"device_binding_name_invalid",
|
||||
),
|
||||
source: normalizeBindingSource(input.source),
|
||||
targetKind: normalizePattern(
|
||||
input.targetKind,
|
||||
tokenPattern,
|
||||
"device_binding_target_kind_invalid",
|
||||
),
|
||||
targetRef: normalizeTargetRef(input.targetRef),
|
||||
capabilities: Object.freeze(normalizeBindingCapabilities(
|
||||
input.capabilities,
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "device_binding.revoke") {
|
||||
assertAllowedKeys(input, ["projectRef", "bindingRef", "resolutionCode"]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
bindingId: normalizeEntityRef(input.bindingRef, "binding"),
|
||||
resolutionCode: normalizePattern(
|
||||
input.resolutionCode,
|
||||
resolutionPattern,
|
||||
"device_binding_resolution_code_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "device_configuration_revision.create") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"deviceRef",
|
||||
"configuration",
|
||||
"changeSummary",
|
||||
]);
|
||||
const configuration = normalizeDeviceConfiguration(input.configuration);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
configuration,
|
||||
configurationDigest: `sha256:${createHash("sha256")
|
||||
.update(JSON.stringify(configuration), "utf8")
|
||||
.digest("hex")}`,
|
||||
changeSummary: normalizeOptionalText(
|
||||
input.changeSummary,
|
||||
1000,
|
||||
"device_configuration_change_summary_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"deviceRef",
|
||||
"configurationRevisionRef",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
configurationRevisionId: normalizeEntityRef(
|
||||
input.configurationRevisionRef,
|
||||
"configuration-revision",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeDeviceConfiguration(input) {
|
||||
const normalized = normalizeConfigurationValue(input, 0, "$configuration");
|
||||
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
|
||||
throw new TypeError("device_configuration_must_be_object");
|
||||
}
|
||||
if (Object.keys(normalized).length === 0) {
|
||||
throw new TypeError("device_configuration_must_not_be_empty");
|
||||
}
|
||||
const serialized = JSON.stringify(normalized);
|
||||
if (Buffer.byteLength(serialized, "utf8") > 32768) {
|
||||
throw new TypeError("device_configuration_too_large");
|
||||
}
|
||||
assertSafeProjection({ configuration: normalized });
|
||||
return deepFreeze(normalized);
|
||||
}
|
||||
|
||||
function normalizeBindingSource(input) {
|
||||
assertPlainObject(input, "device_binding_source_invalid");
|
||||
assertAllowedKeys(input, ["kind", "ref"]);
|
||||
if (input.kind === "device") {
|
||||
return Object.freeze({
|
||||
kind: "device",
|
||||
id: normalizeEntityRef(input.ref, "device"),
|
||||
});
|
||||
}
|
||||
if (input.kind === "collection") {
|
||||
return Object.freeze({
|
||||
kind: "collection",
|
||||
id: normalizeEntityRef(input.ref, "collection"),
|
||||
});
|
||||
}
|
||||
throw new TypeError("device_binding_source_kind_invalid");
|
||||
}
|
||||
|
||||
function normalizeBindingCapabilities(input) {
|
||||
if (!Array.isArray(input) || input.length < 1 || input.length > 16) {
|
||||
throw new TypeError("device_binding_capabilities_invalid");
|
||||
}
|
||||
const normalized = input.map((value) => {
|
||||
if (typeof value !== "string" || !bindingCapabilitySet.has(value)) {
|
||||
throw new TypeError("device_binding_capability_invalid");
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new TypeError("device_binding_capabilities_duplicate");
|
||||
}
|
||||
return normalized.sort();
|
||||
}
|
||||
|
||||
function normalizeTargetRef(value) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !targetRefPattern.test(value)
|
||||
|| secretReferencePattern.test(value)
|
||||
) {
|
||||
throw new TypeError("device_binding_target_ref_invalid");
|
||||
}
|
||||
assertSafeProjection({ targetRef: value });
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeConfigurationValue(value, depth, path) {
|
||||
if (depth > 5) throw new TypeError("device_configuration_depth_exceeded");
|
||||
if (value === null || typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new TypeError(`device_configuration_number_invalid:${path}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (
|
||||
value.length > 1000
|
||||
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new TypeError(`device_configuration_string_invalid:${path}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 64) {
|
||||
throw new TypeError(`device_configuration_array_invalid:${path}`);
|
||||
}
|
||||
return value.map((item, index) =>
|
||||
normalizeConfigurationValue(item, depth + 1, `${path}[${index}]`)
|
||||
);
|
||||
}
|
||||
assertPlainObject(value, `device_configuration_object_invalid:${path}`);
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > 64) {
|
||||
throw new TypeError(`device_configuration_object_invalid:${path}`);
|
||||
}
|
||||
const normalized = {};
|
||||
for (const key of keys.sort()) {
|
||||
if (!configurationKeyPattern.test(key)) {
|
||||
throw new TypeError(`device_configuration_key_invalid:${path}.${key}`);
|
||||
}
|
||||
normalized[key] = normalizeConfigurationValue(
|
||||
value[key],
|
||||
depth + 1,
|
||||
`${path}.${key}`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeEntityRef(value, prefix) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
}
|
||||
const match = value.match(new RegExp(
|
||||
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
|
||||
"i",
|
||||
));
|
||||
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizePattern(value, pattern, code) {
|
||||
if (typeof value !== "string" || !pattern.test(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeDisplayText(value, maxLength, code) {
|
||||
if (typeof value !== "string") throw new TypeError(code);
|
||||
const normalized = value.trim();
|
||||
if (
|
||||
normalized.length < 1
|
||||
|| normalized.length > maxLength
|
||||
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)
|
||||
) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value, maxLength, code) {
|
||||
if (value == null || value === "") return null;
|
||||
return normalizeDisplayText(value, maxLength, code);
|
||||
}
|
||||
|
||||
function assertPlainObject(value, code) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedKeys(input, allowed) {
|
||||
const allowedSet = new Set(allowed);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedSet.has(key)) {
|
||||
throw new TypeError(`device_management_command_field_unexpected:${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user