feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
export const DEVICE_DISCOVERY_SIGNAL_SCHEMA =
|
||||
"nodedc.device.discovery-signal.v1";
|
||||
export const DEVICE_DISCOVERY_VIEW_SCHEMA =
|
||||
"nodedc.device.discovery-view.v1";
|
||||
export const DEVICE_PLANE_BINDING_SCHEMA =
|
||||
"nodedc.device-plane-control.binding.v1";
|
||||
export const DEVICE_ADAPTER_MESSAGE_SCHEMA =
|
||||
"nodedc.device-adapter-message.v1";
|
||||
export const DEVICE_ADAPTER_MESSAGE_VIEW_SCHEMA =
|
||||
"nodedc.device-adapter-message-view.v1";
|
||||
export const DEVICE_ADAPTER_ACCEPTANCE_SCHEMA =
|
||||
"nodedc.device-adapter-acceptance.v1";
|
||||
|
||||
export const DEVICE_LIFECYCLE_STATES = Object.freeze([
|
||||
"quarantine",
|
||||
"claimed",
|
||||
"online",
|
||||
"offline",
|
||||
"retired",
|
||||
]);
|
||||
|
||||
export const DEVICE_BINDING_CAPABILITIES = Object.freeze([
|
||||
"observe",
|
||||
"inspect",
|
||||
"configure",
|
||||
"command",
|
||||
]);
|
||||
|
||||
const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const IMEI_RE = /^\d{15}$/;
|
||||
const IDENTIFIER_VALUE_RE = /^[A-Za-z0-9][A-Za-z0-9._:+\/-]{3,127}$/;
|
||||
const DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/;
|
||||
const SHA256_DIGEST_RE = /^sha256:[a-f0-9]{64}$/;
|
||||
const IDENTIFIER_KIND_RE = /^[a-z][a-z0-9._:-]{1,63}$/;
|
||||
const forbiddenKeyFragments = Object.freeze([
|
||||
"password",
|
||||
"secret",
|
||||
"credential",
|
||||
"rawpayload",
|
||||
"rawpacket",
|
||||
"command",
|
||||
"authorization",
|
||||
"token",
|
||||
]);
|
||||
const safeStatusKeys = new Set([
|
||||
"commandtransport",
|
||||
]);
|
||||
|
||||
export function normalizeDiscoverySignal(input) {
|
||||
assertPlainObject(input, "discovery_signal");
|
||||
rejectForbiddenKeys(input);
|
||||
|
||||
if (input.schemaVersion !== DEVICE_DISCOVERY_SIGNAL_SCHEMA) {
|
||||
throw new TypeError("discovery_signal_schema_invalid");
|
||||
}
|
||||
|
||||
const sessionRef = normalizeOpaqueRef(input.sessionRef, "session_ref");
|
||||
const routeRef = input.routeRef == null
|
||||
? undefined
|
||||
: normalizeEntityRef(input.routeRef, "route", "route_ref");
|
||||
const modelProfileRef = normalizeOpaqueRef(
|
||||
input.modelProfileRef,
|
||||
"model_profile_ref",
|
||||
);
|
||||
const protocol = normalizeUpperToken(input.protocol, "protocol");
|
||||
const observedAt = normalizeTimestamp(input.observedAt, "observed_at");
|
||||
const identifier = normalizeRestrictedIdentifier(input.identifier);
|
||||
const evidence = normalizeDiscoveryEvidence(input.evidence);
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
sessionRef,
|
||||
...(routeRef ? { routeRef } : {}),
|
||||
modelProfileRef,
|
||||
protocol,
|
||||
observedAt,
|
||||
identifier,
|
||||
evidence,
|
||||
lifecycleState: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAdapterMessage(input, { maxBytes = 1024 * 1024 } = {}) {
|
||||
assertPlainObject(input, "device_adapter_message");
|
||||
const allowedKeys = new Set([
|
||||
"schemaVersion",
|
||||
"edgeRef",
|
||||
"adapterRef",
|
||||
"protocolProfileRef",
|
||||
"protocol",
|
||||
"sessionRef",
|
||||
"routeRef",
|
||||
"messageRef",
|
||||
"messageType",
|
||||
"sequence",
|
||||
"observedAt",
|
||||
"idempotencyKey",
|
||||
"identifier",
|
||||
"payloadSchemaRef",
|
||||
"payload",
|
||||
]);
|
||||
rejectUnexpectedKeys(input, allowedKeys, "device_adapter_message_field_unexpected");
|
||||
rejectForbiddenKeys(input);
|
||||
if (input.schemaVersion !== DEVICE_ADAPTER_MESSAGE_SCHEMA) {
|
||||
throw new TypeError("device_adapter_message_schema_invalid");
|
||||
}
|
||||
const normalizedMaxBytes = normalizeByteLimit(maxBytes);
|
||||
const serializedBytes = Buffer.byteLength(JSON.stringify(input), "utf8");
|
||||
if (serializedBytes > normalizedMaxBytes) {
|
||||
throw new TypeError("device_adapter_message_too_large");
|
||||
}
|
||||
const sequence = Number(input.sequence);
|
||||
if (!Number.isSafeInteger(sequence) || sequence < 1) {
|
||||
throw new TypeError("device_adapter_message_sequence_invalid");
|
||||
}
|
||||
assertPlainObject(input.payload, "device_adapter_message_payload");
|
||||
assertJsonValue(input.payload, 0);
|
||||
|
||||
return deepFreeze({
|
||||
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
|
||||
edgeRef: normalizeOpaqueRef(input.edgeRef, "edge_ref"),
|
||||
adapterRef: normalizeAdapterRef(input.adapterRef),
|
||||
protocolProfileRef: normalizeOpaqueRef(
|
||||
input.protocolProfileRef,
|
||||
"protocol_profile_ref",
|
||||
),
|
||||
protocol: normalizeUpperToken(input.protocol, "protocol"),
|
||||
sessionRef: normalizeOpaqueRef(input.sessionRef, "session_ref"),
|
||||
...(input.routeRef == null
|
||||
? {}
|
||||
: { routeRef: normalizeEntityRef(input.routeRef, "route", "route_ref") }),
|
||||
messageRef: normalizeOpaqueRef(input.messageRef, "message_ref"),
|
||||
messageType: normalizeLowerToken(input.messageType, "message_type"),
|
||||
sequence,
|
||||
observedAt: normalizeTimestamp(input.observedAt, "observed_at"),
|
||||
idempotencyKey: normalizeSha256Digest(
|
||||
input.idempotencyKey,
|
||||
"idempotency_key",
|
||||
),
|
||||
identifier: normalizeRestrictedIdentifier(input.identifier),
|
||||
payloadSchemaRef: normalizeOpaqueRef(
|
||||
input.payloadSchemaRef,
|
||||
"payload_schema_ref",
|
||||
),
|
||||
payload: cloneJsonValue(input.payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function toSafeAdapterMessageView(message) {
|
||||
const normalized = normalizeAdapterMessage(message);
|
||||
return deepFreeze({
|
||||
schemaVersion: DEVICE_ADAPTER_MESSAGE_VIEW_SCHEMA,
|
||||
edgeRef: normalized.edgeRef,
|
||||
adapterRef: normalized.adapterRef,
|
||||
protocolProfileRef: normalized.protocolProfileRef,
|
||||
protocol: normalized.protocol,
|
||||
sessionRef: normalized.sessionRef,
|
||||
...(normalized.routeRef ? { routeRef: normalized.routeRef } : {}),
|
||||
messageRef: normalized.messageRef,
|
||||
messageType: normalized.messageType,
|
||||
sequence: normalized.sequence,
|
||||
observedAt: normalized.observedAt,
|
||||
idempotencyKey: normalized.idempotencyKey,
|
||||
identifier: normalizeRestrictedIdentifierProjection({
|
||||
kind: normalized.identifier.kind,
|
||||
masked: maskRestrictedIdentifier(normalized.identifier),
|
||||
}),
|
||||
payloadSchemaRef: normalized.payloadSchemaRef,
|
||||
payload: normalized.payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAdapterAcceptance(input) {
|
||||
assertPlainObject(input, "device_adapter_acceptance");
|
||||
const allowedKeys = new Set([
|
||||
"schemaVersion",
|
||||
"acceptanceRef",
|
||||
"idempotencyKey",
|
||||
"status",
|
||||
"replayed",
|
||||
"acceptedAt",
|
||||
]);
|
||||
rejectUnexpectedKeys(input, allowedKeys, "device_adapter_acceptance_field_unexpected");
|
||||
if (input.schemaVersion !== DEVICE_ADAPTER_ACCEPTANCE_SCHEMA) {
|
||||
throw new TypeError("device_adapter_acceptance_schema_invalid");
|
||||
}
|
||||
if (input.status !== "accepted") {
|
||||
throw new TypeError("device_adapter_acceptance_status_invalid");
|
||||
}
|
||||
if (typeof input.replayed !== "boolean") {
|
||||
throw new TypeError("device_adapter_acceptance_replayed_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: DEVICE_ADAPTER_ACCEPTANCE_SCHEMA,
|
||||
acceptanceRef: normalizeOpaqueRef(input.acceptanceRef, "acceptance_ref"),
|
||||
idempotencyKey: normalizeSha256Digest(
|
||||
input.idempotencyKey,
|
||||
"idempotency_key",
|
||||
),
|
||||
status: "accepted",
|
||||
replayed: input.replayed,
|
||||
acceptedAt: normalizeTimestamp(input.acceptedAt, "accepted_at"),
|
||||
});
|
||||
}
|
||||
|
||||
export function toSafeDiscoveryView(signal, options = {}) {
|
||||
const normalized = normalizeDiscoverySignal(signal);
|
||||
const discoveryRef = options.discoveryRef
|
||||
? normalizeOpaqueRef(options.discoveryRef, "discovery_ref")
|
||||
: undefined;
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: DEVICE_DISCOVERY_VIEW_SCHEMA,
|
||||
...(discoveryRef ? { discoveryRef } : {}),
|
||||
...(normalized.routeRef ? { routeRef: normalized.routeRef } : {}),
|
||||
modelProfileRef: normalized.modelProfileRef,
|
||||
protocol: normalized.protocol,
|
||||
observedAt: normalized.observedAt,
|
||||
lifecycleState: normalized.lifecycleState,
|
||||
identifier: normalizeRestrictedIdentifierProjection({
|
||||
kind: normalized.identifier.kind,
|
||||
masked: maskRestrictedIdentifier(normalized.identifier),
|
||||
}),
|
||||
evidence: normalized.evidence,
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
export function hashRestrictedIdentifier(identifier, pepper) {
|
||||
const normalized = normalizeRestrictedIdentifier(identifier);
|
||||
if (typeof pepper !== "string" || pepper.length < 32) {
|
||||
throw new TypeError("identifier_pepper_invalid");
|
||||
}
|
||||
|
||||
const digest = createHmac("sha256", pepper)
|
||||
.update(`${normalized.kind}\0${normalized.value}`, "utf8")
|
||||
.digest("hex");
|
||||
return `hmac-sha256:${digest}`;
|
||||
}
|
||||
|
||||
export function assertIdentifierDigest(value) {
|
||||
if (typeof value !== "string" || !DIGEST_RE.test(value)) {
|
||||
throw new TypeError("identifier_digest_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeRestrictedIdentifierProjection(input) {
|
||||
assertPlainObject(input, "restricted_identifier_projection");
|
||||
const allowedKeys = new Set(["kind", "masked"]);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new TypeError(
|
||||
`restricted_identifier_projection_field_unexpected:${key}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof input.kind !== "string" || !IDENTIFIER_KIND_RE.test(input.kind)) {
|
||||
throw new TypeError("restricted_identifier_projection_kind_invalid");
|
||||
}
|
||||
if (
|
||||
typeof input.masked !== "string"
|
||||
|| input.masked.length < 5
|
||||
|| input.masked.length > 128
|
||||
|| !input.masked.includes("*")
|
||||
|| /\u0000|[\u0001-\u001f\u007f]/.test(input.masked)
|
||||
|| /\b\d{15}\b/.test(input.masked)
|
||||
) {
|
||||
throw new TypeError("restricted_identifier_projection_mask_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: input.kind,
|
||||
masked: input.masked,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRestrictedIdentifierRecord(input) {
|
||||
assertPlainObject(input, "restricted_identifier_record");
|
||||
const allowedKeys = new Set(["kind", "digest", "masked"]);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new TypeError(
|
||||
`restricted_identifier_record_field_unexpected:${key}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const projection = normalizeRestrictedIdentifierProjection({
|
||||
kind: input.kind,
|
||||
masked: input.masked,
|
||||
});
|
||||
return Object.freeze({
|
||||
...projection,
|
||||
digest: assertIdentifierDigest(input.digest),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeDevicePlaneBinding(input) {
|
||||
assertPlainObject(input, "device_plane_binding");
|
||||
rejectForbiddenKeys(input);
|
||||
if (input.schemaVersion !== DEVICE_PLANE_BINDING_SCHEMA) {
|
||||
throw new TypeError("device_plane_binding_schema_invalid");
|
||||
}
|
||||
|
||||
const allowed = new Set(DEVICE_BINDING_CAPABILITIES);
|
||||
if (!Array.isArray(input.capabilities) || input.capabilities.length === 0) {
|
||||
throw new TypeError("device_plane_binding_capabilities_invalid");
|
||||
}
|
||||
const capabilities = [...new Set(input.capabilities.map((value) => {
|
||||
if (typeof value !== "string" || !allowed.has(value)) {
|
||||
throw new TypeError("device_plane_binding_capability_invalid");
|
||||
}
|
||||
return value;
|
||||
}))].sort();
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: DEVICE_PLANE_BINDING_SCHEMA,
|
||||
bindingRef: normalizeOpaqueRef(input.bindingRef, "binding_ref"),
|
||||
contourRef: normalizeOpaqueRef(input.contourRef, "contour_ref"),
|
||||
capabilities: Object.freeze(capabilities),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertSafeProjection(value) {
|
||||
assertPlainObject(value, "safe_projection");
|
||||
rejectForbiddenKeys(value);
|
||||
const serialized = JSON.stringify(value);
|
||||
if (/\b\d{15}\b/.test(serialized)) {
|
||||
throw new TypeError("safe_projection_contains_unmasked_imei");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeRestrictedIdentifier(input) {
|
||||
assertPlainObject(input, "restricted_identifier");
|
||||
rejectUnexpectedKeys(
|
||||
input,
|
||||
new Set(["kind", "value"]),
|
||||
"restricted_identifier_field_unexpected",
|
||||
);
|
||||
if (typeof input.kind !== "string" || !IDENTIFIER_KIND_RE.test(input.kind)) {
|
||||
throw new TypeError("restricted_identifier_kind_invalid");
|
||||
}
|
||||
if (input.kind === "imei" && !IMEI_RE.test(input.value)) {
|
||||
throw new TypeError("restricted_identifier_imei_invalid");
|
||||
}
|
||||
if (
|
||||
typeof input.value !== "string"
|
||||
|| !IDENTIFIER_VALUE_RE.test(input.value)
|
||||
) {
|
||||
throw new TypeError("restricted_identifier_value_invalid");
|
||||
}
|
||||
return Object.freeze({ kind: input.kind, value: input.value });
|
||||
}
|
||||
|
||||
export function maskRestrictedIdentifier(identifier) {
|
||||
const normalized = normalizeRestrictedIdentifier(identifier);
|
||||
if (normalized.kind === "imei") {
|
||||
return `***********${normalized.value.slice(-4)}`;
|
||||
}
|
||||
const visible = normalized.value.slice(-4);
|
||||
const maskedLength = Math.min(
|
||||
124,
|
||||
Math.max(4, normalized.value.length - visible.length),
|
||||
);
|
||||
return `${"*".repeat(maskedLength)}${visible}`;
|
||||
}
|
||||
|
||||
function normalizeDiscoveryEvidence(input) {
|
||||
assertPlainObject(input, "discovery_evidence");
|
||||
rejectForbiddenKeys(input);
|
||||
if (input.transport !== "tcp") {
|
||||
throw new TypeError("discovery_evidence_transport_invalid");
|
||||
}
|
||||
const bytesObserved = Number(input.bytesObserved);
|
||||
if (!Number.isSafeInteger(bytesObserved) || bytesObserved < 1 || bytesObserved > 4096) {
|
||||
throw new TypeError("discovery_evidence_bytes_invalid");
|
||||
}
|
||||
if (input.framingStatus !== "verified") {
|
||||
throw new TypeError("discovery_evidence_framing_unverified");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
transport: "tcp",
|
||||
bytesObserved,
|
||||
framingStatus: "verified",
|
||||
specificationRef: normalizeOpaqueRef(
|
||||
input.specificationRef,
|
||||
"framing_specification_ref",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function rejectForbiddenKeys(value, path = "$") {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => rejectForbiddenKeys(item, `${path}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
if (
|
||||
!safeStatusKeys.has(normalizedKey)
|
||||
&& forbiddenKeyFragments.some((fragment) => normalizedKey.includes(fragment))
|
||||
) {
|
||||
throw new TypeError(`forbidden_device_field:${path}.${key}`);
|
||||
}
|
||||
rejectForbiddenKeys(child, `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectUnexpectedKeys(input, allowedKeys, errorCode) {
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedKeys.has(key)) throw new TypeError(`${errorCode}:${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLowerToken(value, label) {
|
||||
if (typeof value !== "string" || !/^[a-z][a-z0-9._-]{1,127}$/.test(value)) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeAdapterRef(value) {
|
||||
if (typeof value !== "string" || !/^[a-z][a-z0-9-]{1,62}$/.test(value)) {
|
||||
throw new TypeError("adapter_ref_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSha256Digest(value, label) {
|
||||
if (typeof value !== "string" || !SHA256_DIGEST_RE.test(value)) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeByteLimit(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1024 || parsed > 1024 * 1024) {
|
||||
throw new TypeError("device_adapter_message_limit_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function assertJsonValue(value, depth) {
|
||||
if (depth > 12) throw new TypeError("device_adapter_message_payload_too_deep");
|
||||
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
||||
if (typeof value === "string" && value.length > 64 * 1024) {
|
||||
throw new TypeError("device_adapter_message_payload_string_too_large");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new TypeError("device_adapter_message_payload_number_invalid");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 4096) {
|
||||
throw new TypeError("device_adapter_message_payload_array_too_large");
|
||||
}
|
||||
value.forEach((item) => assertJsonValue(item, depth + 1));
|
||||
return;
|
||||
}
|
||||
assertPlainObject(value, "device_adapter_message_payload");
|
||||
if (Object.keys(value).length > 1024) {
|
||||
throw new TypeError("device_adapter_message_payload_object_too_large");
|
||||
}
|
||||
for (const child of Object.values(value)) assertJsonValue(child, depth + 1);
|
||||
}
|
||||
|
||||
function cloneJsonValue(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function normalizeOpaqueRef(value, label) {
|
||||
if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeEntityRef(value, prefix, label) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !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",
|
||||
).test(value)
|
||||
) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeUpperToken(value, label) {
|
||||
if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value, label) {
|
||||
if (typeof value !== "string") throw new TypeError(`${label}_invalid`);
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPlainObject(value, label) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
|
||||
return value;
|
||||
}
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
Reference in New Issue
Block a user