feat(device-core): add registry management commands
This commit is contained in:
@@ -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;
|
||||||
@@ -9,14 +9,20 @@ import {
|
|||||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||||
import {
|
import {
|
||||||
normalizeManagementActor,
|
normalizeManagementActor,
|
||||||
normalizeManagementCommand,
|
|
||||||
} from "./project-management.mjs";
|
} from "./project-management.mjs";
|
||||||
|
import { normalizeDeviceManagementCommand } from "./management-command.mjs";
|
||||||
|
|
||||||
const managementRoutes = new Map([
|
const managementRoutes = new Map([
|
||||||
["/internal/v1/management/owner-scopes:ensure", "owner_scope.ensure"],
|
["/internal/v1/management/owner-scopes:ensure", "owner_scope.ensure"],
|
||||||
["/internal/v1/management/projects:ensure", "project.ensure"],
|
["/internal/v1/management/projects:ensure", "project.ensure"],
|
||||||
["/internal/v1/management/collections:ensure", "collection.ensure"],
|
["/internal/v1/management/collections:ensure", "collection.ensure"],
|
||||||
["/internal/v1/management/project-grants:upsert", "project_grant.upsert"],
|
["/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({
|
export function createControlCoreApp({
|
||||||
@@ -93,7 +99,10 @@ export function createControlCoreApp({
|
|||||||
);
|
);
|
||||||
const actor = managementActorFromHeaders(request.headers);
|
const actor = managementActorFromHeaders(request.headers);
|
||||||
const input = await readJsonBody(request, 64 * 1024);
|
const input = await readJsonBody(request, 64 * 1024);
|
||||||
const command = normalizeManagementCommand(managementCommandKind, input);
|
const command = normalizeDeviceManagementCommand(
|
||||||
|
managementCommandKind,
|
||||||
|
input,
|
||||||
|
);
|
||||||
const requestDigest = managementRequestDigest({
|
const requestDigest = managementRequestDigest({
|
||||||
actor,
|
actor,
|
||||||
commandKind: managementCommandKind,
|
commandKind: managementCommandKind,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ import { fileURLToPath } from "node:url";
|
|||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
|
|
||||||
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
|
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 {
|
import {
|
||||||
assertActorCanManageOwnerScope,
|
assertActorCanManageOwnerScope,
|
||||||
assertGrantMutationAllowed,
|
assertGrantMutationAllowed,
|
||||||
@@ -20,6 +25,7 @@ const migrationFiles = [
|
|||||||
"002_device_project_access.sql",
|
"002_device_project_access.sql",
|
||||||
"003_device_management_commands.sql",
|
"003_device_management_commands.sql",
|
||||||
"004_device_registry_foundation.sql",
|
"004_device_registry_foundation.sql",
|
||||||
|
"005_device_registry_commands.sql",
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PostgresDeviceRepository {
|
export class PostgresDeviceRepository {
|
||||||
@@ -252,6 +258,13 @@ async function completeManagementReceipt(client, receiptId, result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||||
|
if (isInfrastructureManagementCommand(commandKind)) {
|
||||||
|
return applyInfrastructureManagementCommand(client, {
|
||||||
|
commandKind,
|
||||||
|
actor,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (commandKind === "owner_scope.ensure") {
|
if (commandKind === "owner_scope.ensure") {
|
||||||
return ensureOwnerScope(client, actor, command);
|
return ensureOwnerScope(client, actor, command);
|
||||||
}
|
}
|
||||||
@@ -268,6 +281,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function authorizeManagementReplay(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") {
|
if (commandKind === "owner_scope.ensure") {
|
||||||
assertActorCanManageOwnerScope(actor, command);
|
assertActorCanManageOwnerScope(actor, command);
|
||||||
const ownerScope = await findOwnerScope(
|
const ownerScope = await findOwnerScope(
|
||||||
|
|||||||
+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