feat(device-plane): add universal adapter acceptance boundary
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
DEVICE_ADAPTER_CONTRACT_VERSION,
|
||||
defineDeviceAdapter,
|
||||
} from "../../device-adapter-runtime/src/index.mjs";
|
||||
|
||||
export const ARUSNAVI_INTERNAL_SPECIFICATION_REF =
|
||||
"arusnavi.internal.protocol-sheet.gid-12.v1";
|
||||
|
||||
@@ -53,6 +60,26 @@ export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
|
||||
},
|
||||
});
|
||||
|
||||
export const ARUSNAVI_B2_ADAPTER = defineDeviceAdapter({
|
||||
contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION,
|
||||
adapterRef: "arusnavi-b2",
|
||||
runtimePackageRef: "@nodedc/arusnavi-b2-adapter",
|
||||
profiles: [ARUSNAVI_B2_MODEL_PROFILE],
|
||||
createSession({ profileRef } = {}) {
|
||||
if (profileRef !== ARUSNAVI_B2_MODEL_PROFILE.profileRef) {
|
||||
throw new TypeError("b2_adapter_profile_unsupported");
|
||||
}
|
||||
return {
|
||||
parseHeader: tryParseB2Header2,
|
||||
buildHeaderAcknowledgement: buildB2HeaderAcknowledgement,
|
||||
parseMessage: tryParseB2Package,
|
||||
buildMessageAcknowledgement(message) {
|
||||
return buildB2PackageAcknowledgement(message.packageNumber);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function tryParseB2Header2(input) {
|
||||
assertBuffer(input, "b2_header_buffer_required");
|
||||
if (input.length < HEADER2_LENGTH) {
|
||||
@@ -148,6 +175,16 @@ export function tryParseB2Package(input) {
|
||||
bytesConsumed: offset + 1,
|
||||
packageNumber,
|
||||
packetCount,
|
||||
messageType: "telemetry.package",
|
||||
payloadSchemaRef: "arusnavi.internal.package-metadata.v1",
|
||||
payload: Object.freeze({
|
||||
packageNumber,
|
||||
packetCount,
|
||||
byteLength: offset + 1,
|
||||
packageDigest: `sha256:${createHash("sha256")
|
||||
.update(input.subarray(0, offset + 1))
|
||||
.digest("hex")}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (input.length - offset < 3) {
|
||||
|
||||
@@ -66,11 +66,22 @@ test("parses and acknowledges the official package example", () => {
|
||||
tryParseB2Package(specificationPackage.subarray(0, -1)).status,
|
||||
"incomplete",
|
||||
);
|
||||
assert.deepEqual(tryParseB2Package(specificationPackage), {
|
||||
status: "complete",
|
||||
bytesConsumed: specificationPackage.length,
|
||||
const parsed = tryParseB2Package(specificationPackage);
|
||||
assert.equal(parsed.status, "complete");
|
||||
assert.equal(parsed.bytesConsumed, specificationPackage.length);
|
||||
assert.equal(parsed.packageNumber, 1);
|
||||
assert.equal(parsed.packetCount, 1);
|
||||
assert.equal(parsed.messageType, "telemetry.package");
|
||||
assert.equal(
|
||||
parsed.payloadSchemaRef,
|
||||
"arusnavi.internal.package-metadata.v1",
|
||||
);
|
||||
assert.deepEqual(parsed.payload, {
|
||||
packageNumber: 1,
|
||||
packetCount: 1,
|
||||
byteLength: specificationPackage.length,
|
||||
packageDigest:
|
||||
"sha256:bba7205d2f613ac3dfdb7bccdee292b3837e1c8d3d1254ee68bba2dda3853e11",
|
||||
});
|
||||
assert.equal(
|
||||
buildB2PackageAcknowledgement(1).toString("hex").toUpperCase(),
|
||||
@@ -93,12 +104,14 @@ test("uses packet lengths and checksum instead of scanning for 0x5D", () => {
|
||||
checksum,
|
||||
0x5d,
|
||||
]);
|
||||
assert.deepEqual(tryParseB2Package(packageBytes), {
|
||||
status: "complete",
|
||||
bytesConsumed: packageBytes.length,
|
||||
packageNumber: 2,
|
||||
packetCount: 1,
|
||||
});
|
||||
const parsed = tryParseB2Package(packageBytes);
|
||||
assert.equal(parsed.status, "complete");
|
||||
assert.equal(parsed.bytesConsumed, packageBytes.length);
|
||||
assert.equal(parsed.packageNumber, 2);
|
||||
assert.equal(parsed.packetCount, 1);
|
||||
assert.equal(parsed.messageType, "telemetry.package");
|
||||
assert.equal(parsed.payload.byteLength, packageBytes.length);
|
||||
assert.match(parsed.payload.packageDigest, /^sha256:[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test("fails closed on unsupported headers and malformed packages", () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nodedc/device-adapter-catalog",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
createDeviceAdapterRegistry,
|
||||
} from "../../device-adapter-runtime/src/index.mjs";
|
||||
import {
|
||||
ARUSNAVI_B2_ADAPTER,
|
||||
} from "../../arusnavi-b2-adapter/src/index.mjs";
|
||||
|
||||
export const DEVICE_ADAPTER_CATALOG = Object.freeze({
|
||||
defaultProfileRef: "arusnavi.b2.internal.v1",
|
||||
registry: createDeviceAdapterRegistry({
|
||||
adapters: [ARUSNAVI_B2_ADAPTER],
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEVICE_ADAPTER_CATALOG } from "../src/index.mjs";
|
||||
|
||||
test("allowlists ARUSNAVI B2 as the first adapter without making Gateway vendor-specific", () => {
|
||||
assert.deepEqual(DEVICE_ADAPTER_CATALOG.registry.adapterRefs, ["arusnavi-b2"]);
|
||||
assert.deepEqual(
|
||||
DEVICE_ADAPTER_CATALOG.registry.profileRefs,
|
||||
["arusnavi.b2.internal.v1"],
|
||||
);
|
||||
const registration = DEVICE_ADAPTER_CATALOG.registry.resolveProfile(
|
||||
DEVICE_ADAPTER_CATALOG.defaultProfileRef,
|
||||
);
|
||||
assert.equal(registration.adapter.runtimePackageRef, "@nodedc/arusnavi-b2-adapter");
|
||||
assert.equal(registration.profile.model, "B2");
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nodedc/device-adapter-runtime",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
export const DEVICE_ADAPTER_CONTRACT_VERSION =
|
||||
"nodedc.device-adapter.v1";
|
||||
|
||||
const ADAPTER_REF_RE = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const PROFILE_REF_RE = /^[a-z][a-z0-9._-]{2,127}$/;
|
||||
const RUNTIME_PACKAGE_REF_RE = /^@[a-z0-9-]+\/[a-z0-9-]+$/;
|
||||
|
||||
export function defineDeviceAdapter(input) {
|
||||
assertPlainObject(input, "device_adapter");
|
||||
if (input.contractVersion !== DEVICE_ADAPTER_CONTRACT_VERSION) {
|
||||
throw new TypeError("device_adapter_contract_version_invalid");
|
||||
}
|
||||
const adapterRef = normalizeRef(
|
||||
input.adapterRef,
|
||||
ADAPTER_REF_RE,
|
||||
"device_adapter_ref_invalid",
|
||||
);
|
||||
const runtimePackageRef = normalizeRef(
|
||||
input.runtimePackageRef,
|
||||
RUNTIME_PACKAGE_REF_RE,
|
||||
"device_adapter_runtime_package_ref_invalid",
|
||||
);
|
||||
if (!Array.isArray(input.profiles) || input.profiles.length === 0) {
|
||||
throw new TypeError("device_adapter_profiles_required");
|
||||
}
|
||||
const profiles = input.profiles.map((profile) =>
|
||||
normalizeProfile(profile, adapterRef)
|
||||
);
|
||||
if (new Set(profiles.map((profile) => profile.profileRef)).size !== profiles.length) {
|
||||
throw new TypeError("device_adapter_profile_duplicate");
|
||||
}
|
||||
if (typeof input.createSession !== "function") {
|
||||
throw new TypeError("device_adapter_session_factory_required");
|
||||
}
|
||||
|
||||
return deepFreeze({
|
||||
contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION,
|
||||
adapterRef,
|
||||
runtimePackageRef,
|
||||
profiles,
|
||||
createSession: input.createSession,
|
||||
});
|
||||
}
|
||||
|
||||
export function createDeviceAdapterRegistry({ adapters = [] } = {}) {
|
||||
if (!Array.isArray(adapters)) {
|
||||
throw new TypeError("device_adapter_registry_adapters_invalid");
|
||||
}
|
||||
const byAdapterRef = new Map();
|
||||
const byProfileRef = new Map();
|
||||
for (const adapter of adapters) {
|
||||
const normalized = defineDeviceAdapter(adapter);
|
||||
if (byAdapterRef.has(normalized.adapterRef)) {
|
||||
throw new TypeError("device_adapter_registry_adapter_duplicate");
|
||||
}
|
||||
byAdapterRef.set(normalized.adapterRef, normalized);
|
||||
for (const profile of normalized.profiles) {
|
||||
if (byProfileRef.has(profile.profileRef)) {
|
||||
throw new TypeError("device_adapter_registry_profile_duplicate");
|
||||
}
|
||||
byProfileRef.set(profile.profileRef, Object.freeze({
|
||||
adapter: normalized,
|
||||
profile,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
adapterRefs: Object.freeze([...byAdapterRef.keys()].sort()),
|
||||
profileRefs: Object.freeze([...byProfileRef.keys()].sort()),
|
||||
getAdapter(adapterRef) {
|
||||
const normalized = normalizeRef(
|
||||
adapterRef,
|
||||
ADAPTER_REF_RE,
|
||||
"device_adapter_ref_invalid",
|
||||
);
|
||||
const adapter = byAdapterRef.get(normalized);
|
||||
if (!adapter) throw new TypeError("device_adapter_not_allowlisted");
|
||||
return adapter;
|
||||
},
|
||||
resolveProfile(profileRef) {
|
||||
const normalized = normalizeRef(
|
||||
profileRef,
|
||||
PROFILE_REF_RE,
|
||||
"device_adapter_profile_ref_invalid",
|
||||
);
|
||||
const registration = byProfileRef.get(normalized);
|
||||
if (!registration) {
|
||||
throw new TypeError("device_adapter_profile_not_allowlisted");
|
||||
}
|
||||
return registration;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function assertDeviceAdapterSession(session) {
|
||||
assertPlainObject(session, "device_adapter_session");
|
||||
for (const method of [
|
||||
"parseHeader",
|
||||
"buildHeaderAcknowledgement",
|
||||
"parseMessage",
|
||||
"buildMessageAcknowledgement",
|
||||
]) {
|
||||
if (typeof session[method] !== "function") {
|
||||
throw new TypeError(`device_adapter_session_method_missing:${method}`);
|
||||
}
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
function normalizeProfile(profile, adapterRef) {
|
||||
assertPlainObject(profile, "device_adapter_profile");
|
||||
const profileRef = normalizeRef(
|
||||
profile.profileRef,
|
||||
PROFILE_REF_RE,
|
||||
"device_adapter_profile_ref_invalid",
|
||||
);
|
||||
if (profile.adapterRef != null && profile.adapterRef !== adapterRef) {
|
||||
throw new TypeError("device_adapter_profile_adapter_mismatch");
|
||||
}
|
||||
const maxBufferedBytes = Number(profile?.framing?.maxBufferedBytes);
|
||||
if (
|
||||
!Number.isSafeInteger(maxBufferedBytes)
|
||||
|| maxBufferedBytes < 1024
|
||||
|| maxBufferedBytes > 256 * 1024
|
||||
) {
|
||||
throw new TypeError("device_adapter_profile_buffer_limit_invalid");
|
||||
}
|
||||
return deepFreeze({ ...profile, profileRef, adapterRef });
|
||||
}
|
||||
|
||||
function normalizeRef(value, pattern, errorCode) {
|
||||
if (typeof value !== "string" || !pattern.test(value)) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_ADAPTER_CONTRACT_VERSION,
|
||||
createDeviceAdapterRegistry,
|
||||
defineDeviceAdapter,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
function adapter(adapterRef = "generic-tracker", profileRef = "generic.tracker.v1") {
|
||||
return {
|
||||
contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION,
|
||||
adapterRef,
|
||||
runtimePackageRef: `@nodedc/${adapterRef}-adapter`,
|
||||
profiles: [{
|
||||
profileRef,
|
||||
framing: { maxBufferedBytes: 64 * 1024 },
|
||||
}],
|
||||
createSession: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
test("resolves only explicitly allowlisted adapter profiles", () => {
|
||||
const registry = createDeviceAdapterRegistry({
|
||||
adapters: [adapter()],
|
||||
});
|
||||
assert.deepEqual(registry.adapterRefs, ["generic-tracker"]);
|
||||
assert.deepEqual(registry.profileRefs, ["generic.tracker.v1"]);
|
||||
assert.equal(
|
||||
registry.resolveProfile("generic.tracker.v1").adapter.adapterRef,
|
||||
"generic-tracker",
|
||||
);
|
||||
assert.throws(
|
||||
() => registry.resolveProfile("unknown.tracker.v1"),
|
||||
/device_adapter_profile_not_allowlisted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects duplicate adapters and cross-adapter profile collisions", () => {
|
||||
assert.throws(
|
||||
() => createDeviceAdapterRegistry({ adapters: [adapter(), adapter()] }),
|
||||
/device_adapter_registry_adapter_duplicate/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createDeviceAdapterRegistry({
|
||||
adapters: [
|
||||
adapter("generic-tracker", "shared.profile.v1"),
|
||||
adapter("other-tracker", "shared.profile.v1"),
|
||||
],
|
||||
}),
|
||||
/device_adapter_registry_profile_duplicate/,
|
||||
);
|
||||
});
|
||||
|
||||
test("freezes adapter metadata but keeps the session factory callable", () => {
|
||||
const defined = defineDeviceAdapter(adapter());
|
||||
assert.equal(Object.isFrozen(defined), true);
|
||||
assert.equal(Object.isFrozen(defined.profiles[0]), true);
|
||||
assert.equal(typeof defined.createSession, "function");
|
||||
});
|
||||
@@ -6,6 +6,12 @@ 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",
|
||||
@@ -24,7 +30,9 @@ export const DEVICE_BINDING_CAPABILITIES = Object.freeze([
|
||||
|
||||
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",
|
||||
@@ -75,6 +83,129 @@ export function normalizeDiscoverySignal(input) {
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
@@ -204,13 +335,24 @@ export function assertSafeProjection(value) {
|
||||
|
||||
export function normalizeRestrictedIdentifier(input) {
|
||||
assertPlainObject(input, "restricted_identifier");
|
||||
if (input.kind !== "imei") {
|
||||
throw new TypeError("restricted_identifier_kind_unsupported");
|
||||
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 (typeof input.value !== "string" || !IMEI_RE.test(input.value)) {
|
||||
if (input.kind === "imei" && !IMEI_RE.test(input.value)) {
|
||||
throw new TypeError("restricted_identifier_imei_invalid");
|
||||
}
|
||||
return Object.freeze({ kind: "imei", value: input.value });
|
||||
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) {
|
||||
@@ -218,7 +360,12 @@ export function maskRestrictedIdentifier(identifier) {
|
||||
if (normalized.kind === "imei") {
|
||||
return `***********${normalized.value.slice(-4)}`;
|
||||
}
|
||||
throw new TypeError("restricted_identifier_kind_unsupported");
|
||||
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) {
|
||||
@@ -265,6 +412,73 @@ function rejectForbiddenKeys(value, path = "$") {
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -306,3 +520,11 @@ function assertPlainObject(value, label) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,23 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_ADAPTER_ACCEPTANCE_SCHEMA,
|
||||
DEVICE_ADAPTER_MESSAGE_SCHEMA,
|
||||
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
DEVICE_PLANE_BINDING_SCHEMA,
|
||||
assertIdentifierDigest,
|
||||
assertSafeProjection,
|
||||
hashRestrictedIdentifier,
|
||||
maskRestrictedIdentifier,
|
||||
normalizeDevicePlaneBinding,
|
||||
normalizeAdapterAcceptance,
|
||||
normalizeAdapterMessage,
|
||||
normalizeDiscoverySignal,
|
||||
normalizeRestrictedIdentifier,
|
||||
normalizeRestrictedIdentifierProjection,
|
||||
normalizeRestrictedIdentifierRecord,
|
||||
toSafeDiscoveryView,
|
||||
toSafeAdapterMessageView,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
const fakeImei = "000000000000001";
|
||||
@@ -110,6 +117,25 @@ test("restricted identifier records keep digest internal and expose only a mask"
|
||||
);
|
||||
});
|
||||
|
||||
test("restricted identifiers support future adapter-defined hardware ids", () => {
|
||||
const identifier = normalizeRestrictedIdentifier({
|
||||
kind: "serial",
|
||||
value: "SN-TRACKER-0001",
|
||||
});
|
||||
assert.deepEqual(identifier, {
|
||||
kind: "serial",
|
||||
value: "SN-TRACKER-0001",
|
||||
});
|
||||
assert.equal(maskRestrictedIdentifier(identifier), "***********0001");
|
||||
assert.match(
|
||||
hashRestrictedIdentifier(
|
||||
identifier,
|
||||
"test-only-pepper-with-at-least-32-bytes",
|
||||
),
|
||||
/^hmac-sha256:[a-f0-9]{64}$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unverified framing and command-shaped discovery input", () => {
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({
|
||||
@@ -147,3 +173,93 @@ test("normalizes an opaque Foundry control binding without device data", () => {
|
||||
assert.deepEqual(binding.capabilities, ["inspect", "observe"]);
|
||||
assertSafeProjection(binding);
|
||||
});
|
||||
|
||||
test("normalizes a bounded typed adapter message and masks its identity", () => {
|
||||
const message = {
|
||||
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
|
||||
edgeRef: "edge:robot2b-vps-001",
|
||||
adapterRef: "arusnavi-b2",
|
||||
protocolProfileRef: "arusnavi.b2.internal.v1",
|
||||
protocol: "INTERNAL",
|
||||
sessionRef: "session:test-001",
|
||||
routeRef: "route:11111111-1111-4111-8111-111111111111",
|
||||
messageRef: "package:1:abc123",
|
||||
messageType: "telemetry.package",
|
||||
sequence: 1,
|
||||
observedAt: "2026-08-11T12:00:00.000Z",
|
||||
idempotencyKey: `sha256:${"a".repeat(64)}`,
|
||||
identifier: { kind: "imei", value: fakeImei },
|
||||
payloadSchemaRef: "arusnavi.internal.package-metadata.v1",
|
||||
payload: {
|
||||
packageNumber: 1,
|
||||
packetCount: 1,
|
||||
packageDigest: `sha256:${"b".repeat(64)}`,
|
||||
},
|
||||
};
|
||||
const normalized = normalizeAdapterMessage(message);
|
||||
const safe = toSafeAdapterMessageView(normalized);
|
||||
assert.equal(normalized.identifier.value, fakeImei);
|
||||
assert.equal(safe.identifier.masked, "***********0001");
|
||||
assert.equal(JSON.stringify(safe).includes(fakeImei), false);
|
||||
assertSafeProjection(safe);
|
||||
});
|
||||
|
||||
test("rejects oversized, untyped and secret-shaped adapter messages", () => {
|
||||
const base = {
|
||||
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
|
||||
edgeRef: "edge:test",
|
||||
adapterRef: "generic-tracker",
|
||||
protocolProfileRef: "generic.tracker.v1",
|
||||
protocol: "GENERIC",
|
||||
sessionRef: "session:test",
|
||||
messageRef: "message:1",
|
||||
messageType: "telemetry.sample",
|
||||
sequence: 1,
|
||||
observedAt: "2026-08-11T12:00:00.000Z",
|
||||
idempotencyKey: `sha256:${"a".repeat(64)}`,
|
||||
identifier: { kind: "imei", value: fakeImei },
|
||||
payloadSchemaRef: "generic.telemetry.v1",
|
||||
payload: { value: 1 },
|
||||
};
|
||||
assert.throws(
|
||||
() => normalizeAdapterMessage({ ...base, payload: "raw" }),
|
||||
/device_adapter_message_payload_invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeAdapterMessage({
|
||||
...base,
|
||||
payload: { devicePassword: "forbidden" },
|
||||
}),
|
||||
/forbidden_device_field/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeAdapterMessage({
|
||||
...base,
|
||||
payload: { value: "x".repeat(4096) },
|
||||
}, { maxBytes: 1024 }),
|
||||
/device_adapter_message_too_large/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeAdapterMessage({
|
||||
...base,
|
||||
idempotencyKey: "message-not-a-digest",
|
||||
}),
|
||||
/idempotency_key_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts only an explicit durable Core acceptance contract", () => {
|
||||
const acceptance = normalizeAdapterAcceptance({
|
||||
schemaVersion: DEVICE_ADAPTER_ACCEPTANCE_SCHEMA,
|
||||
acceptanceRef: "acceptance:test-001",
|
||||
idempotencyKey: `sha256:${"a".repeat(64)}`,
|
||||
status: "accepted",
|
||||
replayed: false,
|
||||
acceptedAt: "2026-08-11T12:00:00.000Z",
|
||||
});
|
||||
assert.equal(acceptance.status, "accepted");
|
||||
assert.throws(
|
||||
() => normalizeAdapterAcceptance({ ...acceptance, status: "queued" }),
|
||||
/device_adapter_acceptance_status_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user