From 72db23c0e93a8a3ed4399bf64c9a33ed07a519e7 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 17:44:14 +0300 Subject: [PATCH] feat(device-core): add registry management commands --- .../005_device_registry_commands.sql | 21 + .../services/device-control-core/src/app.mjs | 13 +- .../src/infrastructure-management.mjs | 399 +++++++++ .../src/infrastructure-repository.mjs | 765 ++++++++++++++++++ .../src/management-command.mjs | 21 + .../src/postgres-repository.mjs | 20 + ...device-registry-command-migration.test.mjs | 43 + .../test/infrastructure-app.test.mjs | 102 +++ .../test/infrastructure-management.test.mjs | 153 ++++ .../test/infrastructure-repository.test.mjs | 249 ++++++ 10 files changed, 1784 insertions(+), 2 deletions(-) create mode 100644 device-plane/services/device-control-core/migrations/005_device_registry_commands.sql create mode 100644 device-plane/services/device-control-core/src/infrastructure-management.mjs create mode 100644 device-plane/services/device-control-core/src/infrastructure-repository.mjs create mode 100644 device-plane/services/device-control-core/src/management-command.mjs create mode 100644 device-plane/services/device-control-core/test/device-registry-command-migration.test.mjs create mode 100644 device-plane/services/device-control-core/test/infrastructure-app.test.mjs create mode 100644 device-plane/services/device-control-core/test/infrastructure-management.test.mjs create mode 100644 device-plane/services/device-control-core/test/infrastructure-repository.test.mjs diff --git a/device-plane/services/device-control-core/migrations/005_device_registry_commands.sql b/device-plane/services/device-control-core/migrations/005_device_registry_commands.sql new file mode 100644 index 0000000..062ce52 --- /dev/null +++ b/device-plane/services/device-control-core/migrations/005_device_registry_commands.sql @@ -0,0 +1,21 @@ +begin; + +alter table device_management_command_receipts + drop constraint if exists device_management_command_receipts_command_kind_check; + +alter table device_management_command_receipts + add constraint device_management_command_receipts_command_kind_check + check (command_kind in ( + 'owner_scope.ensure', + 'project.ensure', + 'collection.ensure', + 'project_grant.upsert', + 'adapter_package.ensure', + 'adapter_version.register', + 'model_profile.register', + 'edge.ensure', + 'route.ensure', + 'enrollment_intent.ensure' + )); + +commit; diff --git a/device-plane/services/device-control-core/src/app.mjs b/device-plane/services/device-control-core/src/app.mjs index 491d28a..4ce040a 100644 --- a/device-plane/services/device-control-core/src/app.mjs +++ b/device-plane/services/device-control-core/src/app.mjs @@ -9,14 +9,20 @@ import { } from "../../../packages/device-protocol-contract/src/index.mjs"; import { normalizeManagementActor, - normalizeManagementCommand, } from "./project-management.mjs"; +import { normalizeDeviceManagementCommand } from "./management-command.mjs"; const managementRoutes = new Map([ ["/internal/v1/management/owner-scopes:ensure", "owner_scope.ensure"], ["/internal/v1/management/projects:ensure", "project.ensure"], ["/internal/v1/management/collections:ensure", "collection.ensure"], ["/internal/v1/management/project-grants:upsert", "project_grant.upsert"], + ["/internal/v1/management/adapter-packages:ensure", "adapter_package.ensure"], + ["/internal/v1/management/adapter-versions:register", "adapter_version.register"], + ["/internal/v1/management/model-profiles:register", "model_profile.register"], + ["/internal/v1/management/edges:ensure", "edge.ensure"], + ["/internal/v1/management/routes:ensure", "route.ensure"], + ["/internal/v1/management/enrollment-intents:ensure", "enrollment_intent.ensure"], ]); export function createControlCoreApp({ @@ -93,7 +99,10 @@ export function createControlCoreApp({ ); const actor = managementActorFromHeaders(request.headers); const input = await readJsonBody(request, 64 * 1024); - const command = normalizeManagementCommand(managementCommandKind, input); + const command = normalizeDeviceManagementCommand( + managementCommandKind, + input, + ); const requestDigest = managementRequestDigest({ actor, commandKind: managementCommandKind, diff --git a/device-plane/services/device-control-core/src/infrastructure-management.mjs b/device-plane/services/device-control-core/src/infrastructure-management.mjs new file mode 100644 index 0000000..efeafca --- /dev/null +++ b/device-plane/services/device-control-core/src/infrastructure-management.mjs @@ -0,0 +1,399 @@ +import { + assertIdentifierDigest, + assertSafeProjection, +} from "../../../packages/device-protocol-contract/src/index.mjs"; +import { normalizeManagementActor } from "./project-management.mjs"; + +export const DEVICE_INFRASTRUCTURE_COMMAND_KINDS = Object.freeze([ + "adapter_package.ensure", + "adapter_version.register", + "model_profile.register", + "edge.ensure", + "route.ensure", + "enrollment_intent.ensure", +]); + +const commandKindSet = new Set(DEVICE_INFRASTRUCTURE_COMMAND_KINDS); +const keyPattern = /^[a-z][a-z0-9-]{1,62}$/; +const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/; +const profileRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/; +const protocolPattern = /^[A-Z][A-Z0-9_]{0,31}$/; +const capabilityPattern = /^[a-z][a-z0-9._-]{1,63}$/; +const semverPattern = /^[0-9]+\.[0-9]+\.[0-9]+(?:[+-][A-Za-z0-9.-]+)?$/; +const digestPattern = /^sha256:[a-f0-9]{64}$/; +const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +export function isInfrastructureManagementCommand(kind) { + return commandKindSet.has(kind); +} + +export function normalizeInfrastructureManagementCommand(kind, input) { + if (!commandKindSet.has(kind)) { + throw new TypeError("device_infrastructure_command_kind_invalid"); + } + assertPlainObject(input, "device_infrastructure_command_invalid"); + + if (kind === "adapter_package.ensure") { + assertAllowedKeys(input, [ + "packageKey", + "displayName", + "publisherRef", + "lifecycleState", + ]); + return Object.freeze({ + packageKey: normalizeKey(input.packageKey, "device_adapter_package_key_invalid"), + displayName: normalizeDisplayText( + input.displayName, + 160, + "device_adapter_package_name_invalid", + ), + publisherRef: normalizeOpaqueRef( + input.publisherRef, + "device_adapter_publisher_ref_invalid", + ), + lifecycleState: normalizeEnum( + input.lifecycleState ?? "active", + new Set(["active", "retired"]), + "device_adapter_package_state_invalid", + ), + }); + } + + if (kind === "adapter_version.register") { + assertAllowedKeys(input, [ + "adapterPackageRef", + "version", + "runtimePackageRef", + "contentDigest", + "contractVersion", + "capabilities", + "lifecycleState", + ]); + return Object.freeze({ + adapterPackageId: normalizeEntityRef( + input.adapterPackageRef, + "adapter-package", + "device_adapter_package_ref_invalid", + ), + version: normalizePattern( + input.version, + semverPattern, + "device_adapter_version_invalid", + ), + runtimePackageRef: normalizeOpaqueRef( + input.runtimePackageRef, + "device_adapter_runtime_package_ref_invalid", + ), + contentDigest: normalizePattern( + input.contentDigest, + digestPattern, + "device_adapter_content_digest_invalid", + ), + contractVersion: normalizeProfileRef( + input.contractVersion, + "device_adapter_contract_version_invalid", + ), + capabilities: Object.freeze(normalizeCapabilities(input.capabilities ?? [])), + lifecycleState: normalizeEnum( + input.lifecycleState ?? "draft", + new Set(["draft", "active", "retired"]), + "device_adapter_version_state_invalid", + ), + }); + } + + if (kind === "model_profile.register") { + assertAllowedKeys(input, [ + "adapterVersionRef", + "profileRef", + "schemaVersion", + "vendor", + "model", + "deviceType", + "protocol", + "schemaArtifactRef", + "profileDigest", + "capabilities", + "lifecycleState", + ]); + return Object.freeze({ + adapterVersionId: normalizeEntityRef( + input.adapterVersionRef, + "adapter-version", + "device_adapter_version_ref_invalid", + ), + profileRef: normalizeProfileRef( + input.profileRef, + "device_model_profile_ref_invalid", + ), + schemaVersion: normalizeProfileRef( + input.schemaVersion, + "device_model_profile_schema_version_invalid", + ), + vendor: normalizeDisplayText(input.vendor, 120, "device_model_vendor_invalid"), + model: normalizeDisplayText(input.model, 120, "device_model_name_invalid"), + deviceType: normalizePattern( + input.deviceType, + capabilityPattern, + "device_model_type_invalid", + ), + protocol: normalizePattern( + input.protocol, + protocolPattern, + "device_model_protocol_invalid", + ), + schemaArtifactRef: normalizeOpaqueRef( + input.schemaArtifactRef, + "device_model_schema_artifact_ref_invalid", + ), + profileDigest: normalizePattern( + input.profileDigest, + digestPattern, + "device_model_profile_digest_invalid", + ), + capabilities: Object.freeze(normalizeCapabilities(input.capabilities ?? [])), + lifecycleState: normalizeEnum( + input.lifecycleState ?? "draft", + new Set(["draft", "active", "retired"]), + "device_model_profile_state_invalid", + ), + }); + } + + if (kind === "edge.ensure") { + assertAllowedKeys(input, [ + "edgeKey", + "displayName", + "deploymentRef", + "lifecycleState", + ]); + return Object.freeze({ + edgeKey: normalizeKey(input.edgeKey, "device_edge_key_invalid"), + displayName: normalizeDisplayText( + input.displayName, + 160, + "device_edge_name_invalid", + ), + deploymentRef: normalizeOptionalOpaqueRef( + input.deploymentRef, + "device_edge_deployment_ref_invalid", + ), + lifecycleState: normalizeEnum( + input.lifecycleState ?? "provisioning", + new Set(["provisioning", "active", "suspended", "retired"]), + "device_edge_state_invalid", + ), + }); + } + + if (kind === "route.ensure") { + assertAllowedKeys(input, [ + "projectRef", + "routeKey", + "displayName", + "edgeRef", + "modelProfileRef", + "listenerRef", + "protocol", + "direction", + "lifecycleState", + ]); + return Object.freeze({ + projectId: normalizeEntityRef( + input.projectRef, + "project", + "device_project_ref_invalid", + ), + routeKey: normalizeKey(input.routeKey, "device_route_key_invalid"), + displayName: normalizeDisplayText( + input.displayName, + 160, + "device_route_name_invalid", + ), + edgeId: normalizeEntityRef( + input.edgeRef, + "edge", + "device_edge_ref_invalid", + ), + modelProfileRef: normalizeProfileRef( + input.modelProfileRef, + "device_model_profile_ref_invalid", + ), + listenerRef: normalizeOpaqueRef( + input.listenerRef, + "device_route_listener_ref_invalid", + ), + protocol: normalizePattern( + input.protocol, + protocolPattern, + "device_route_protocol_invalid", + ), + direction: normalizeEnum( + input.direction ?? "telemetry", + new Set(["telemetry", "bidirectional"]), + "device_route_direction_invalid", + ), + lifecycleState: normalizeEnum( + input.lifecycleState ?? "draft", + new Set(["draft", "active", "suspended", "retired"]), + "device_route_state_invalid", + ), + }); + } + + assertAllowedKeys(input, [ + "projectRef", + "enrollmentKey", + "routeRef", + "modelProfileRef", + "displayName", + "identifierKind", + "identifierDigest", + "identifierMasked", + "expiresAt", + ]); + const identifierMasked = normalizeDisplayText( + input.identifierMasked, + 64, + "device_enrollment_identifier_masked_invalid", + ); + assertSafeProjection({ identifierMasked }); + return Object.freeze({ + projectId: normalizeEntityRef( + input.projectRef, + "project", + "device_project_ref_invalid", + ), + enrollmentKey: normalizeKey( + input.enrollmentKey, + "device_enrollment_key_invalid", + ), + routeId: normalizeEntityRef( + input.routeRef, + "route", + "device_route_ref_invalid", + ), + modelProfileRef: normalizeProfileRef( + input.modelProfileRef, + "device_model_profile_ref_invalid", + ), + displayName: normalizeDisplayText( + input.displayName, + 160, + "device_enrollment_name_invalid", + ), + identifierKind: normalizePattern( + input.identifierKind, + /^[a-z][a-z0-9._-]{1,31}$/, + "device_enrollment_identifier_kind_invalid", + ), + identifierDigest: assertIdentifierDigest(input.identifierDigest), + identifierMasked, + expiresAt: normalizeOptionalTimestamp(input.expiresAt), + }); +} + +export function assertPlatformCatalogAuthority(actorInput) { + const actor = normalizeManagementActor(actorInput); + if (actor.hubRole !== "owner") { + throw domainError("device_platform_catalog_access_denied", 403); + } + return actor; +} + +function normalizeCapabilities(input) { + if (!Array.isArray(input) || input.length > 64) { + throw new TypeError("device_adapter_capabilities_invalid"); + } + return [...new Set(input.map((capability) => normalizePattern( + capability, + capabilityPattern, + "device_adapter_capability_invalid", + )))].sort(); +} + +function normalizeEntityRef(value, prefix, code) { + if (typeof value !== "string") throw new TypeError(code); + const match = value.match(new RegExp( + `^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, + "i", + )); + if (!match) throw new TypeError(code); + return match[1].toLowerCase(); +} + +function normalizeKey(value, code) { + return normalizePattern(value, keyPattern, code); +} + +function normalizeProfileRef(value, code) { + return normalizePattern(value, profileRefPattern, code); +} + +function normalizeOpaqueRef(value, code) { + return normalizePattern(value, opaqueRefPattern, code); +} + +function normalizeOptionalOpaqueRef(value, code) { + if (value == null || value === "") return null; + return normalizeOpaqueRef(value, code); +} + +function normalizePattern(value, pattern, code) { + if (typeof value !== "string" || !pattern.test(value)) { + throw new TypeError(code); + } + return value; +} + +function normalizeDisplayText(value, maxLength, code) { + if (typeof value !== "string") throw new TypeError(code); + const normalized = value.trim(); + if (normalized.length < 1 || normalized.length > maxLength) { + throw new TypeError(code); + } + if (/\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)) { + throw new TypeError(code); + } + return normalized; +} + +function normalizeOptionalTimestamp(value) { + if (value == null || value === "") return null; + if (typeof value !== "string" || !isoTimestampPattern.test(value)) { + throw new TypeError("device_enrollment_expires_at_invalid"); + } + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) { + throw new TypeError("device_enrollment_expires_at_invalid"); + } + return value; +} + +function normalizeEnum(value, allowed, code) { + if (typeof value !== "string" || !allowed.has(value)) { + throw new TypeError(code); + } + return value; +} + +function assertPlainObject(value, code) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(code); + } +} + +function assertAllowedKeys(input, allowed) { + const allowedSet = new Set(allowed); + for (const key of Object.keys(input)) { + if (!allowedSet.has(key)) { + throw new TypeError(`device_management_command_field_unexpected:${key}`); + } + } +} + +function domainError(code, statusCode) { + const error = new Error(code); + error.statusCode = statusCode; + return error; +} diff --git a/device-plane/services/device-control-core/src/infrastructure-repository.mjs b/device-plane/services/device-control-core/src/infrastructure-repository.mjs new file mode 100644 index 0000000..73825a9 --- /dev/null +++ b/device-plane/services/device-control-core/src/infrastructure-repository.mjs @@ -0,0 +1,765 @@ +import { randomUUID } from "node:crypto"; + +import { + assertPlatformCatalogAuthority, + isInfrastructureManagementCommand, +} from "./infrastructure-management.mjs"; +import { + assertProjectCapability, + toProjectRef, +} from "./project-management.mjs"; + +export async function applyInfrastructureManagementCommand( + client, + { commandKind, actor, command }, +) { + if (!isInfrastructureManagementCommand(commandKind)) { + throw new TypeError("device_infrastructure_command_kind_invalid"); + } + if (commandKind === "adapter_package.ensure") { + return ensureAdapterPackage(client, actor, command); + } + if (commandKind === "adapter_version.register") { + return registerAdapterVersion(client, actor, command); + } + if (commandKind === "model_profile.register") { + return registerModelProfile(client, actor, command); + } + if (commandKind === "edge.ensure") { + return ensureEdge(client, actor, command); + } + if (commandKind === "route.ensure") { + return ensureRoute(client, actor, command); + } + return ensureEnrollmentIntent(client, actor, command); +} + +export async function authorizeInfrastructureManagementReplay( + client, + { commandKind, actor, command }, +) { + if (!isInfrastructureManagementCommand(commandKind)) { + throw new TypeError("device_infrastructure_command_kind_invalid"); + } + if ( + commandKind === "adapter_package.ensure" + || commandKind === "adapter_version.register" + || commandKind === "model_profile.register" + || commandKind === "edge.ensure" + ) { + assertPlatformCatalogAuthority(actor); + return; + } + + const capability = commandKind === "route.ensure" + ? "route.manage" + : "device.enroll"; + await assertCurrentProjectCapability(client, actor, command.projectId, capability); +} + +async function ensureAdapterPackage(client, actor, command) { + assertPlatformCatalogAuthority(actor); + const result = await client.query( + `insert into device_adapter_packages ( + id, + package_key, + display_name, + publisher_ref, + lifecycle_state, + created_by_ref + ) values ($1, $2, $3, $4, $5, $6) + on conflict (package_key) do update set + display_name = excluded.display_name, + lifecycle_state = excluded.lifecycle_state, + updated_at = now() + where device_adapter_packages.publisher_ref = excluded.publisher_ref + returning id, package_key, display_name, publisher_ref, lifecycle_state, + created_at, updated_at, (xmax = 0) as created`, + [ + randomUUID(), + command.packageKey, + command.displayName, + command.publisherRef, + command.lifecycleState, + actor.userRef, + ], + ); + const row = requireMutationRow( + result, + "device_adapter_package_identity_conflict", + ); + await addAudit(client, { + eventType: row.created + ? "adapter_package.created" + : "adapter_package.updated", + actorRef: actor.userRef, + payload: { + adapterPackageRef: `adapter-package:${row.id}`, + packageKey: row.package_key, + publisherRef: row.publisher_ref, + lifecycleState: row.lifecycle_state, + }, + }); + return { + created: row.created === true, + adapterPackage: adapterPackageView(row), + }; +} + +async function registerAdapterVersion(client, actor, command) { + assertPlatformCatalogAuthority(actor); + const adapterPackage = await findAdapterPackage( + client, + command.adapterPackageId, + ); + if (adapterPackage.lifecycle_state !== "active") { + throw domainError("device_adapter_package_inactive", 409); + } + + const result = await client.query( + `insert into device_adapter_versions ( + id, + adapter_package_id, + version, + runtime_package_ref, + content_digest, + contract_version, + capabilities, + lifecycle_state, + registered_by_ref + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + on conflict (adapter_package_id, version) do update set + lifecycle_state = excluded.lifecycle_state, + updated_at = now() + where device_adapter_versions.runtime_package_ref = excluded.runtime_package_ref + and device_adapter_versions.content_digest = excluded.content_digest + and device_adapter_versions.contract_version = excluded.contract_version + and device_adapter_versions.capabilities = excluded.capabilities + returning id, adapter_package_id, version, runtime_package_ref, + content_digest, contract_version, capabilities, lifecycle_state, + created_at, updated_at, (xmax = 0) as created`, + [ + randomUUID(), + command.adapterPackageId, + command.version, + command.runtimePackageRef, + command.contentDigest, + command.contractVersion, + command.capabilities, + command.lifecycleState, + actor.userRef, + ], + ); + const row = requireMutationRow( + result, + "device_adapter_version_identity_conflict", + ); + await addAudit(client, { + eventType: row.created + ? "adapter_version.registered" + : "adapter_version.lifecycle_updated", + actorRef: actor.userRef, + payload: { + adapterPackageRef: `adapter-package:${row.adapter_package_id}`, + adapterVersionRef: `adapter-version:${row.id}`, + version: row.version, + contentDigest: row.content_digest, + lifecycleState: row.lifecycle_state, + }, + }); + return { + created: row.created === true, + adapterPackage: adapterPackageView(adapterPackage), + adapterVersion: adapterVersionView(row), + }; +} + +async function registerModelProfile(client, actor, command) { + assertPlatformCatalogAuthority(actor); + const adapterVersion = await findAdapterVersion( + client, + command.adapterVersionId, + ); + if ( + adapterVersion.package_lifecycle_state !== "active" + || adapterVersion.lifecycle_state === "retired" + ) { + throw domainError("device_adapter_version_inactive", 409); + } + if ( + command.lifecycleState === "active" + && adapterVersion.lifecycle_state !== "active" + ) { + throw domainError("device_model_profile_adapter_not_active", 409); + } + + const profile = { + schemaVersion: command.schemaVersion, + profileRef: command.profileRef, + vendor: command.vendor, + model: command.model, + deviceType: command.deviceType, + protocol: command.protocol, + schemaArtifactRef: command.schemaArtifactRef, + capabilities: command.capabilities, + }; + const result = await client.query( + `insert into device_model_profiles ( + profile_ref, + schema_version, + vendor, + model, + device_type, + protocol, + profile, + adapter_version_id, + schema_artifact_ref, + profile_digest, + capabilities, + lifecycle_state + ) values ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, $12) + on conflict (profile_ref) do update set + lifecycle_state = excluded.lifecycle_state, + updated_at = now() + where device_model_profiles.schema_version = excluded.schema_version + and device_model_profiles.vendor = excluded.vendor + and device_model_profiles.model = excluded.model + and device_model_profiles.device_type = excluded.device_type + and device_model_profiles.protocol = excluded.protocol + and device_model_profiles.profile = excluded.profile + and device_model_profiles.adapter_version_id = excluded.adapter_version_id + and device_model_profiles.schema_artifact_ref = excluded.schema_artifact_ref + and device_model_profiles.profile_digest = excluded.profile_digest + and device_model_profiles.capabilities = excluded.capabilities + returning profile_ref, schema_version, vendor, model, device_type, + protocol, adapter_version_id, schema_artifact_ref, profile_digest, + capabilities, lifecycle_state, created_at, updated_at, + (xmax = 0) as created`, + [ + command.profileRef, + command.schemaVersion, + command.vendor, + command.model, + command.deviceType, + command.protocol, + JSON.stringify(profile), + command.adapterVersionId, + command.schemaArtifactRef, + command.profileDigest, + command.capabilities, + command.lifecycleState, + ], + ); + const row = requireMutationRow( + result, + "device_model_profile_identity_conflict", + ); + await addAudit(client, { + eventType: row.created + ? "model_profile.registered" + : "model_profile.lifecycle_updated", + actorRef: actor.userRef, + payload: { + adapterVersionRef: `adapter-version:${row.adapter_version_id}`, + modelProfileRef: row.profile_ref, + profileDigest: row.profile_digest, + lifecycleState: row.lifecycle_state, + }, + }); + return { + created: row.created === true, + adapterVersion: adapterVersionView(adapterVersion), + modelProfile: modelProfileView(row), + }; +} + +async function ensureEdge(client, actor, command) { + assertPlatformCatalogAuthority(actor); + const result = await client.query( + `insert into device_edges ( + id, + edge_key, + display_name, + deployment_ref, + lifecycle_state, + created_by_ref + ) values ($1, $2, $3, $4, $5, $6) + on conflict (edge_key) do update set + display_name = excluded.display_name, + deployment_ref = excluded.deployment_ref, + lifecycle_state = excluded.lifecycle_state, + updated_at = now() + returning id, edge_key, display_name, deployment_ref, lifecycle_state, + created_at, updated_at, (xmax = 0) as created`, + [ + randomUUID(), + command.edgeKey, + command.displayName, + command.deploymentRef, + command.lifecycleState, + actor.userRef, + ], + ); + const row = requireMutationRow(result, "device_edge_identity_conflict"); + await addAudit(client, { + eventType: row.created ? "edge.created" : "edge.updated", + actorRef: actor.userRef, + payload: { + edgeRef: `edge:${row.id}`, + edgeKey: row.edge_key, + deploymentRef: row.deployment_ref, + lifecycleState: row.lifecycle_state, + }, + }); + return { + created: row.created === true, + edge: edgeView(row), + }; +} + +async function ensureRoute(client, actor, command) { + await assertCurrentProjectCapability( + client, + actor, + command.projectId, + "route.manage", + ); + const edge = await findEdge(client, command.edgeId); + const profile = await findModelProfile(client, command.modelProfileRef); + assertRouteDependencies(command, edge, profile); + + const result = await client.query( + `insert into device_routes ( + id, + project_id, + route_key, + display_name, + edge_id, + model_profile_ref, + listener_ref, + protocol, + direction, + lifecycle_state, + created_by_ref + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + on conflict (project_id, route_key) do update set + display_name = excluded.display_name, + edge_id = excluded.edge_id, + model_profile_ref = excluded.model_profile_ref, + listener_ref = excluded.listener_ref, + protocol = excluded.protocol, + direction = excluded.direction, + lifecycle_state = excluded.lifecycle_state, + updated_at = now() + returning id, project_id, route_key, display_name, edge_id, + model_profile_ref, listener_ref, protocol, direction, lifecycle_state, + created_at, updated_at, (xmax = 0) as created`, + [ + randomUUID(), + command.projectId, + command.routeKey, + command.displayName, + command.edgeId, + command.modelProfileRef, + command.listenerRef, + command.protocol, + command.direction, + command.lifecycleState, + actor.userRef, + ], + ); + const row = requireMutationRow(result, "device_route_identity_conflict"); + await addAudit(client, { + eventType: row.created ? "route.created" : "route.updated", + actorRef: actor.userRef, + projectId: command.projectId, + payload: { + projectRef: toProjectRef(command.projectId), + routeRef: `route:${row.id}`, + routeKey: row.route_key, + edgeRef: `edge:${row.edge_id}`, + modelProfileRef: row.model_profile_ref, + listenerRef: row.listener_ref, + direction: row.direction, + lifecycleState: row.lifecycle_state, + }, + }); + return { + created: row.created === true, + route: routeView(row), + }; +} + +async function ensureEnrollmentIntent(client, actor, command) { + await assertCurrentProjectCapability( + client, + actor, + command.projectId, + "device.enroll", + ); + const route = await findProjectRoute( + client, + command.projectId, + command.routeId, + ); + if (route.lifecycle_state !== "active") { + throw domainError("device_enrollment_route_inactive", 409); + } + if (route.model_profile_ref !== command.modelProfileRef) { + throw domainError("device_enrollment_profile_mismatch", 409); + } + + const result = await client.query( + `insert into device_enrollment_intents ( + id, + project_id, + enrollment_key, + route_id, + model_profile_ref, + display_name, + expected_identifier_kind, + expected_identifier_digest, + expected_identifier_masked, + expires_at, + created_by_ref + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + on conflict (project_id, enrollment_key) do update set + display_name = excluded.display_name, + expires_at = excluded.expires_at, + updated_at = now() + where device_enrollment_intents.route_id = excluded.route_id + and device_enrollment_intents.model_profile_ref = excluded.model_profile_ref + and device_enrollment_intents.expected_identifier_kind = excluded.expected_identifier_kind + and device_enrollment_intents.expected_identifier_digest = excluded.expected_identifier_digest + and device_enrollment_intents.expected_identifier_masked = excluded.expected_identifier_masked + and device_enrollment_intents.lifecycle_state = 'pending' + returning id, project_id, enrollment_key, route_id, model_profile_ref, + display_name, expected_identifier_kind, expected_identifier_masked, + lifecycle_state, expires_at, claimed_device_id, created_at, updated_at, + (xmax = 0) as created`, + [ + randomUUID(), + command.projectId, + command.enrollmentKey, + command.routeId, + command.modelProfileRef, + command.displayName, + command.identifierKind, + command.identifierDigest, + command.identifierMasked, + command.expiresAt, + actor.userRef, + ], + ); + const row = requireMutationRow( + result, + "device_enrollment_intent_identity_conflict", + ); + await addAudit(client, { + eventType: row.created + ? "enrollment_intent.created" + : "enrollment_intent.updated", + actorRef: actor.userRef, + projectId: command.projectId, + payload: { + projectRef: toProjectRef(command.projectId), + enrollmentIntentRef: `enrollment-intent:${row.id}`, + enrollmentKey: row.enrollment_key, + routeRef: `route:${row.route_id}`, + modelProfileRef: row.model_profile_ref, + identifier: { + kind: row.expected_identifier_kind, + masked: row.expected_identifier_masked, + }, + lifecycleState: row.lifecycle_state, + }, + }); + return { + created: row.created === true, + enrollmentIntent: enrollmentIntentView(row), + }; +} + +async function assertCurrentProjectCapability( + client, + actor, + projectId, + capability, +) { + const project = await client.query( + `select p.id, p.lifecycle_state, os.lifecycle_state as owner_lifecycle_state + from device_projects p + join device_owner_scopes os on os.id = p.owner_scope_id + where p.id = $1 + for share of p, os`, + [projectId], + ); + const row = project.rows[0]; + if (!row) throw domainError("device_project_not_found", 404); + if (row.owner_lifecycle_state !== "active") { + throw domainError("device_owner_scope_inactive", 409); + } + if (row.lifecycle_state !== "active") { + throw domainError("device_project_inactive", 409); + } + const grants = await client.query( + `select id, principal_kind, principal_ref, project_role, + capability_allow, capability_deny, lifecycle_state + from device_project_grants + where project_id = $1 + order by created_at, id + for share`, + [projectId], + ); + assertProjectCapability( + actor, + grants.rows.map((grant) => ({ + grantRef: `grant:${grant.id}`, + principalKind: grant.principal_kind, + principalRef: grant.principal_ref, + projectRole: grant.project_role, + capabilityAllow: grant.capability_allow ?? [], + capabilityDeny: grant.capability_deny ?? [], + lifecycleState: grant.lifecycle_state, + })), + capability, + ); +} + +async function findAdapterPackage(client, adapterPackageId) { + const result = await client.query( + `select id, package_key, display_name, publisher_ref, lifecycle_state, + created_at, updated_at + from device_adapter_packages + where id = $1 + for share`, + [adapterPackageId], + ); + if (!result.rows[0]) { + throw domainError("device_adapter_package_not_found", 404); + } + return result.rows[0]; +} + +async function findAdapterVersion(client, adapterVersionId) { + const result = await client.query( + `select av.id, av.adapter_package_id, av.version, + av.runtime_package_ref, av.content_digest, av.contract_version, + av.capabilities, av.lifecycle_state, av.created_at, av.updated_at, + ap.lifecycle_state as package_lifecycle_state + from device_adapter_versions av + join device_adapter_packages ap on ap.id = av.adapter_package_id + where av.id = $1 + for share of av, ap`, + [adapterVersionId], + ); + if (!result.rows[0]) { + throw domainError("device_adapter_version_not_found", 404); + } + return result.rows[0]; +} + +async function findEdge(client, edgeId) { + const result = await client.query( + `select id, edge_key, display_name, deployment_ref, lifecycle_state, + created_at, updated_at + from device_edges + where id = $1 + for share`, + [edgeId], + ); + if (!result.rows[0]) throw domainError("device_edge_not_found", 404); + return result.rows[0]; +} + +async function findModelProfile(client, profileRef) { + const result = await client.query( + `select mp.profile_ref, mp.schema_version, mp.vendor, mp.model, + mp.device_type, mp.protocol, mp.adapter_version_id, + mp.schema_artifact_ref, mp.profile_digest, mp.capabilities, + mp.lifecycle_state, mp.created_at, mp.updated_at, + av.lifecycle_state as adapter_lifecycle_state, + ap.lifecycle_state as package_lifecycle_state + from device_model_profiles mp + left join device_adapter_versions av on av.id = mp.adapter_version_id + left join device_adapter_packages ap on ap.id = av.adapter_package_id + where mp.profile_ref = $1 + for share of mp`, + [profileRef], + ); + if (!result.rows[0]) { + throw domainError("device_model_profile_not_found", 404); + } + return result.rows[0]; +} + +async function findProjectRoute(client, projectId, routeId) { + const result = await client.query( + `select id, project_id, route_key, display_name, edge_id, + model_profile_ref, listener_ref, protocol, direction, lifecycle_state, + created_at, updated_at + from device_routes + where id = $1 and project_id = $2 + for share`, + [routeId, projectId], + ); + if (!result.rows[0]) throw domainError("device_route_not_found", 404); + return result.rows[0]; +} + +function assertRouteDependencies(command, edge, profile) { + if (profile.adapter_version_id == null) { + throw domainError("device_model_profile_unregistered", 409); + } + if (profile.protocol !== command.protocol) { + throw domainError("device_route_protocol_mismatch", 409); + } + if ( + edge.lifecycle_state === "retired" + || profile.lifecycle_state === "retired" + || profile.adapter_lifecycle_state === "retired" + || profile.package_lifecycle_state === "retired" + ) { + throw domainError("device_route_dependency_inactive", 409); + } + if ( + command.lifecycleState === "active" + && ( + edge.lifecycle_state !== "active" + || profile.lifecycle_state !== "active" + || profile.adapter_lifecycle_state !== "active" + || profile.package_lifecycle_state !== "active" + ) + ) { + throw domainError("device_route_dependency_not_active", 409); + } +} + +async function addAudit(client, { + eventType, + actorRef, + projectId = null, + payload, +}) { + await client.query( + `insert into device_audit_events ( + id, + event_type, + actor_ref, + project_id, + payload + ) values ($1, $2, $3, $4, $5::jsonb)`, + [randomUUID(), eventType, actorRef, projectId, JSON.stringify(payload)], + ); +} + +function requireMutationRow(result, code) { + if (!result.rows[0]) throw domainError(code, 409); + return result.rows[0]; +} + +function adapterPackageView(row) { + return { + adapterPackageRef: `adapter-package:${row.id}`, + packageKey: row.package_key, + displayName: row.display_name, + publisherRef: row.publisher_ref, + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function adapterVersionView(row) { + return { + adapterVersionRef: `adapter-version:${row.id}`, + adapterPackageRef: `adapter-package:${row.adapter_package_id}`, + version: row.version, + runtimePackageRef: row.runtime_package_ref, + contentDigest: row.content_digest, + contractVersion: row.contract_version, + capabilities: [...(row.capabilities ?? [])].sort(), + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function modelProfileView(row) { + return { + modelProfileRef: row.profile_ref, + adapterVersionRef: `adapter-version:${row.adapter_version_id}`, + schemaVersion: row.schema_version, + vendor: row.vendor, + model: row.model, + deviceType: row.device_type, + protocol: row.protocol, + schemaArtifactRef: row.schema_artifact_ref, + profileDigest: row.profile_digest, + capabilities: [...(row.capabilities ?? [])].sort(), + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function edgeView(row) { + return { + edgeRef: `edge:${row.id}`, + edgeKey: row.edge_key, + displayName: row.display_name, + deploymentRef: row.deployment_ref ?? null, + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function routeView(row) { + return { + routeRef: `route:${row.id}`, + projectRef: toProjectRef(row.project_id), + routeKey: row.route_key, + displayName: row.display_name, + edgeRef: `edge:${row.edge_id}`, + modelProfileRef: row.model_profile_ref, + listenerRef: row.listener_ref, + protocol: row.protocol, + direction: row.direction, + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function enrollmentIntentView(row) { + return { + enrollmentIntentRef: `enrollment-intent:${row.id}`, + projectRef: toProjectRef(row.project_id), + enrollmentKey: row.enrollment_key, + routeRef: `route:${row.route_id}`, + modelProfileRef: row.model_profile_ref, + displayName: row.display_name, + identifier: { + kind: row.expected_identifier_kind, + masked: row.expected_identifier_masked, + }, + lifecycleState: row.lifecycle_state, + expiresAt: toIso(row.expires_at), + claimedDeviceRef: row.claimed_device_id + ? `device:${row.claimed_device_id}` + : null, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function toIso(value) { + return value == null ? null : new Date(value).toISOString(); +} + +function domainError(code, statusCode) { + const error = new Error(code); + error.statusCode = statusCode; + return error; +} diff --git a/device-plane/services/device-control-core/src/management-command.mjs b/device-plane/services/device-control-core/src/management-command.mjs new file mode 100644 index 0000000..5c89c84 --- /dev/null +++ b/device-plane/services/device-control-core/src/management-command.mjs @@ -0,0 +1,21 @@ +import { + DEVICE_INFRASTRUCTURE_COMMAND_KINDS, + isInfrastructureManagementCommand, + normalizeInfrastructureManagementCommand, +} from "./infrastructure-management.mjs"; +import { + DEVICE_MANAGEMENT_COMMAND_KINDS, + normalizeManagementCommand, +} from "./project-management.mjs"; + +export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([ + ...DEVICE_MANAGEMENT_COMMAND_KINDS, + ...DEVICE_INFRASTRUCTURE_COMMAND_KINDS, +]); + +export function normalizeDeviceManagementCommand(kind, input) { + if (isInfrastructureManagementCommand(kind)) { + return normalizeInfrastructureManagementCommand(kind, input); + } + return normalizeManagementCommand(kind, input); +} diff --git a/device-plane/services/device-control-core/src/postgres-repository.mjs b/device-plane/services/device-control-core/src/postgres-repository.mjs index 62af81d..05f21e9 100644 --- a/device-plane/services/device-control-core/src/postgres-repository.mjs +++ b/device-plane/services/device-control-core/src/postgres-repository.mjs @@ -6,6 +6,11 @@ import { fileURLToPath } from "node:url"; import pg from "pg"; import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs"; +import { + applyInfrastructureManagementCommand, + authorizeInfrastructureManagementReplay, +} from "./infrastructure-repository.mjs"; +import { isInfrastructureManagementCommand } from "./infrastructure-management.mjs"; import { assertActorCanManageOwnerScope, assertGrantMutationAllowed, @@ -20,6 +25,7 @@ const migrationFiles = [ "002_device_project_access.sql", "003_device_management_commands.sql", "004_device_registry_foundation.sql", + "005_device_registry_commands.sql", ]; export class PostgresDeviceRepository { @@ -252,6 +258,13 @@ async function completeManagementReceipt(client, receiptId, result) { } async function applyManagementCommand(client, { commandKind, actor, command }) { + if (isInfrastructureManagementCommand(commandKind)) { + return applyInfrastructureManagementCommand(client, { + commandKind, + actor, + command, + }); + } if (commandKind === "owner_scope.ensure") { return ensureOwnerScope(client, actor, command); } @@ -268,6 +281,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) { } async function authorizeManagementReplay(client, { commandKind, actor, command }) { + if (isInfrastructureManagementCommand(commandKind)) { + return authorizeInfrastructureManagementReplay(client, { + commandKind, + actor, + command, + }); + } if (commandKind === "owner_scope.ensure") { assertActorCanManageOwnerScope(actor, command); const ownerScope = await findOwnerScope( diff --git a/device-plane/services/device-control-core/test/device-registry-command-migration.test.mjs b/device-plane/services/device-control-core/test/device-registry-command-migration.test.mjs new file mode 100644 index 0000000..ab9ecd9 --- /dev/null +++ b/device-plane/services/device-control-core/test/device-registry-command-migration.test.mjs @@ -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); +}); diff --git a/device-plane/services/device-control-core/test/infrastructure-app.test.mjs b/device-plane/services/device-control-core/test/infrastructure-app.test.mjs new file mode 100644 index 0000000..8ee44a9 --- /dev/null +++ b/device-plane/services/device-control-core/test/infrastructure-app.test.mjs @@ -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", + }; +} diff --git a/device-plane/services/device-control-core/test/infrastructure-management.test.mjs b/device-plane/services/device-control-core/test/infrastructure-management.test.mjs new file mode 100644 index 0000000..da3161e --- /dev/null +++ b/device-plane/services/device-control-core/test/infrastructure-management.test.mjs @@ -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: [], + }); +} diff --git a/device-plane/services/device-control-core/test/infrastructure-repository.test.mjs b/device-plane/services/device-control-core/test/infrastructure-repository.test.mjs new file mode 100644 index 0000000..aa3c08f --- /dev/null +++ b/device-plane/services/device-control-core/test/infrastructure-repository.test.mjs @@ -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, "\\$&"); +}