feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nodedc/arusnavi-b2-adapter",
|
||||
"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,331 @@
|
||||
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";
|
||||
|
||||
const HEADER2_LENGTH = 10;
|
||||
const HEADER_START = 0xff;
|
||||
const HEADER2_GPRS_VERSION = 0x23;
|
||||
const PACKAGE_START = 0x5b;
|
||||
const PACKAGE_END = 0x5d;
|
||||
const SERVER_COMMAND_START = 0x7b;
|
||||
const SERVER_COMMAND_END = 0x7d;
|
||||
const MIN_PACKAGE_NUMBER = 0x01;
|
||||
const MAX_PACKAGE_NUMBER = 0xfb;
|
||||
const PACKET_FIXED_LENGTH = 8;
|
||||
const MAX_PACKET_DATA_LENGTH = 32 * 1024;
|
||||
const MAX_PACKAGE_LENGTH = 64 * 1024;
|
||||
|
||||
export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
|
||||
schemaVersion: "nodedc.device-model-profile.v1",
|
||||
profileRef: "arusnavi.b2.internal.v1",
|
||||
vendor: "ARUSNAVI",
|
||||
model: "B2",
|
||||
deviceType: "tracker",
|
||||
protocol: "INTERNAL",
|
||||
monitoringServerSlots: 4,
|
||||
serverIdentity: {
|
||||
kind: "imei",
|
||||
source: "modem",
|
||||
trust: "claimed-not-ownership-proof",
|
||||
},
|
||||
bootstrap: {
|
||||
operatorSurface: "ARUSNAVI_WEB_OR_LOCAL_CONFIGURATOR",
|
||||
platformCredentialRequired: false,
|
||||
preserveExistingRoutes: true,
|
||||
},
|
||||
framing: {
|
||||
status: "verified-read-only",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
headerVersion: "HEADER2_GPRS_0x23",
|
||||
headerBytes: HEADER2_LENGTH,
|
||||
maxBufferedBytes: MAX_PACKAGE_LENGTH,
|
||||
},
|
||||
acknowledgement: {
|
||||
header: "server-time-only",
|
||||
package: "package-number-only",
|
||||
},
|
||||
commandTransport: {
|
||||
status: "typed-service-ping-v1",
|
||||
exportedCommandBuilders: 1,
|
||||
},
|
||||
routeCompatibility: {
|
||||
gelios: "parallel-preserved",
|
||||
automaticCommandFailover: false,
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
buildTypedCommand: buildB2TypedCommand,
|
||||
parseTypedCommandResponse: tryParseB2TypedCommandResponse,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function tryParseB2Header2(input) {
|
||||
assertBuffer(input, "b2_header_buffer_required");
|
||||
if (input.length < HEADER2_LENGTH) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: HEADER2_LENGTH,
|
||||
});
|
||||
}
|
||||
if (input[0] !== HEADER_START) {
|
||||
throw new TypeError("b2_header_start_invalid");
|
||||
}
|
||||
if (input[1] !== HEADER2_GPRS_VERSION) {
|
||||
throw new TypeError("b2_header_version_unsupported");
|
||||
}
|
||||
|
||||
const identifier = input.readBigUInt64LE(2).toString(10);
|
||||
if (!/^\d{15}$/.test(identifier)) {
|
||||
throw new TypeError("b2_header_imei_invalid");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
status: "complete",
|
||||
bytesConsumed: HEADER2_LENGTH,
|
||||
identifier: Object.freeze({
|
||||
kind: "imei",
|
||||
value: identifier,
|
||||
trust: "claimed-not-ownership-proof",
|
||||
}),
|
||||
evidence: Object.freeze({
|
||||
transport: "tcp",
|
||||
bytesObserved: HEADER2_LENGTH,
|
||||
framingStatus: "verified",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildB2HeaderAcknowledgement(unixSeconds) {
|
||||
const timestamp = normalizeUInt32(
|
||||
unixSeconds,
|
||||
"b2_header_ack_timestamp_invalid",
|
||||
);
|
||||
const commandData = Buffer.allocUnsafe(4);
|
||||
commandData.writeUInt32LE(timestamp);
|
||||
return Buffer.from([
|
||||
SERVER_COMMAND_START,
|
||||
commandData.length,
|
||||
0x00,
|
||||
checksum(commandData),
|
||||
...commandData,
|
||||
SERVER_COMMAND_END,
|
||||
]);
|
||||
}
|
||||
|
||||
export function tryParseB2Package(input) {
|
||||
assertBuffer(input, "b2_package_buffer_required");
|
||||
if (input.length === 0) {
|
||||
return Object.freeze({ status: "incomplete", minimumBytes: 1 });
|
||||
}
|
||||
if (input[0] !== PACKAGE_START) {
|
||||
throw new TypeError("b2_package_start_invalid");
|
||||
}
|
||||
if (input.length < 3) {
|
||||
return Object.freeze({ status: "incomplete", minimumBytes: 3 });
|
||||
}
|
||||
|
||||
const packageNumber = input[1];
|
||||
if (
|
||||
packageNumber < MIN_PACKAGE_NUMBER
|
||||
|| packageNumber > MAX_PACKAGE_NUMBER
|
||||
) {
|
||||
throw new TypeError("b2_package_number_invalid");
|
||||
}
|
||||
|
||||
let offset = 2;
|
||||
let packetCount = 0;
|
||||
while (true) {
|
||||
if (offset >= MAX_PACKAGE_LENGTH) {
|
||||
throw new TypeError("b2_package_length_exceeded");
|
||||
}
|
||||
if (offset >= input.length) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: offset + 1,
|
||||
});
|
||||
}
|
||||
if (input[offset] === PACKAGE_END) {
|
||||
if (packetCount === 0) {
|
||||
throw new TypeError("b2_package_empty");
|
||||
}
|
||||
return Object.freeze({
|
||||
status: "complete",
|
||||
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) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: offset + 3,
|
||||
});
|
||||
}
|
||||
|
||||
const dataLength = input.readUInt16LE(offset + 1);
|
||||
if (dataLength > MAX_PACKET_DATA_LENGTH) {
|
||||
throw new TypeError("b2_packet_data_length_exceeded");
|
||||
}
|
||||
const packetLength = PACKET_FIXED_LENGTH + dataLength;
|
||||
const packetEnd = offset + packetLength;
|
||||
if (packetEnd + 1 > MAX_PACKAGE_LENGTH) {
|
||||
throw new TypeError("b2_package_length_exceeded");
|
||||
}
|
||||
if (input.length < packetEnd) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: packetEnd,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedChecksum = checksum(
|
||||
input.subarray(offset + 3, packetEnd - 1),
|
||||
);
|
||||
if (input[packetEnd - 1] !== expectedChecksum) {
|
||||
throw new TypeError("b2_packet_checksum_invalid");
|
||||
}
|
||||
packetCount += 1;
|
||||
offset = packetEnd;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildB2PackageAcknowledgement(packageNumber) {
|
||||
const normalized = Number(packageNumber);
|
||||
if (
|
||||
!Number.isSafeInteger(normalized)
|
||||
|| normalized < MIN_PACKAGE_NUMBER
|
||||
|| normalized > MAX_PACKAGE_NUMBER
|
||||
) {
|
||||
throw new TypeError("b2_package_ack_number_invalid");
|
||||
}
|
||||
return Buffer.from([
|
||||
SERVER_COMMAND_START,
|
||||
0x00,
|
||||
normalized,
|
||||
SERVER_COMMAND_END,
|
||||
]);
|
||||
}
|
||||
|
||||
export function buildB2TypedCommand({ commandType, accessCode } = {}) {
|
||||
if (commandType !== "service.ping") {
|
||||
throw new TypeError("b2_typed_command_unsupported");
|
||||
}
|
||||
if (typeof accessCode !== "string" || !/^\d{6}$/.test(accessCode)) {
|
||||
throw new TypeError("b2_command_access_code_invalid");
|
||||
}
|
||||
return Buffer.from(`${accessCode}*SERV*1.1`, "ascii");
|
||||
}
|
||||
|
||||
export function tryParseB2TypedCommandResponse(input, { commandType } = {}) {
|
||||
assertBuffer(input, "b2_command_response_buffer_required");
|
||||
if (commandType !== "service.ping") {
|
||||
throw new TypeError("b2_typed_command_unsupported");
|
||||
}
|
||||
const expected = Buffer.from("SERV OK", "ascii");
|
||||
const compared = Math.min(input.length, expected.length);
|
||||
if (!input.subarray(0, compared).equals(expected.subarray(0, compared))) {
|
||||
return Object.freeze({ status: "not-command" });
|
||||
}
|
||||
if (input.length < expected.length) {
|
||||
return Object.freeze({ status: "incomplete", minimumBytes: expected.length });
|
||||
}
|
||||
return Object.freeze({
|
||||
status: "acknowledged",
|
||||
bytesConsumed: expected.length,
|
||||
resultCode: "serv_ok",
|
||||
});
|
||||
}
|
||||
|
||||
export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
|
||||
if (profile.monitoringServerSlots !== 4) {
|
||||
throw new TypeError("b2_server_slot_count_invalid");
|
||||
}
|
||||
if (profile.protocol !== "INTERNAL") {
|
||||
throw new TypeError("b2_protocol_invalid");
|
||||
}
|
||||
if (profile.serverIdentity.kind !== "imei") {
|
||||
throw new TypeError("b2_identity_kind_invalid");
|
||||
}
|
||||
if (profile.framing.status !== "verified-read-only") {
|
||||
throw new TypeError("b2_framing_must_be_verified");
|
||||
}
|
||||
if (
|
||||
profile.framing.specificationRef
|
||||
!== ARUSNAVI_INTERNAL_SPECIFICATION_REF
|
||||
) {
|
||||
throw new TypeError("b2_framing_specification_invalid");
|
||||
}
|
||||
if (profile.commandTransport.status !== "typed-service-ping-v1") {
|
||||
throw new TypeError("b2_command_transport_profile_invalid");
|
||||
}
|
||||
if (profile.routeCompatibility.gelios !== "parallel-preserved") {
|
||||
throw new TypeError("b2_gelios_route_must_be_preserved");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function checksum(input) {
|
||||
let value = 0;
|
||||
for (const byte of input) value = (value + byte) & 0xff;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUInt32(value, errorCode) {
|
||||
const normalized = Number(value);
|
||||
if (
|
||||
!Number.isSafeInteger(normalized)
|
||||
|| normalized < 0
|
||||
|| normalized > 0xffffffff
|
||||
) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertBuffer(input, errorCode) {
|
||||
if (!Buffer.isBuffer(input)) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
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,164 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ARUSNAVI_B2_MODEL_PROFILE,
|
||||
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
assertB2ProfileInvariant,
|
||||
buildB2HeaderAcknowledgement,
|
||||
buildB2PackageAcknowledgement,
|
||||
buildB2TypedCommand,
|
||||
tryParseB2Header2,
|
||||
tryParseB2Package,
|
||||
tryParseB2TypedCommandResponse,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
const specificationHeader = Buffer.from(
|
||||
"FF23E9EF782DE7120300",
|
||||
"hex",
|
||||
);
|
||||
const specificationPackage = Buffer.from(
|
||||
"5B01010000FBDEC251EC5D",
|
||||
"hex",
|
||||
);
|
||||
|
||||
test("records the official B2 route, framing and identity evidence", () => {
|
||||
assert.equal(assertB2ProfileInvariant(), true);
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.monitoringServerSlots, 4);
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.protocol, "INTERNAL");
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.serverIdentity.kind, "imei");
|
||||
assert.equal(
|
||||
ARUSNAVI_B2_MODEL_PROFILE.framing.specificationRef,
|
||||
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
);
|
||||
assert.equal(
|
||||
ARUSNAVI_B2_MODEL_PROFILE.routeCompatibility.gelios,
|
||||
"parallel-preserved",
|
||||
);
|
||||
});
|
||||
|
||||
test("parses the official HEADER2 example as a claimed IMEI", () => {
|
||||
assert.equal(
|
||||
tryParseB2Header2(specificationHeader.subarray(0, 9)).status,
|
||||
"incomplete",
|
||||
);
|
||||
const parsed = tryParseB2Header2(specificationHeader);
|
||||
assert.equal(parsed.status, "complete");
|
||||
assert.equal(parsed.bytesConsumed, 10);
|
||||
assert.equal(parsed.identifier.kind, "imei");
|
||||
assert.equal(parsed.identifier.value, "865209039777769");
|
||||
assert.equal(parsed.identifier.trust, "claimed-not-ownership-proof");
|
||||
assert.deepEqual(parsed.evidence, {
|
||||
transport: "tcp",
|
||||
bytesObserved: 10,
|
||||
framingStatus: "verified",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
});
|
||||
});
|
||||
|
||||
test("builds the official HEADER2 acknowledgement example", () => {
|
||||
assert.equal(
|
||||
buildB2HeaderAcknowledgement(0x52db95de).toString("hex").toUpperCase(),
|
||||
"7B0400A0DE95DB527D",
|
||||
);
|
||||
});
|
||||
|
||||
test("parses and acknowledges the official package example", () => {
|
||||
assert.equal(
|
||||
tryParseB2Package(specificationPackage.subarray(0, -1)).status,
|
||||
"incomplete",
|
||||
);
|
||||
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(),
|
||||
"7B00017D",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses packet lengths and checksum instead of scanning for 0x5D", () => {
|
||||
const packetData = Buffer.from([0x5d]);
|
||||
const unixTime = Buffer.from([0x01, 0x00, 0x00, 0x00]);
|
||||
const checksum = (0x01 + 0x5d) & 0xff;
|
||||
const packageBytes = Buffer.from([
|
||||
0x5b,
|
||||
0x02,
|
||||
0x01,
|
||||
packetData.length,
|
||||
0x00,
|
||||
...unixTime,
|
||||
...packetData,
|
||||
checksum,
|
||||
0x5d,
|
||||
]);
|
||||
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", () => {
|
||||
assert.throws(
|
||||
() => tryParseB2Header2(Buffer.from("FE23E9EF782DE7120300", "hex")),
|
||||
/b2_header_start_invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => tryParseB2Header2(Buffer.from("FF24E9EF782DE7120300", "hex")),
|
||||
/b2_header_version_unsupported/,
|
||||
);
|
||||
const badChecksum = Buffer.from(specificationPackage);
|
||||
badChecksum[badChecksum.length - 2] ^= 0xff;
|
||||
assert.throws(
|
||||
() => tryParseB2Package(badChecksum),
|
||||
/b2_packet_checksum_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("exports only the typed service-ping command", () => {
|
||||
assert.equal(
|
||||
ARUSNAVI_B2_MODEL_PROFILE.commandTransport.status,
|
||||
"typed-service-ping-v1",
|
||||
);
|
||||
assert.equal(
|
||||
ARUSNAVI_B2_MODEL_PROFILE.commandTransport.exportedCommandBuilders,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
buildB2TypedCommand({ commandType: "service.ping", accessCode: "123456" })
|
||||
.toString("ascii"),
|
||||
"123456*SERV*1.1",
|
||||
);
|
||||
assert.deepEqual(
|
||||
tryParseB2TypedCommandResponse(Buffer.from("SERV OK", "ascii"), {
|
||||
commandType: "service.ping",
|
||||
}),
|
||||
{ status: "acknowledged", bytesConsumed: 7, resultCode: "serv_ok" },
|
||||
);
|
||||
assert.throws(
|
||||
() => buildB2TypedCommand({ commandType: "service.ping", accessCode: "12345" }),
|
||||
/b2_command_access_code_invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => buildB2TypedCommand({ commandType: "firmware.update", accessCode: "123456" }),
|
||||
/b2_typed_command_unsupported/,
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@nodedc/device-edge-channel-contract",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
export const DEVICE_EDGE_CHANNEL_SCHEMA =
|
||||
"nodedc.device-edge.channel-envelope.v1";
|
||||
|
||||
export const DEVICE_EDGE_CHANNEL_PATH =
|
||||
"/internal/v1/device-edge/channel";
|
||||
|
||||
export const DEVICE_EDGE_CHANNEL_LIMITS = Object.freeze({
|
||||
maxEnvelopeBytes: 1024 * 1024,
|
||||
maxPendingAcceptances: 128,
|
||||
keepaliveMs: 15_000,
|
||||
deadPeerMs: 45_000,
|
||||
reconnectMinimumMs: 1_000,
|
||||
reconnectMaximumMs: 30_000,
|
||||
});
|
||||
|
||||
export const EDGE_TO_CORE_MESSAGE_KINDS = Object.freeze([
|
||||
"channel.hello",
|
||||
"channel.heartbeat",
|
||||
"tracker.session-opened",
|
||||
"tracker.session-closed",
|
||||
"discovery.observed",
|
||||
"adapter.message",
|
||||
"delivery.acknowledged",
|
||||
"command.status",
|
||||
"channel.counters",
|
||||
]);
|
||||
|
||||
export const CORE_TO_EDGE_MESSAGE_KINDS = Object.freeze([
|
||||
"channel.accepted",
|
||||
"channel.heartbeat",
|
||||
"flow.window",
|
||||
"session.disposition",
|
||||
"event.accepted",
|
||||
"event.rejected",
|
||||
]);
|
||||
|
||||
const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
|
||||
const forbiddenKeyFragments = Object.freeze([
|
||||
"password",
|
||||
"secret",
|
||||
"credential",
|
||||
"authorization",
|
||||
"privatekey",
|
||||
"bearertoken",
|
||||
]);
|
||||
|
||||
export function normalizeChannelEnvelope(input, options = {}) {
|
||||
assertPlainObject(input, "device_edge_channel_envelope");
|
||||
const direction = normalizeDirection(options.direction);
|
||||
const maxEnvelopeBytes = normalizeLimit(
|
||||
options.maxEnvelopeBytes,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
);
|
||||
const allowedKeys = new Set([
|
||||
"schemaVersion",
|
||||
"edgeRegistrationId",
|
||||
"channelGeneration",
|
||||
"trackerSessionId",
|
||||
"adapterProfileRef",
|
||||
"sequence",
|
||||
"eventAt",
|
||||
"receivedAt",
|
||||
"payloadBytes",
|
||||
"messageKind",
|
||||
"correlationId",
|
||||
"payload",
|
||||
]);
|
||||
rejectUnexpectedKeys(input, allowedKeys);
|
||||
rejectForbiddenKeys(input);
|
||||
|
||||
if (input.schemaVersion !== DEVICE_EDGE_CHANNEL_SCHEMA) {
|
||||
throw new TypeError("device_edge_channel_schema_invalid");
|
||||
}
|
||||
const allowedKinds = direction === "edge-to-core"
|
||||
? EDGE_TO_CORE_MESSAGE_KINDS
|
||||
: CORE_TO_EDGE_MESSAGE_KINDS;
|
||||
if (!allowedKinds.includes(input.messageKind)) {
|
||||
throw new TypeError("device_edge_channel_message_kind_invalid");
|
||||
}
|
||||
const sequence = Number(input.sequence);
|
||||
if (!Number.isSafeInteger(sequence) || sequence < 1) {
|
||||
throw new TypeError("device_edge_channel_sequence_invalid");
|
||||
}
|
||||
assertJsonValue(input.payload, 0);
|
||||
const payload = cloneJsonValue(input.payload);
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
|
||||
if (payloadBytes > maxEnvelopeBytes) {
|
||||
throw new TypeError("device_edge_channel_payload_too_large");
|
||||
}
|
||||
if (Number(input.payloadBytes) !== payloadBytes) {
|
||||
throw new TypeError("device_edge_channel_payload_length_mismatch");
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
schemaVersion: DEVICE_EDGE_CHANNEL_SCHEMA,
|
||||
edgeRegistrationId: normalizeRef(
|
||||
input.edgeRegistrationId,
|
||||
"edge_registration_id",
|
||||
),
|
||||
channelGeneration: normalizeRef(
|
||||
input.channelGeneration,
|
||||
"channel_generation",
|
||||
),
|
||||
trackerSessionId: normalizeRef(
|
||||
input.trackerSessionId,
|
||||
"tracker_session_id",
|
||||
),
|
||||
adapterProfileRef: normalizeRef(
|
||||
input.adapterProfileRef,
|
||||
"adapter_profile_ref",
|
||||
),
|
||||
sequence,
|
||||
eventAt: normalizeTimestamp(input.eventAt, "event_at"),
|
||||
receivedAt: normalizeTimestamp(input.receivedAt, "received_at"),
|
||||
payloadBytes,
|
||||
messageKind: input.messageKind,
|
||||
correlationId: normalizeRef(input.correlationId, "correlation_id"),
|
||||
payload,
|
||||
};
|
||||
return deepFreeze(normalized);
|
||||
}
|
||||
|
||||
export function createChannelEnvelope(fields, options = {}) {
|
||||
assertPlainObject(fields, "device_edge_channel_fields");
|
||||
const payload = fields.payload ?? {};
|
||||
assertJsonValue(payload, 0);
|
||||
const clonedPayload = cloneJsonValue(payload);
|
||||
return normalizeChannelEnvelope({
|
||||
...fields,
|
||||
schemaVersion: DEVICE_EDGE_CHANNEL_SCHEMA,
|
||||
payloadBytes: Buffer.byteLength(JSON.stringify(clonedPayload), "utf8"),
|
||||
payload: clonedPayload,
|
||||
}, options);
|
||||
}
|
||||
|
||||
export function encodeChannelEnvelope(envelope, options = {}) {
|
||||
const normalized = normalizeChannelEnvelope(envelope, options);
|
||||
const encoded = Buffer.from(`${JSON.stringify(normalized)}\n`, "utf8");
|
||||
const maxEnvelopeBytes = normalizeLimit(
|
||||
options.maxEnvelopeBytes,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
);
|
||||
if (encoded.length > maxEnvelopeBytes) {
|
||||
throw new TypeError("device_edge_channel_envelope_too_large");
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function createChannelEnvelopeDecoder(options = {}) {
|
||||
const direction = normalizeDirection(options.direction);
|
||||
const maxEnvelopeBytes = normalizeLimit(
|
||||
options.maxEnvelopeBytes,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
);
|
||||
let buffered = Buffer.alloc(0);
|
||||
|
||||
return Object.freeze({
|
||||
push(chunk) {
|
||||
if (!Buffer.isBuffer(chunk) && !(chunk instanceof Uint8Array)) {
|
||||
throw new TypeError("device_edge_channel_chunk_invalid");
|
||||
}
|
||||
let incoming = Buffer.from(chunk);
|
||||
const envelopes = [];
|
||||
while (incoming.length > 0) {
|
||||
const newlineIndex = incoming.indexOf(0x0a);
|
||||
if (newlineIndex < 0) {
|
||||
if (buffered.length + incoming.length > maxEnvelopeBytes) {
|
||||
throw new TypeError("device_edge_channel_envelope_too_large");
|
||||
}
|
||||
buffered = buffered.length === 0
|
||||
? incoming
|
||||
: Buffer.concat([buffered, incoming]);
|
||||
break;
|
||||
}
|
||||
if (buffered.length + newlineIndex === 0) {
|
||||
throw new TypeError("device_edge_channel_envelope_empty");
|
||||
}
|
||||
if (buffered.length + newlineIndex + 1 > maxEnvelopeBytes) {
|
||||
throw new TypeError("device_edge_channel_envelope_too_large");
|
||||
}
|
||||
const segment = incoming.subarray(0, newlineIndex);
|
||||
const line = buffered.length === 0
|
||||
? segment
|
||||
: Buffer.concat([buffered, segment]);
|
||||
buffered = Buffer.alloc(0);
|
||||
incoming = incoming.subarray(newlineIndex + 1);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(line.toString("utf8"));
|
||||
} catch {
|
||||
throw new TypeError("device_edge_channel_envelope_json_invalid");
|
||||
}
|
||||
envelopes.push(normalizeChannelEnvelope(parsed, {
|
||||
direction,
|
||||
maxEnvelopeBytes,
|
||||
}));
|
||||
}
|
||||
return envelopes;
|
||||
},
|
||||
finish() {
|
||||
if (buffered.length !== 0) {
|
||||
throw new TypeError("device_edge_channel_envelope_truncated");
|
||||
}
|
||||
},
|
||||
bufferedBytes() {
|
||||
return buffered.length;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCertificateFingerprint(value) {
|
||||
const compact = String(value || "").replaceAll(":", "").toUpperCase();
|
||||
if (!/^[A-F0-9]{64}$/.test(compact)) {
|
||||
throw new TypeError("device_edge_channel_certificate_fingerprint_invalid");
|
||||
}
|
||||
return compact.match(/.{2}/g).join(":");
|
||||
}
|
||||
|
||||
export function normalizeCertificateIdentities(value) {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 2) {
|
||||
throw new TypeError("device_edge_channel_certificate_identities_invalid");
|
||||
}
|
||||
const generations = new Set();
|
||||
const fingerprints = new Set();
|
||||
let activeCount = 0;
|
||||
const identities = value.map((identity) => {
|
||||
assertPlainObject(identity, "device_edge_channel_certificate_identity");
|
||||
rejectUnexpectedKeys(
|
||||
identity,
|
||||
new Set(["generationRef", "fingerprint", "status"]),
|
||||
);
|
||||
if (!["active", "staged"].includes(identity.status)) {
|
||||
throw new TypeError("device_edge_channel_certificate_identity_status_invalid");
|
||||
}
|
||||
if (identity.status === "active") activeCount += 1;
|
||||
const generationRef = normalizeRef(
|
||||
identity.generationRef,
|
||||
"certificate_generation_ref",
|
||||
);
|
||||
const fingerprint = normalizeCertificateFingerprint(identity.fingerprint);
|
||||
if (generations.has(generationRef) || fingerprints.has(fingerprint)) {
|
||||
throw new TypeError("device_edge_channel_certificate_identity_duplicate");
|
||||
}
|
||||
generations.add(generationRef);
|
||||
fingerprints.add(fingerprint);
|
||||
return Object.freeze({
|
||||
generationRef,
|
||||
fingerprint,
|
||||
status: identity.status,
|
||||
});
|
||||
});
|
||||
if (activeCount !== 1) {
|
||||
throw new TypeError("device_edge_channel_active_certificate_identity_invalid");
|
||||
}
|
||||
return Object.freeze(identities);
|
||||
}
|
||||
|
||||
export function nextReconnectDelay(attempt, options = {}) {
|
||||
const normalizedAttempt = Number(attempt);
|
||||
if (!Number.isSafeInteger(normalizedAttempt) || normalizedAttempt < 0) {
|
||||
throw new TypeError("device_edge_channel_reconnect_attempt_invalid");
|
||||
}
|
||||
const minimumMs = normalizeReconnectDuration(
|
||||
options.minimumMs,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs,
|
||||
);
|
||||
const maximumMs = normalizeReconnectDuration(
|
||||
options.maximumMs,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs,
|
||||
);
|
||||
if (maximumMs < minimumMs) {
|
||||
throw new TypeError("device_edge_channel_reconnect_range_invalid");
|
||||
}
|
||||
const random = options.random ?? Math.random;
|
||||
if (typeof random !== "function") {
|
||||
throw new TypeError("device_edge_channel_random_invalid");
|
||||
}
|
||||
const ceiling = Math.min(maximumMs, minimumMs * (2 ** normalizedAttempt));
|
||||
const floor = Math.max(minimumMs, Math.floor(ceiling / 2));
|
||||
const sample = Number(random());
|
||||
if (!Number.isFinite(sample) || sample < 0 || sample > 1) {
|
||||
throw new TypeError("device_edge_channel_random_invalid");
|
||||
}
|
||||
return Math.floor(floor + ((ceiling - floor) * sample));
|
||||
}
|
||||
|
||||
function normalizeReconnectDuration(value, fallback) {
|
||||
const number = value == null ? fallback : Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < 10 || number > 120_000) {
|
||||
throw new TypeError("device_edge_channel_reconnect_duration_invalid");
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function normalizeDirection(value) {
|
||||
if (!['edge-to-core', 'core-to-edge'].includes(value)) {
|
||||
throw new TypeError("device_edge_channel_direction_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeLimit(value, fallback) {
|
||||
const number = value == null ? fallback : Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < 256 || number > 4 * 1024 * 1024) {
|
||||
throw new TypeError("device_edge_channel_limit_invalid");
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function normalizeRef(value, field) {
|
||||
if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) {
|
||||
throw new TypeError(`device_edge_channel_${field}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value, field) {
|
||||
if (typeof value !== "string" || !ISO_TIMESTAMP_RE.test(value)) {
|
||||
throw new TypeError(`device_edge_channel_${field}_invalid`);
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
throw new TypeError(`device_edge_channel_${field}_invalid`);
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function assertPlainObject(value, name) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${name}_invalid`);
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError(`${name}_invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectUnexpectedKeys(value, allowedKeys) {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new TypeError(`device_edge_channel_field_unexpected:${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rejectForbiddenKeys(value, depth = 0) {
|
||||
if (depth > 16 || value == null || typeof value !== "object") return;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const compact = key.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
|
||||
if (forbiddenKeyFragments.some((fragment) => compact.includes(fragment))) {
|
||||
throw new TypeError(`device_edge_channel_forbidden_field:${key}`);
|
||||
}
|
||||
rejectForbiddenKeys(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function assertJsonValue(value, depth) {
|
||||
if (depth > 16) {
|
||||
throw new TypeError("device_edge_channel_payload_depth_invalid");
|
||||
}
|
||||
if (value == null || typeof value === "string" || typeof value === "boolean") {
|
||||
return;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) assertJsonValue(child, depth + 1);
|
||||
return;
|
||||
}
|
||||
assertPlainObject(value, "device_edge_channel_payload");
|
||||
for (const child of Object.values(value)) {
|
||||
assertJsonValue(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function cloneJsonValue(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createChannelEnvelope,
|
||||
createChannelEnvelopeDecoder,
|
||||
encodeChannelEnvelope,
|
||||
nextReconnectDelay,
|
||||
normalizeCertificateIdentities,
|
||||
normalizeChannelEnvelope,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
const now = "2026-08-11T12:00:00.000Z";
|
||||
|
||||
function envelope(overrides = {}) {
|
||||
return createChannelEnvelope({
|
||||
edgeRegistrationId: "edge:pilot-1",
|
||||
channelGeneration: "generation:pilot-1",
|
||||
trackerSessionId: "channel:control",
|
||||
adapterProfileRef: "channel.control.v1",
|
||||
sequence: 1,
|
||||
eventAt: now,
|
||||
receivedAt: now,
|
||||
messageKind: "channel.hello",
|
||||
correlationId: "correlation:hello-1",
|
||||
payload: { status: "ready" },
|
||||
...overrides,
|
||||
}, { direction: "edge-to-core" });
|
||||
}
|
||||
|
||||
test("round-trips a bounded versioned Edge envelope", () => {
|
||||
const decoder = createChannelEnvelopeDecoder({ direction: "edge-to-core" });
|
||||
const encoded = encodeChannelEnvelope(envelope(), {
|
||||
direction: "edge-to-core",
|
||||
});
|
||||
const split = Math.floor(encoded.length / 2);
|
||||
|
||||
assert.deepEqual(decoder.push(encoded.subarray(0, split)), []);
|
||||
assert.deepEqual(decoder.push(encoded.subarray(split)), [envelope()]);
|
||||
assert.equal(decoder.bufferedBytes(), 0);
|
||||
decoder.finish();
|
||||
});
|
||||
|
||||
test("fails closed on unknown kinds, payload mismatches and oversized frames", () => {
|
||||
const valid = envelope();
|
||||
assert.throws(() => normalizeChannelEnvelope({
|
||||
...valid,
|
||||
messageKind: "tcp.forward",
|
||||
}, { direction: "edge-to-core" }), /message_kind_invalid/);
|
||||
assert.throws(() => normalizeChannelEnvelope({
|
||||
...valid,
|
||||
payloadBytes: valid.payloadBytes + 1,
|
||||
}, { direction: "edge-to-core" }), /payload_length_mismatch/);
|
||||
|
||||
const decoder = createChannelEnvelopeDecoder({
|
||||
direction: "edge-to-core",
|
||||
maxEnvelopeBytes: 256,
|
||||
});
|
||||
assert.throws(() => decoder.push(Buffer.alloc(257, 0x61)), /envelope_too_large/);
|
||||
});
|
||||
|
||||
test("uses bounded jittered exponential reconnect delays", () => {
|
||||
assert.equal(nextReconnectDelay(0, {
|
||||
minimumMs: 1000,
|
||||
maximumMs: 30_000,
|
||||
random: () => 1,
|
||||
}), 1000);
|
||||
assert.equal(nextReconnectDelay(5, {
|
||||
minimumMs: 1000,
|
||||
maximumMs: 30_000,
|
||||
random: () => 1,
|
||||
}), 30_000);
|
||||
assert.equal(nextReconnectDelay(5, {
|
||||
minimumMs: 1000,
|
||||
maximumMs: 30_000,
|
||||
random: () => 0,
|
||||
}), 15_000);
|
||||
});
|
||||
|
||||
test("allows exactly one active and at most one staged certificate generation", () => {
|
||||
const activeFingerprint = "AA:".repeat(31) + "AA";
|
||||
const stagedFingerprint = "BB:".repeat(31) + "BB";
|
||||
const identities = normalizeCertificateIdentities([
|
||||
{
|
||||
generationRef: "trust-generation:1",
|
||||
fingerprint: activeFingerprint,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
generationRef: "trust-generation:2",
|
||||
fingerprint: stagedFingerprint,
|
||||
status: "staged",
|
||||
},
|
||||
]);
|
||||
assert.equal(identities.length, 2);
|
||||
assert.equal(identities[0].status, "active");
|
||||
assert.equal(identities[1].status, "staged");
|
||||
assert.throws(() => normalizeCertificateIdentities([
|
||||
{ ...identities[0], status: "staged" },
|
||||
identities[1],
|
||||
]), /active_certificate_identity_invalid/);
|
||||
assert.throws(() => normalizeCertificateIdentities([
|
||||
identities[0],
|
||||
{ ...identities[1], status: "active" },
|
||||
]), /active_certificate_identity_invalid/);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nodedc/device-protocol-contract",
|
||||
"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,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);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
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";
|
||||
const fakeSignal = {
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
sessionRef: "session:test-001",
|
||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||
protocol: "INTERNAL",
|
||||
observedAt: "2026-07-25T00:00:00.000Z",
|
||||
identifier: {
|
||||
kind: "imei",
|
||||
value: fakeImei,
|
||||
},
|
||||
evidence: {
|
||||
transport: "tcp",
|
||||
bytesObserved: 128,
|
||||
framingStatus: "verified",
|
||||
specificationRef: "arusnavi.internal.framing.test-v1",
|
||||
},
|
||||
};
|
||||
|
||||
test("normalizes a verified discovery into quarantine with commands disabled", () => {
|
||||
const signal = normalizeDiscoverySignal(fakeSignal);
|
||||
assert.equal(signal.lifecycleState, "quarantine");
|
||||
assert.equal(signal.commandTransport, "disabled");
|
||||
assert.equal(signal.identifier.value, fakeImei);
|
||||
});
|
||||
|
||||
test("safe discovery projection masks the restricted identifier", () => {
|
||||
const view = toSafeDiscoveryView(fakeSignal, {
|
||||
discoveryRef: "discovery:test-001",
|
||||
});
|
||||
const serialized = JSON.stringify(view);
|
||||
assert.equal(view.identifier.masked, "***********0001");
|
||||
assert.equal(serialized.includes(fakeImei), false);
|
||||
assertSafeProjection(view);
|
||||
});
|
||||
|
||||
test("route-bound discovery preserves only a validated opaque route reference", () => {
|
||||
const routeRef = "route:11111111-1111-4111-8111-111111111111";
|
||||
const signal = normalizeDiscoverySignal({ ...fakeSignal, routeRef });
|
||||
const view = toSafeDiscoveryView(signal);
|
||||
|
||||
assert.equal(signal.routeRef, routeRef);
|
||||
assert.equal(view.routeRef, routeRef);
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({ ...fakeSignal, routeRef: "route:generic" }),
|
||||
/route_ref_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("identifier hashing requires a strong process-only pepper", () => {
|
||||
const identifier = { kind: "imei", value: fakeImei };
|
||||
assert.throws(
|
||||
() => hashRestrictedIdentifier(identifier, "short"),
|
||||
/identifier_pepper_invalid/,
|
||||
);
|
||||
const digest = hashRestrictedIdentifier(
|
||||
identifier,
|
||||
"test-only-pepper-with-at-least-32-bytes",
|
||||
);
|
||||
assertIdentifierDigest(digest);
|
||||
assert.equal(digest.includes(fakeImei), false);
|
||||
assert.equal(
|
||||
digest,
|
||||
hashRestrictedIdentifier(
|
||||
identifier,
|
||||
"test-only-pepper-with-at-least-32-bytes",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("restricted identifier records keep digest internal and expose only a mask", () => {
|
||||
const record = normalizeRestrictedIdentifierRecord({
|
||||
kind: "vendor.serial",
|
||||
digest: `hmac-sha256:${"a".repeat(64)}`,
|
||||
masked: "********ABCD",
|
||||
});
|
||||
const projection = normalizeRestrictedIdentifierProjection({
|
||||
kind: record.kind,
|
||||
masked: record.masked,
|
||||
});
|
||||
|
||||
assert.deepEqual(projection, {
|
||||
kind: "vendor.serial",
|
||||
masked: "********ABCD",
|
||||
});
|
||||
assert.equal("digest" in projection, false);
|
||||
assertSafeProjection({ identifier: projection });
|
||||
assert.throws(
|
||||
() => normalizeRestrictedIdentifierProjection({
|
||||
kind: "vendor.serial",
|
||||
masked: "SERIAL-PLAINTEXT",
|
||||
}),
|
||||
/restricted_identifier_projection_mask_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
...fakeSignal,
|
||||
evidence: { ...fakeSignal.evidence, framingStatus: "unverified" },
|
||||
}),
|
||||
/discovery_evidence_framing_unverified/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({
|
||||
...fakeSignal,
|
||||
command: { kind: "restart" },
|
||||
}),
|
||||
/forbidden_device_field/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects secret-like fields recursively", () => {
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({
|
||||
...fakeSignal,
|
||||
metadata: { devicePassword: "not-a-real-password" },
|
||||
}),
|
||||
/forbidden_device_field/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes an opaque Foundry control binding without device data", () => {
|
||||
const binding = normalizeDevicePlaneBinding({
|
||||
schemaVersion: DEVICE_PLANE_BINDING_SCHEMA,
|
||||
bindingRef: "binding:test-001",
|
||||
contourRef: "contour:robot2b-test",
|
||||
capabilities: ["inspect", "observe", "observe"],
|
||||
});
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const contractUrl = new URL(
|
||||
"../../../deployment/device-edge-core-channel-v1.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const freezeUrl = new URL(
|
||||
"../../../deployment/superseded-vps-initiated-transport-v1.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const sourceAcceptanceUrl = new URL(
|
||||
"../../../deployment/device-edge-core-channel-source-v1.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const edgeBuilder = fileURLToPath(new URL(
|
||||
"../../../infra/deploy-runner/build-device-edge-vps-artifact.mjs",
|
||||
import.meta.url,
|
||||
));
|
||||
const enrollmentBuilder = fileURLToPath(new URL(
|
||||
"../../../infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs",
|
||||
import.meta.url,
|
||||
));
|
||||
|
||||
async function readJson(url) {
|
||||
return JSON.parse(await readFile(url, "utf8"));
|
||||
}
|
||||
|
||||
test("pins a Core-initiated mutually authenticated Edge channel", async () => {
|
||||
const contract = await readJson(contractUrl);
|
||||
|
||||
assert.equal(contract.status, "accepted-design");
|
||||
assert.equal(contract.direction, "device-gateway-core-initiated");
|
||||
assert.equal(contract.transport.tls, "TLSv1.3-mutual-authentication");
|
||||
assert.equal(contract.transport.genericTcpForwarding, "forbidden");
|
||||
assert.equal(contract.networkBoundary.synologyPublicIngress, false);
|
||||
assert.equal(contract.networkBoundary.vpsInitiatedSynologyConnection, false);
|
||||
assert.equal(contract.networkBoundary.subnetRoutes, false);
|
||||
assert.equal(contract.networkBoundary.exitNode, false);
|
||||
assert.equal(contract.identity.privateKeysInArtifacts, false);
|
||||
});
|
||||
|
||||
test("requires Core acceptance before acknowledging tracker packages", async () => {
|
||||
const contract = await readJson(contractUrl);
|
||||
|
||||
assert.equal(
|
||||
contract.acknowledgement.trackerPackageAck,
|
||||
"only-after-bounded-core-acceptance",
|
||||
);
|
||||
assert.equal(
|
||||
contract.acknowledgement.coreUnavailable,
|
||||
"do-not-acknowledge-tracker-package",
|
||||
);
|
||||
assert.equal(contract.acknowledgement.deliverySemantics, "at-least-once");
|
||||
assert.equal(contract.pilotLimits.durableEdgeSpool, false);
|
||||
assert.ok(contract.pilotLimits.maxBufferedBytesPerTrackerSession <= 262144);
|
||||
assert.ok(contract.pilotLimits.maxAggregateBufferedBytes <= 33554432);
|
||||
assert.equal(contract.pilotSlo.trackerAckBeforeDurableCoreAcceptance, 0);
|
||||
assert.equal(contract.pilotSlo.lossOfCoreAcceptedPackages, 0);
|
||||
assert.ok(
|
||||
contract.pilotSlo.edgeReceiveToCoreAcceptanceP99Milliseconds <= 5000,
|
||||
);
|
||||
assert.ok(contract.pilotSlo.deadCoreDetectionHardCeilingSeconds <= 45);
|
||||
});
|
||||
|
||||
test("records source acceptance without opening an Edge or tracker port", async () => {
|
||||
const acceptance = await readJson(sourceAcceptanceUrl);
|
||||
|
||||
assert.equal(acceptance.status, "source-accepted");
|
||||
assert.equal(acceptance.transport.initiator, "device-gateway-core");
|
||||
assert.equal(acceptance.transport.tls, "TLSv1.3-mutual-authentication");
|
||||
assert.equal(acceptance.identity.privateKeysInSource, false);
|
||||
assert.equal(acceptance.identity.privateKeysInArtifact, false);
|
||||
assert.equal(
|
||||
acceptance.identity.rotation,
|
||||
"one-active-plus-one-staged-generation",
|
||||
);
|
||||
assert.equal(acceptance.identity.retiredFingerprint, "reject");
|
||||
assert.equal(acceptance.runtime.mutationInThisTransition, false);
|
||||
assert.equal(acceptance.runtime.edgePort8443Published, false);
|
||||
assert.equal(acceptance.runtime.trackerPort9921Published, false);
|
||||
assert.equal(acceptance.runtime.synologyPublicIngress, false);
|
||||
assert.equal(acceptance.runtime.commandTransport, "disabled");
|
||||
assert.equal(acceptance.runtime.gelios, "untouched");
|
||||
});
|
||||
|
||||
test("freezes the VPS-initiated Tailscale and SSH backhaul", async () => {
|
||||
const freeze = await readJson(freezeUrl);
|
||||
|
||||
assert.equal(freeze.status, "frozen");
|
||||
assert.equal(freeze.successor, "nodedc.device-edge.core-channel.v1");
|
||||
assert.equal(freeze.runtimeMutationInPhase0, false);
|
||||
assert.ok(freeze.forbiddenForNewPlanOrApply.includes(
|
||||
"nodedc.device-plane.backhaul-vps-enrollment.v1",
|
||||
));
|
||||
assert.ok(freeze.forbiddenForNewPlanOrApply.includes(
|
||||
"tailscale-userspace-key-only-ssh-local-forward",
|
||||
));
|
||||
});
|
||||
|
||||
test("superseded artifact builders fail closed outside test-only reconstruction", () => {
|
||||
const environment = { ...process.env };
|
||||
delete environment.NODEDC_ALLOW_SUPERSEDED_TRANSPORT;
|
||||
|
||||
const edge = spawnSync(
|
||||
process.execPath,
|
||||
[edgeBuilder, "backhaul", "superseded-backhaul-unit"],
|
||||
{ encoding: "utf8", env: environment },
|
||||
);
|
||||
assert.notEqual(edge.status, 0);
|
||||
assert.match(edge.stderr, /vps_initiated_transport_frozen:ADR-0001/);
|
||||
|
||||
const enrollment = spawnSync(
|
||||
process.execPath,
|
||||
[enrollmentBuilder, "superseded-enrollment-unit"],
|
||||
{ encoding: "utf8", env: environment },
|
||||
);
|
||||
assert.notEqual(enrollment.status, 0);
|
||||
assert.match(enrollment.stderr, /vps_initiated_transport_frozen:ADR-0001/);
|
||||
});
|
||||
Reference in New Issue
Block a user