feat(device-core): add registry management commands
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const migrationUrl = new URL(
|
||||
"../migrations/005_device_registry_commands.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||
|
||||
test("registry command migration extends the durable receipt allowlist", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
for (const kind of [
|
||||
"adapter_package.ensure",
|
||||
"adapter_version.register",
|
||||
"model_profile.register",
|
||||
"edge.ensure",
|
||||
"route.ensure",
|
||||
"enrollment_intent.ensure",
|
||||
]) {
|
||||
assert.match(sql, new RegExp(`'${kind.replace(".", "\\.")}'`));
|
||||
}
|
||||
assert.doesNotMatch(sql, /session\.(ensure|create|upsert)/);
|
||||
});
|
||||
|
||||
test("registry command migration contains no environment or device data", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.doesNotMatch(sql, /insert\s+into/i);
|
||||
assert.doesNotMatch(sql, /dcctouch|arusnavi|\bb2\b|imei|gelios/i);
|
||||
assert.doesNotMatch(sql, /password|secret|credential|private_key/i);
|
||||
});
|
||||
|
||||
test("repository applies registry commands after registry schema", async () => {
|
||||
const source = await readFile(repositoryUrl, "utf8");
|
||||
const schemaIndex = source.indexOf("004_device_registry_foundation.sql");
|
||||
const commandsIndex = source.indexOf("005_device_registry_commands.sql");
|
||||
|
||||
assert.notEqual(schemaIndex, -1);
|
||||
assert.notEqual(commandsIndex, -1);
|
||||
assert.ok(schemaIndex < commandsIndex);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createControlCoreApp } from "../src/app.mjs";
|
||||
|
||||
const managementToken = "test-only-management-token-with-32-bytes";
|
||||
|
||||
test("management API forwards a normalized generic Edge registration", async () => {
|
||||
let executed;
|
||||
const runtime = await startServer({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async (input) => {
|
||||
executed = input;
|
||||
return { replayed: false, result: { created: true } };
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/edges:ensure`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: managementHeaders(),
|
||||
body: JSON.stringify({
|
||||
edgeKey: "generic-edge",
|
||||
displayName: "Generic Edge",
|
||||
deploymentRef: "deployment:device-edge/pilot",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(executed.commandKind, "edge.ensure");
|
||||
assert.deepEqual(executed.command, {
|
||||
edgeKey: "generic-edge",
|
||||
displayName: "Generic Edge",
|
||||
deploymentRef: "deployment:device-edge/pilot",
|
||||
lifecycleState: "provisioning",
|
||||
});
|
||||
assert.equal(executed.actor.hubRole, "owner");
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("management API exposes no user-owned session mutation", async () => {
|
||||
let executions = 0;
|
||||
const runtime = await startServer({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async () => {
|
||||
executions += 1;
|
||||
return { replayed: false, result: {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/sessions:ensure`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: managementHeaders(),
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal(executions, 0);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function startServer(options) {
|
||||
const server = createControlCoreApp(options);
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function managementHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${managementToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": "phase23-edge-0001",
|
||||
"X-NODEDC-User-Ref": "user:platform-owner",
|
||||
"X-NODEDC-Hub-Role": "owner",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
assertPlatformCatalogAuthority,
|
||||
DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
|
||||
normalizeInfrastructureManagementCommand,
|
||||
} from "../src/infrastructure-management.mjs";
|
||||
import {
|
||||
ALL_DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||
normalizeDeviceManagementCommand,
|
||||
} from "../src/management-command.mjs";
|
||||
import { normalizeManagementActor } from "../src/project-management.mjs";
|
||||
|
||||
const projectRef = "project:11111111-1111-4111-8111-111111111111";
|
||||
const packageRef = "adapter-package:22222222-2222-4222-8222-222222222222";
|
||||
const versionRef = "adapter-version:33333333-3333-4333-8333-333333333333";
|
||||
const edgeRef = "edge:44444444-4444-4444-8444-444444444444";
|
||||
const routeRef = "route:55555555-5555-4555-8555-555555555555";
|
||||
const digest = `sha256:${"a".repeat(64)}`;
|
||||
const identifierDigest = `hmac-sha256:${"b".repeat(64)}`;
|
||||
|
||||
test("aggregates project and infrastructure commands without a session mutation", () => {
|
||||
for (const kind of DEVICE_INFRASTRUCTURE_COMMAND_KINDS) {
|
||||
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true);
|
||||
}
|
||||
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes("project.ensure"), true);
|
||||
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes("session.ensure"), false);
|
||||
assert.throws(
|
||||
() => normalizeDeviceManagementCommand("session.ensure", {}),
|
||||
/device_management_command_kind_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes immutable adapter version metadata and sorted capabilities", () => {
|
||||
const command = normalizeInfrastructureManagementCommand(
|
||||
"adapter_version.register",
|
||||
{
|
||||
adapterPackageRef: packageRef,
|
||||
version: "1.2.3",
|
||||
runtimePackageRef: "artifact:device-adapters/generic-1.2.3",
|
||||
contentDigest: digest,
|
||||
contractVersion: "nodedc.device-adapter.v1",
|
||||
capabilities: ["telemetry.observe", "command.typed", "telemetry.observe"],
|
||||
lifecycleState: "active",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(command.adapterPackageId, packageRef.slice("adapter-package:".length));
|
||||
assert.deepEqual(command.capabilities, ["command.typed", "telemetry.observe"]);
|
||||
assert.equal(command.contentDigest, digest);
|
||||
});
|
||||
|
||||
test("normalizes a generic model profile as artifact metadata, not executable payload", () => {
|
||||
const command = normalizeInfrastructureManagementCommand(
|
||||
"model_profile.register",
|
||||
{
|
||||
adapterVersionRef: versionRef,
|
||||
profileRef: "vendor.model.protocol.v1",
|
||||
schemaVersion: "nodedc.device-model-profile.v1",
|
||||
vendor: "Example Vendor",
|
||||
model: "Model One",
|
||||
deviceType: "tracker",
|
||||
protocol: "GENERIC_TCP",
|
||||
schemaArtifactRef: "artifact:model-profiles/vendor-model-v1",
|
||||
profileDigest: digest,
|
||||
capabilities: ["telemetry.observe"],
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(command.adapterVersionId, versionRef.slice("adapter-version:".length));
|
||||
assert.equal(command.protocol, "GENERIC_TCP");
|
||||
assert.equal(command.lifecycleState, "draft");
|
||||
assert.equal("profile" in command, false);
|
||||
assert.equal("source" in command, false);
|
||||
});
|
||||
|
||||
test("route and enrollment commands resolve only scoped references", () => {
|
||||
const route = normalizeInfrastructureManagementCommand("route.ensure", {
|
||||
projectRef,
|
||||
routeKey: "primary-ingress",
|
||||
displayName: "Primary ingress",
|
||||
edgeRef,
|
||||
modelProfileRef: "vendor.model.protocol.v1",
|
||||
listenerRef: "listener:generic-tcp-primary",
|
||||
protocol: "GENERIC_TCP",
|
||||
direction: "bidirectional",
|
||||
});
|
||||
const enrollment = normalizeInfrastructureManagementCommand(
|
||||
"enrollment_intent.ensure",
|
||||
{
|
||||
projectRef,
|
||||
enrollmentKey: "pilot-device",
|
||||
routeRef,
|
||||
modelProfileRef: "vendor.model.protocol.v1",
|
||||
displayName: "Pilot device",
|
||||
identifierKind: "serial",
|
||||
identifierDigest,
|
||||
identifierMasked: "********0001",
|
||||
expiresAt: "2026-09-01T00:00:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(route.projectId, projectRef.slice("project:".length));
|
||||
assert.equal(route.edgeId, edgeRef.slice("edge:".length));
|
||||
assert.equal(enrollment.routeId, routeRef.slice("route:".length));
|
||||
assert.equal(enrollment.identifierDigest, identifierDigest);
|
||||
});
|
||||
|
||||
test("enrollment contract rejects raw identifiers and credential-shaped fields", () => {
|
||||
const base = {
|
||||
projectRef,
|
||||
enrollmentKey: "pilot-device",
|
||||
routeRef,
|
||||
modelProfileRef: "vendor.model.protocol.v1",
|
||||
displayName: "Pilot device",
|
||||
identifierKind: "imei",
|
||||
identifierDigest,
|
||||
identifierMasked: "***********0001",
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => normalizeInfrastructureManagementCommand(
|
||||
"enrollment_intent.ensure",
|
||||
{ ...base, identifierMasked: "000000000000001" },
|
||||
),
|
||||
/safe_projection_contains_unmasked_imei/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeInfrastructureManagementCommand(
|
||||
"enrollment_intent.ensure",
|
||||
{ ...base, credential: "forbidden" },
|
||||
),
|
||||
/device_management_command_field_unexpected:credential/,
|
||||
);
|
||||
});
|
||||
|
||||
test("shared catalog and Edge authority requires the Hub owner ceiling", () => {
|
||||
assert.doesNotThrow(() => assertPlatformCatalogAuthority(actor("owner")));
|
||||
assert.throws(
|
||||
() => assertPlatformCatalogAuthority(actor("admin")),
|
||||
/device_platform_catalog_access_denied/,
|
||||
);
|
||||
});
|
||||
|
||||
function actor(hubRole) {
|
||||
return normalizeManagementActor({
|
||||
userRef: "user:platform-admin",
|
||||
hubRole,
|
||||
groupRefs: [],
|
||||
ownerScopes: [],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import { normalizeDeviceManagementCommand } from "../src/management-command.mjs";
|
||||
import { PostgresDeviceRepository } from "../src/postgres-repository.mjs";
|
||||
import { normalizeManagementActor } from "../src/project-management.mjs";
|
||||
|
||||
const now = new Date("2026-08-10T00:00:00.000Z");
|
||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||
const edgeId = "22222222-2222-4222-8222-222222222222";
|
||||
const routeId = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
test("commits an owner-authorized generic Edge registration", async () => {
|
||||
const actor = managementActor("owner");
|
||||
const command = normalizeDeviceManagementCommand("edge.ensure", {
|
||||
edgeKey: "generic-edge",
|
||||
displayName: "Generic Edge",
|
||||
deploymentRef: "deployment:device-edge/pilot",
|
||||
});
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_management_command_receipts", {
|
||||
rows: [{ id: "receipt-edge" }],
|
||||
}),
|
||||
step("insert into device_edges", {
|
||||
rows: [{
|
||||
id: edgeId,
|
||||
edge_key: command.edgeKey,
|
||||
display_name: command.displayName,
|
||||
deployment_ref: command.deploymentRef,
|
||||
lifecycle_state: command.lifecycleState,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
created: true,
|
||||
}],
|
||||
}),
|
||||
step("insert into device_audit_events"),
|
||||
step("update device_management_command_receipts"),
|
||||
step("commit"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
const result = await repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "edge.ensure",
|
||||
command,
|
||||
digestCharacter: "a",
|
||||
}));
|
||||
|
||||
assert.equal(result.replayed, false);
|
||||
assert.equal(result.result.edge.edgeRef, `edge:${edgeId}`);
|
||||
assert.equal(result.result.edge.lifecycleState, "provisioning");
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("denies project route mutation without an explicit project grant", async () => {
|
||||
const actor = managementActor("owner");
|
||||
const command = normalizeDeviceManagementCommand("route.ensure", {
|
||||
projectRef: `project:${projectId}`,
|
||||
routeKey: "generic-ingress",
|
||||
displayName: "Generic ingress",
|
||||
edgeRef: `edge:${edgeId}`,
|
||||
modelProfileRef: "vendor.model.protocol.v1",
|
||||
listenerRef: "listener:generic-tcp-primary",
|
||||
protocol: "GENERIC_TCP",
|
||||
});
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_management_command_receipts", {
|
||||
rows: [{ id: "receipt-route" }],
|
||||
}),
|
||||
step("from device_projects p", {
|
||||
rows: [{
|
||||
id: projectId,
|
||||
lifecycle_state: "active",
|
||||
owner_lifecycle_state: "active",
|
||||
}],
|
||||
}),
|
||||
step("from device_project_grants", { rows: [] }),
|
||||
step("rollback"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
await assert.rejects(
|
||||
repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "route.ensure",
|
||||
command,
|
||||
digestCharacter: "b",
|
||||
})),
|
||||
/device_project_capability_denied/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("stores enrollment digest but returns and audits only its masked projection", async () => {
|
||||
const actor = managementActor("member");
|
||||
const command = normalizeDeviceManagementCommand(
|
||||
"enrollment_intent.ensure",
|
||||
{
|
||||
projectRef: `project:${projectId}`,
|
||||
enrollmentKey: "pilot-device",
|
||||
routeRef: `route:${routeId}`,
|
||||
modelProfileRef: "vendor.model.protocol.v1",
|
||||
displayName: "Pilot device",
|
||||
identifierKind: "serial",
|
||||
identifierDigest: `hmac-sha256:${"c".repeat(64)}`,
|
||||
identifierMasked: "********0001",
|
||||
expiresAt: "2026-09-01T00:00:00.000Z",
|
||||
},
|
||||
);
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_management_command_receipts", {
|
||||
rows: [{ id: "receipt-enrollment" }],
|
||||
}),
|
||||
step("from device_projects p", {
|
||||
rows: [{
|
||||
id: projectId,
|
||||
lifecycle_state: "active",
|
||||
owner_lifecycle_state: "active",
|
||||
}],
|
||||
}),
|
||||
step("from device_project_grants", {
|
||||
rows: [{
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
principal_kind: "user",
|
||||
principal_ref: actor.userRef,
|
||||
project_role: "engineer",
|
||||
capability_allow: [],
|
||||
capability_deny: [],
|
||||
lifecycle_state: "active",
|
||||
}],
|
||||
}),
|
||||
step("from device_routes", {
|
||||
rows: [{
|
||||
id: routeId,
|
||||
project_id: projectId,
|
||||
route_key: "generic-ingress",
|
||||
display_name: "Generic ingress",
|
||||
edge_id: edgeId,
|
||||
model_profile_ref: command.modelProfileRef,
|
||||
listener_ref: "listener:generic-tcp-primary",
|
||||
protocol: "GENERIC_TCP",
|
||||
direction: "telemetry",
|
||||
lifecycle_state: "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}],
|
||||
}),
|
||||
step("insert into device_enrollment_intents", {
|
||||
rows: [{
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
project_id: projectId,
|
||||
enrollment_key: command.enrollmentKey,
|
||||
route_id: routeId,
|
||||
model_profile_ref: command.modelProfileRef,
|
||||
display_name: command.displayName,
|
||||
expected_identifier_kind: command.identifierKind,
|
||||
expected_identifier_masked: command.identifierMasked,
|
||||
lifecycle_state: "pending",
|
||||
expires_at: new Date(command.expiresAt),
|
||||
claimed_device_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
created: true,
|
||||
}],
|
||||
}),
|
||||
step("insert into device_audit_events"),
|
||||
step("update device_management_command_receipts"),
|
||||
step("commit"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
const result = await repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "enrollment_intent.ensure",
|
||||
command,
|
||||
digestCharacter: "d",
|
||||
}));
|
||||
|
||||
assertSafeProjection(result.result.enrollmentIntent);
|
||||
assert.equal(
|
||||
result.result.enrollmentIntent.identifier.masked,
|
||||
command.identifierMasked,
|
||||
);
|
||||
assert.equal(JSON.stringify(result.result).includes(command.identifierDigest), false);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
function managementActor(hubRole) {
|
||||
return normalizeManagementActor({
|
||||
userRef: "user:platform-owner",
|
||||
hubRole,
|
||||
groupRefs: [],
|
||||
ownerScopes: [],
|
||||
});
|
||||
}
|
||||
|
||||
function commandInput({ actor, commandKind, command, digestCharacter }) {
|
||||
return {
|
||||
idempotencyKey: `phase23-${commandKind.replaceAll(".", "-")}-0001`,
|
||||
commandKind,
|
||||
requestDigest: `sha256:${digestCharacter.repeat(64)}`,
|
||||
actor,
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
||||
function repositoryWithClient(client) {
|
||||
return new PostgresDeviceRepository({
|
||||
pool: {
|
||||
query: async () => ({ rows: [] }),
|
||||
connect: async () => client,
|
||||
end: async () => undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function step(includes, result = { rows: [] }) {
|
||||
return { includes, result };
|
||||
}
|
||||
|
||||
function scriptedClient(steps) {
|
||||
const queue = [...steps];
|
||||
return {
|
||||
released: false,
|
||||
async query(sql) {
|
||||
const next = queue.shift();
|
||||
assert.ok(next, `Unexpected query: ${sql}`);
|
||||
assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i"));
|
||||
return next.result;
|
||||
},
|
||||
release() {
|
||||
this.released = true;
|
||||
},
|
||||
remaining() {
|
||||
return queue.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user