feat(core): add ontology-backed asset and host runtime

This commit is contained in:
DCCONSTRUCTIONS
2026-08-22 14:47:44 +03:00
parent 6ed4414a97
commit b302b6ba1a
13 changed files with 1959 additions and 89 deletions
@@ -0,0 +1,284 @@
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',
'device.claim',
'device.update',
'device.transfer',
'discovery.reject',
'discovery.expire',
'device_credential_binding.upsert',
'device_credential_binding.revoke',
'device_binding.ensure',
'device_binding.revoke',
'device_configuration_revision.create',
'device_configuration_desired.set',
'asset.ensure',
'asset_binding.ensure',
'asset_binding.close',
'infrastructure_host.ensure',
'infrastructure_endpoint.ensure',
'infrastructure_deployment.ensure',
'infrastructure_service_instance.ensure',
'health_observation.record'
));
create table if not exists device_assets (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
asset_key text not null
check (asset_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
asset_type_ref text not null
check (length(btrim(asset_type_ref)) between 3 and 256),
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'retired')),
ontology_entity_id text not null default 'asset.asset'
check (ontology_entity_id = 'asset.asset'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, asset_key),
unique (id, project_id, owner_scope_id),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id)
);
create index if not exists device_assets_project_state_idx
on device_assets (project_id, lifecycle_state, updated_at desc);
create table if not exists device_asset_bindings (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
binding_key text not null
check (binding_key ~ '^[a-z][a-z0-9-]{1,62}$'),
device_id uuid not null,
asset_id uuid not null,
binding_kind text not null default 'tracking'
check (binding_kind in ('tracking', 'installed', 'assigned')),
valid_from timestamptz not null,
valid_to timestamptz,
provenance_ref text not null
check (length(btrim(provenance_ref)) between 3 and 256),
ontology_entity_id text not null default 'device.asset_binding'
check (ontology_entity_id = 'device.asset_binding'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
closed_by_ref text
check (closed_by_ref is null or length(btrim(closed_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, binding_key),
foreign key (device_id, project_id, owner_scope_id)
references device_instances(id, project_id, owner_scope_id),
foreign key (asset_id, project_id, owner_scope_id)
references device_assets(id, project_id, owner_scope_id),
check (valid_to is null or valid_to > valid_from),
check ((valid_to is null and closed_by_ref is null) or (valid_to is not null and closed_by_ref is not null))
);
create unique index if not exists device_asset_bindings_active_device_idx
on device_asset_bindings (device_id)
where valid_to is null;
create index if not exists device_asset_bindings_asset_time_idx
on device_asset_bindings (asset_id, valid_from desc, valid_to);
create table if not exists device_infrastructure_hosts (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_key text not null
check (host_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
provider_ref text
check (provider_ref is null or length(btrim(provider_ref)) between 3 and 256),
external_ref text
check (external_ref is null or length(btrim(external_ref)) between 3 and 256),
management_credential_ref text
check (
management_credential_ref is null
or management_credential_ref ~ '^secret-ref:[A-Za-z0-9][A-Za-z0-9._:/+-]{2,244}$'
),
lifecycle_state text not null default 'provisioning'
check (lifecycle_state in ('provisioning', 'active', 'suspended', 'retired')),
ontology_entity_id text not null default 'infrastructure.host'
check (ontology_entity_id = 'infrastructure.host'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, host_key),
unique (id, project_id, owner_scope_id),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id)
);
create index if not exists device_infrastructure_hosts_project_state_idx
on device_infrastructure_hosts (project_id, lifecycle_state, updated_at desc);
create table if not exists device_infrastructure_endpoints (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid not null,
endpoint_key text not null
check (endpoint_key ~ '^[a-z][a-z0-9-]{1,62}$'),
purpose text not null
check (purpose in ('management', 'service', 'monitoring')),
endpoint_uri text not null
check (
length(btrim(endpoint_uri)) between 8 and 512
and endpoint_uri !~ '@'
),
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'disabled', 'retired')),
ontology_entity_id text not null default 'infrastructure.endpoint'
check (ontology_entity_id = 'infrastructure.endpoint'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (host_id, endpoint_key),
unique (id, project_id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id)
);
create table if not exists device_infrastructure_deployments (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid not null,
deployment_key text not null
check (deployment_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
artifact_ref text not null
check (length(btrim(artifact_ref)) between 3 and 256),
artifact_digest text not null
check (artifact_digest ~ '^sha256:[a-f0-9]{64}$'),
lifecycle_state text not null default 'desired'
check (lifecycle_state in ('desired', 'applying', 'active', 'failed', 'retired')),
ontology_entity_id text not null default 'infrastructure.deployment'
check (ontology_entity_id = 'infrastructure.deployment'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, deployment_key),
unique (id, project_id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id)
);
create table if not exists device_infrastructure_service_instances (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid not null,
deployment_id uuid not null,
edge_id uuid references device_edges(id),
service_key text not null
check (service_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
service_role text not null
check (service_role ~ '^[a-z][a-z0-9._-]{1,63}$'),
lifecycle_state text not null default 'provisioning'
check (lifecycle_state in ('provisioning', 'active', 'degraded', 'stopped', 'retired')),
ontology_entity_id text not null default 'infrastructure.service_instance'
check (ontology_entity_id = 'infrastructure.service_instance'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (host_id, service_key),
unique (id, project_id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id),
foreign key (deployment_id, project_id, owner_scope_id)
references device_infrastructure_deployments(id, project_id, owner_scope_id)
);
create table if not exists device_health_observations (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid,
service_instance_id uuid,
observed_state text not null
check (observed_state in ('reachable', 'degraded', 'unreachable')),
evidence_class text not null
check (evidence_class in ('agent_probe', 'channel', 'management_probe', 'manual')),
source_ref text not null
check (length(btrim(source_ref)) between 3 and 256),
schema_ref text not null
check (length(btrim(schema_ref)) between 3 and 256),
evidence_projection jsonb not null default '{}'::jsonb
check (
jsonb_typeof(evidence_projection) = 'object'
and octet_length(evidence_projection::text) <= 16384
),
observed_at timestamptz not null,
expires_at timestamptz not null,
ontology_entity_id text not null default 'observation.health_observation'
check (ontology_entity_id = 'observation.health_observation'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
recorded_by_ref text not null
check (length(btrim(recorded_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id),
foreign key (service_instance_id, project_id, owner_scope_id)
references device_infrastructure_service_instances(id, project_id, owner_scope_id),
check (
(host_id is not null and service_instance_id is null)
or (host_id is null and service_instance_id is not null)
),
check (expires_at > observed_at)
);
create index if not exists device_health_observations_host_time_idx
on device_health_observations (host_id, observed_at desc)
where host_id is not null;
create index if not exists device_health_observations_service_time_idx
on device_health_observations (service_instance_id, observed_at desc)
where service_instance_id is not null;
commit;
+58
View File
@@ -46,6 +46,29 @@ const managementRoutes = new Map([
"/internal/v1/management/device-configurations:set-desired",
"device_configuration_desired.set",
],
["/internal/v1/management/assets:ensure", "asset.ensure"],
["/internal/v1/management/asset-bindings:ensure", "asset_binding.ensure"],
["/internal/v1/management/asset-bindings:close", "asset_binding.close"],
[
"/internal/v1/management/infrastructure-hosts:ensure",
"infrastructure_host.ensure",
],
[
"/internal/v1/management/infrastructure-endpoints:ensure",
"infrastructure_endpoint.ensure",
],
[
"/internal/v1/management/infrastructure-deployments:ensure",
"infrastructure_deployment.ensure",
],
[
"/internal/v1/management/infrastructure-service-instances:ensure",
"infrastructure_service_instance.ensure",
],
[
"/internal/v1/management/health-observations:record",
"health_observation.record",
],
]);
export function createControlCoreApp({
@@ -295,6 +318,34 @@ export function createControlCoreApp({
return writeJson(response, 200, { ok: true, workspace });
}
const ontologyProjectId = projectOntologyId(requestUrl.pathname);
if (request.method === "GET" && ontologyProjectId) {
if (!managementApiEnabled) {
return writeJson(response, 404, {
ok: false,
error: "device_management_api_disabled",
});
}
if (!matchesBearer(request.headers.authorization, managementToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_management_auth_required",
});
}
if (typeof repository.getProjectOntologyProjection !== "function") {
return writeJson(response, 503, {
ok: false,
error: "device_query_repository_unavailable",
});
}
const actor = managementActorFromHeaders(request.headers);
const projection = await repository.getProjectOntologyProjection(
actor,
ontologyProjectId,
);
return writeJson(response, 200, { ok: true, projection });
}
if (
request.method === "POST"
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
@@ -408,6 +459,13 @@ function projectWorkspaceId(pathname) {
return match?.[1]?.toLowerCase() ?? null;
}
function projectOntologyId(pathname) {
const match = pathname.match(
/^\/internal\/v1\/query\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/ontology$/i,
);
return match?.[1]?.toLowerCase() ?? null;
}
function managementActorFromHeaders(headers) {
return normalizeManagementActor({
userRef: singleHeader(headers["x-nodedc-user-ref"]),
@@ -17,6 +17,11 @@ import {
DEVICE_MANAGEMENT_COMMAND_KINDS,
normalizeManagementCommand,
} from "./project-management.mjs";
import {
DEVICE_ONTOLOGY_COMMAND_KINDS,
isOntologyManagementCommand,
normalizeOntologyManagementCommand,
} from "./ontology-management.mjs";
import {
DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
isSensitiveReferenceManagementCommand,
@@ -29,9 +34,13 @@ export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
...DEVICE_LIFECYCLE_COMMAND_KINDS,
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
...DEVICE_ONTOLOGY_COMMAND_KINDS,
]);
export function normalizeDeviceManagementCommand(kind, input) {
if (isOntologyManagementCommand(kind)) {
return normalizeOntologyManagementCommand(kind, input);
}
if (isControlResourceManagementCommand(kind)) {
return normalizeControlResourceManagementCommand(kind, input);
}
@@ -0,0 +1,319 @@
import {
assertSafeProjection,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export const DEVICE_ONTOLOGY_COMMAND_KINDS = Object.freeze([
"asset.ensure",
"asset_binding.ensure",
"asset_binding.close",
"infrastructure_host.ensure",
"infrastructure_endpoint.ensure",
"infrastructure_deployment.ensure",
"infrastructure_service_instance.ensure",
"health_observation.record",
]);
export const DEVICE_ONTOLOGY_CATALOG_HASH = "229c61c02a790906";
const commandKindSet = new Set(DEVICE_ONTOLOGY_COMMAND_KINDS);
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
const secretRefPattern = /^secret-ref:[A-Za-z0-9][A-Za-z0-9._:/+-]{2,244}$/;
const digestPattern = /^sha256:[a-f0-9]{64}$/;
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
export function isOntologyManagementCommand(kind) {
return commandKindSet.has(kind);
}
export function normalizeOntologyManagementCommand(kind, input) {
if (!commandKindSet.has(kind)) {
throw new TypeError("device_ontology_command_kind_invalid");
}
assertPlainObject(input, "device_ontology_command_invalid");
if (kind === "asset.ensure") {
assertAllowedKeys(input, [
"projectRef", "assetKey", "displayName", "assetTypeRef", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
assetKey: normalizeKey(input.assetKey, "device_asset_key_invalid"),
displayName: normalizeText(input.displayName, 160, "device_asset_name_invalid"),
assetTypeRef: normalizeOpaqueRef(input.assetTypeRef, "device_asset_type_ref_invalid"),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "active",
new Set(["active", "retired"]),
"device_asset_state_invalid",
),
});
}
if (kind === "asset_binding.ensure") {
assertAllowedKeys(input, [
"projectRef", "bindingKey", "deviceRef", "assetRef", "bindingKind",
"validFrom", "provenanceRef",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
bindingKey: normalizeKey(input.bindingKey, "device_asset_binding_key_invalid"),
deviceId: normalizeEntityRef(input.deviceRef, "device"),
assetId: normalizeEntityRef(input.assetRef, "asset"),
bindingKind: normalizeEnum(
input.bindingKind ?? "tracking",
new Set(["tracking", "installed", "assigned"]),
"device_asset_binding_kind_invalid",
),
validFrom: normalizeTimestamp(input.validFrom, "device_asset_binding_valid_from_invalid"),
provenanceRef: normalizeOpaqueRef(
input.provenanceRef,
"device_asset_binding_provenance_ref_invalid",
),
});
}
if (kind === "asset_binding.close") {
assertAllowedKeys(input, ["projectRef", "assetBindingRef", "validTo"]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
assetBindingId: normalizeEntityRef(input.assetBindingRef, "asset-binding"),
validTo: normalizeTimestamp(input.validTo, "device_asset_binding_valid_to_invalid"),
});
}
if (kind === "infrastructure_host.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostKey", "displayName", "providerRef", "externalRef",
"managementCredentialRef", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostKey: normalizeKey(input.hostKey, "device_host_key_invalid"),
displayName: normalizeText(input.displayName, 160, "device_host_name_invalid"),
providerRef: normalizeOptionalOpaqueRef(input.providerRef, "device_host_provider_ref_invalid"),
externalRef: normalizeOptionalOpaqueRef(input.externalRef, "device_host_external_ref_invalid"),
managementCredentialRef: normalizeOptionalPattern(
input.managementCredentialRef,
secretRefPattern,
"device_host_management_credential_ref_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "provisioning",
new Set(["provisioning", "active", "suspended", "retired"]),
"device_host_state_invalid",
),
});
}
if (kind === "infrastructure_endpoint.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostRef", "endpointKey", "purpose", "endpointUri",
"lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostId: normalizeEntityRef(input.hostRef, "host"),
endpointKey: normalizeKey(input.endpointKey, "device_endpoint_key_invalid"),
purpose: normalizeEnum(
input.purpose,
new Set(["management", "service", "monitoring"]),
"device_endpoint_purpose_invalid",
),
endpointUri: normalizeEndpointUri(input.endpointUri),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "active",
new Set(["active", "disabled", "retired"]),
"device_endpoint_state_invalid",
),
});
}
if (kind === "infrastructure_deployment.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostRef", "deploymentKey", "displayName", "artifactRef",
"artifactDigest", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostId: normalizeEntityRef(input.hostRef, "host"),
deploymentKey: normalizeKey(input.deploymentKey, "device_deployment_key_invalid"),
displayName: normalizeText(
input.displayName,
160,
"device_deployment_name_invalid",
),
artifactRef: normalizeOpaqueRef(input.artifactRef, "device_deployment_artifact_ref_invalid"),
artifactDigest: normalizePattern(
input.artifactDigest,
digestPattern,
"device_deployment_artifact_digest_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "desired",
new Set(["desired", "applying", "active", "failed", "retired"]),
"device_deployment_state_invalid",
),
});
}
if (kind === "infrastructure_service_instance.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostRef", "deploymentRef", "edgeRef", "serviceKey",
"displayName", "serviceRole", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostId: normalizeEntityRef(input.hostRef, "host"),
deploymentId: normalizeEntityRef(input.deploymentRef, "deployment"),
edgeId: input.edgeRef == null ? null : normalizeEntityRef(input.edgeRef, "edge"),
serviceKey: normalizeKey(input.serviceKey, "device_service_instance_key_invalid"),
displayName: normalizeText(
input.displayName,
160,
"device_service_instance_name_invalid",
),
serviceRole: normalizePattern(
input.serviceRole,
/^[a-z][a-z0-9._-]{1,63}$/,
"device_service_instance_role_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "provisioning",
new Set(["provisioning", "active", "degraded", "stopped", "retired"]),
"device_service_instance_state_invalid",
),
});
}
assertAllowedKeys(input, [
"projectRef", "subjectKind", "subjectRef", "observedState", "evidenceClass",
"sourceRef", "schemaRef", "evidence", "observedAt", "expiresAt",
]);
const subjectKind = normalizeEnum(
input.subjectKind,
new Set(["host", "service-instance"]),
"device_health_subject_kind_invalid",
);
const evidence = structuredClone(assertSafeProjection(input.evidence ?? {}));
if (Buffer.byteLength(JSON.stringify(evidence), "utf8") > 16 * 1024) {
throw new TypeError("device_health_evidence_too_large");
}
const observedAt = normalizeTimestamp(
input.observedAt,
"device_health_observed_at_invalid",
);
const expiresAt = normalizeTimestamp(
input.expiresAt,
"device_health_expires_at_invalid",
);
if (Date.parse(expiresAt) <= Date.parse(observedAt)) {
throw new TypeError("device_health_freshness_window_invalid");
}
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
subjectKind,
subjectId: normalizeEntityRef(input.subjectRef, subjectKind),
observedState: normalizeEnum(
input.observedState,
new Set(["reachable", "degraded", "unreachable"]),
"device_health_observed_state_invalid",
),
evidenceClass: normalizeEnum(
input.evidenceClass,
new Set(["agent_probe", "channel", "management_probe", "manual"]),
"device_health_evidence_class_invalid",
),
sourceRef: normalizeOpaqueRef(input.sourceRef, "device_health_source_ref_invalid"),
schemaRef: normalizeOpaqueRef(input.schemaRef, "device_health_schema_ref_invalid"),
evidence: Object.freeze(evidence),
observedAt,
expiresAt,
});
}
function normalizeEndpointUri(value) {
if (typeof value !== "string" || value.length > 512) {
throw new TypeError("device_endpoint_uri_invalid");
}
let parsed;
try {
parsed = new URL(value);
} catch {
throw new TypeError("device_endpoint_uri_invalid");
}
if (
!new Set(["https:", "ssh:", "tcp:"]).has(parsed.protocol)
|| !parsed.hostname
|| parsed.username
|| parsed.password
|| parsed.search
|| parsed.hash
) {
throw new TypeError("device_endpoint_uri_invalid");
}
return parsed.toString();
}
function normalizeEntityRef(value, kind) {
if (typeof value !== "string") throw new TypeError(`device_${kind}_ref_invalid`);
const match = value.match(new RegExp(`^${kind}:(${uuidPattern.source})$`, "i"));
if (!match) throw new TypeError(`device_${kind}_ref_invalid`);
return match[1].toLowerCase();
}
function normalizeKey(value, errorCode) {
return normalizePattern(value, keyPattern, errorCode);
}
function normalizeOpaqueRef(value, errorCode) {
return normalizePattern(value, opaqueRefPattern, errorCode);
}
function normalizeOptionalOpaqueRef(value, errorCode) {
return value == null || value === "" ? null : normalizeOpaqueRef(value, errorCode);
}
function normalizeOptionalPattern(value, pattern, errorCode) {
return value == null || value === "" ? null : normalizePattern(value, pattern, errorCode);
}
function normalizePattern(value, pattern, errorCode) {
if (typeof value !== "string" || !pattern.test(value)) throw new TypeError(errorCode);
return value;
}
function normalizeText(value, maximum, errorCode) {
if (typeof value !== "string") throw new TypeError(errorCode);
const normalized = value.trim();
if (normalized.length < 1 || normalized.length > maximum) throw new TypeError(errorCode);
return normalized;
}
function normalizeEnum(value, allowed, errorCode) {
if (typeof value !== "string" || !allowed.has(value)) throw new TypeError(errorCode);
return value;
}
function normalizeTimestamp(value, errorCode) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(value)) {
throw new TypeError(errorCode);
}
const timestamp = new Date(value);
if (!Number.isFinite(timestamp.valueOf())) throw new TypeError(errorCode);
return timestamp.toISOString();
}
function assertPlainObject(value, errorCode) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(errorCode);
}
}
function assertAllowedKeys(value, allowedKeys) {
const allowed = new Set(allowedKeys);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) {
throw new TypeError(`device_ontology_command_field_unexpected:${key}`);
}
}
}
@@ -0,0 +1,213 @@
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
export async function getDeviceProjectOntologyProjection(client, actor, projectId) {
await findProjectWithCapability(
client,
actor,
projectId,
"infrastructure.read",
{ lock: false },
);
const [assets, assetBindings, hosts, endpoints, deployments, services] =
await Promise.all([
client.query(
`select * from device_assets
where project_id = $1
order by display_name, id`,
[projectId],
),
client.query(
`select dab.*, di.display_name as device_name,
da.display_name as asset_name
from device_asset_bindings dab
join device_instances di on di.id = dab.device_id
join device_assets da on da.id = dab.asset_id
where dab.project_id = $1
order by dab.valid_from desc, dab.id`,
[projectId],
),
client.query(
`select dih.id, dih.project_id, dih.host_key, dih.display_name,
dih.provider_ref, dih.external_ref, dih.lifecycle_state,
dih.ontology_entity_id, dih.ontology_catalog_hash,
(dih.management_credential_ref is not null) as management_credential_configured,
ho.id as health_observation_id,
ho.observed_state as health_observed_state,
ho.evidence_class as health_evidence_class,
ho.observed_at as health_observed_at,
ho.expires_at as health_expires_at
from device_infrastructure_hosts dih
left join lateral (
select dho.id, dho.observed_state, dho.evidence_class,
dho.observed_at, dho.expires_at
from device_health_observations dho
where dho.host_id = dih.id
order by dho.observed_at desc, dho.id desc
limit 1
) ho on true
where dih.project_id = $1
order by dih.display_name, dih.id`,
[projectId],
),
client.query(
`select * from device_infrastructure_endpoints
where project_id = $1
order by host_id, endpoint_key, id`,
[projectId],
),
client.query(
`select * from device_infrastructure_deployments
where project_id = $1
order by updated_at desc, id`,
[projectId],
),
client.query(
`select disi.*,
ho.id as health_observation_id,
ho.observed_state as health_observed_state,
ho.evidence_class as health_evidence_class,
ho.observed_at as health_observed_at,
ho.expires_at as health_expires_at
from device_infrastructure_service_instances disi
left join lateral (
select dho.id, dho.observed_state, dho.evidence_class,
dho.observed_at, dho.expires_at
from device_health_observations dho
where dho.service_instance_id = disi.id
order by dho.observed_at desc, dho.id desc
limit 1
) ho on true
where disi.project_id = $1
order by disi.display_name, disi.id`,
[projectId],
),
]);
return {
ontology: {
catalogHash: "229c61c02a790906",
packages: ["asset", "device", "infrastructure", "observation"],
},
assets: assets.rows.map(assetView),
assetBindings: assetBindings.rows.map(assetBindingView),
hosts: hosts.rows.map(hostView),
endpoints: endpoints.rows.map(endpointView),
deployments: deployments.rows.map(deploymentView),
serviceInstances: services.rows.map(serviceInstanceView),
policies: {
restrictedIdentifiers: "masked-only",
managementCredentials: "opaque-reference-only",
missingHealthEvidence: "unobserved-not-unhealthy",
arbitraryConsole: "disabled",
},
};
}
function assetView(row) {
return {
assetRef: `asset:${row.id}`,
assetKey: row.asset_key,
displayName: row.display_name,
assetTypeRef: row.asset_type_ref,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
};
}
function assetBindingView(row) {
return {
assetBindingRef: `asset-binding:${row.id}`,
bindingKey: row.binding_key,
deviceRef: `device:${row.device_id}`,
deviceName: row.device_name,
assetRef: `asset:${row.asset_id}`,
assetName: row.asset_name,
bindingKind: row.binding_kind,
validFrom: toIso(row.valid_from),
validTo: toIso(row.valid_to),
provenanceRef: row.provenance_ref,
ontology: ontologyView(row),
};
}
function hostView(row) {
return {
hostRef: `host:${row.id}`,
hostKey: row.host_key,
displayName: row.display_name,
providerRef: row.provider_ref ?? null,
externalRef: row.external_ref ?? null,
managementCredentialConfigured: row.management_credential_configured === true,
lifecycleState: row.lifecycle_state,
health: healthView(row),
ontology: ontologyView(row),
};
}
function endpointView(row) {
return {
endpointRef: `endpoint:${row.id}`,
hostRef: `host:${row.host_id}`,
endpointKey: row.endpoint_key,
purpose: row.purpose,
endpointUri: row.endpoint_uri,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
};
}
function deploymentView(row) {
return {
deploymentRef: `deployment:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentKey: row.deployment_key,
displayName: row.display_name,
artifactRef: row.artifact_ref,
artifactDigest: row.artifact_digest,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
};
}
function serviceInstanceView(row) {
return {
serviceInstanceRef: `service-instance:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentRef: `deployment:${row.deployment_id}`,
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
serviceKey: row.service_key,
displayName: row.display_name,
serviceRole: row.service_role,
lifecycleState: row.lifecycle_state,
health: healthView(row),
ontology: ontologyView(row),
};
}
function healthView(row) {
if (!row.health_observation_id) {
return { state: "unobserved", freshness: "missing", observationRef: null };
}
const fresh = new Date(row.health_expires_at).valueOf() > Date.now();
return {
state: fresh ? row.health_observed_state : "unobserved",
freshness: fresh ? "fresh" : "stale",
lastObservedState: row.health_observed_state,
evidenceClass: row.health_evidence_class,
observedAt: toIso(row.health_observed_at),
expiresAt: toIso(row.health_expires_at),
observationRef: `health-observation:${row.health_observation_id}`,
};
}
function ontologyView(row) {
return {
entityId: row.ontology_entity_id,
catalogHash: row.ontology_catalog_hash,
};
}
function toIso(value) {
return value == null ? null : new Date(value).toISOString();
}
@@ -0,0 +1,611 @@
import { randomUUID } from "node:crypto";
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
import { isOntologyManagementCommand } from "./ontology-management.mjs";
import { toProjectRef } from "./project-management.mjs";
const commandCapabilities = Object.freeze({
"asset.ensure": "asset.manage",
"asset_binding.ensure": "binding.manage",
"asset_binding.close": "binding.manage",
"infrastructure_host.ensure": "infrastructure.manage",
"infrastructure_endpoint.ensure": "infrastructure.manage",
"infrastructure_deployment.ensure": "infrastructure.manage",
"infrastructure_service_instance.ensure": "infrastructure.manage",
"health_observation.record": "observation.write",
});
export async function applyOntologyManagementCommand(
client,
{ commandKind, actor, command },
) {
assertOntologyCommand(commandKind);
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
commandCapabilities[commandKind],
);
if (commandKind === "asset.ensure") {
return ensureAsset(client, actor, project, command);
}
if (commandKind === "asset_binding.ensure") {
return ensureAssetBinding(client, actor, project, command);
}
if (commandKind === "asset_binding.close") {
return closeAssetBinding(client, actor, project, command);
}
if (commandKind === "infrastructure_host.ensure") {
return ensureHost(client, actor, project, command);
}
if (commandKind === "infrastructure_endpoint.ensure") {
return ensureEndpoint(client, actor, project, command);
}
if (commandKind === "infrastructure_deployment.ensure") {
return ensureDeployment(client, actor, project, command);
}
if (commandKind === "infrastructure_service_instance.ensure") {
return ensureServiceInstance(client, actor, project, command);
}
return recordHealthObservation(client, actor, project, command);
}
export async function authorizeOntologyManagementReplay(
client,
{ commandKind, actor, command },
) {
assertOntologyCommand(commandKind);
await findProjectWithCapability(
client,
actor,
command.projectId,
commandCapabilities[commandKind],
);
}
async function ensureAsset(client, actor, project, command) {
const result = await client.query(
`insert into device_assets (
id, owner_scope_id, project_id, asset_key, display_name,
asset_type_ref, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8)
on conflict (project_id, asset_key) do update set
display_name = excluded.display_name,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_assets.asset_type_ref = excluded.asset_type_ref
and (
device_assets.lifecycle_state = excluded.lifecycle_state
or (
device_assets.lifecycle_state = 'active'
and excluded.lifecycle_state = 'retired'
)
)
returning *, (xmax = 0) as created`,
[
randomUUID(),
project.owner_scope_id,
project.id,
command.assetKey,
command.displayName,
command.assetTypeRef,
command.lifecycleState,
actor.userRef,
],
);
const row = requireRow(result, "device_asset_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "asset.created" : "asset.updated",
payload: {
assetRef: `asset:${row.id}`,
assetKey: row.asset_key,
assetTypeRef: row.asset_type_ref,
lifecycleState: row.lifecycle_state,
ontologyEntityId: row.ontology_entity_id,
},
});
return { created: row.created === true, asset: assetView(row) };
}
async function ensureAssetBinding(client, actor, project, command) {
const [deviceResult, assetResult] = await Promise.all([
client.query(
`select di.id, di.project_id, di.owner_scope_id, di.display_name,
di.lifecycle_state, dmp.device_type
from device_instances di
join device_model_profiles dmp on dmp.profile_ref = di.model_profile_ref
where di.id = $1 and di.project_id = $2
for share of di, dmp`,
[command.deviceId, project.id],
),
client.query(
`select id, project_id, owner_scope_id, display_name, lifecycle_state
from device_assets
where id = $1 and project_id = $2
for share`,
[command.assetId, project.id],
),
]);
const device = requireRow(deviceResult, "device_not_found", 404);
const asset = requireRow(assetResult, "device_asset_not_found", 404);
if (!["claimed", "online", "offline"].includes(device.lifecycle_state)) {
throw domainError("device_asset_binding_device_inactive", 409);
}
if (command.bindingKind === "tracking" && device.device_type !== "tracker") {
throw domainError("device_asset_binding_tracker_required", 409);
}
if (asset.lifecycle_state !== "active") {
throw domainError("device_asset_binding_asset_inactive", 409);
}
const result = await client.query(
`insert into device_asset_bindings (
id, owner_scope_id, project_id, binding_key, device_id, asset_id,
binding_kind, valid_from, provenance_ref, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (project_id, binding_key) do update set
updated_at = now()
where device_asset_bindings.device_id = excluded.device_id
and device_asset_bindings.asset_id = excluded.asset_id
and device_asset_bindings.binding_kind = excluded.binding_kind
and device_asset_bindings.valid_from = excluded.valid_from
and device_asset_bindings.valid_to is null
and device_asset_bindings.provenance_ref = excluded.provenance_ref
returning *, (xmax = 0) as created`,
[
randomUUID(),
project.owner_scope_id,
project.id,
command.bindingKey,
command.deviceId,
command.assetId,
command.bindingKind,
command.validFrom,
command.provenanceRef,
actor.userRef,
],
);
const row = requireRow(result, "device_asset_binding_identity_conflict");
await addAudit(client, {
actor,
project,
deviceId: row.device_id,
eventType: row.created ? "asset_binding.created" : "asset_binding.confirmed",
payload: assetBindingAudit(row),
});
return { created: row.created === true, assetBinding: assetBindingView(row) };
}
async function closeAssetBinding(client, actor, project, command) {
const result = await client.query(
`update device_asset_bindings
set valid_to = $3,
closed_by_ref = $4,
updated_at = now()
where id = $1
and project_id = $2
and valid_to is null
and valid_from < $3
returning *`,
[command.assetBindingId, project.id, command.validTo, actor.userRef],
);
const row = requireRow(result, "device_asset_binding_not_closable", 409);
await addAudit(client, {
actor,
project,
deviceId: row.device_id,
eventType: "asset_binding.closed",
payload: assetBindingAudit(row),
});
return { closed: true, assetBinding: assetBindingView(row) };
}
async function ensureHost(client, actor, project, command) {
const result = await client.query(
`insert into device_infrastructure_hosts (
id, owner_scope_id, project_id, host_key, display_name, provider_ref,
external_ref, management_credential_ref, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (project_id, host_key) do update set
display_name = excluded.display_name,
provider_ref = excluded.provider_ref,
external_ref = excluded.external_ref,
management_credential_ref = excluded.management_credential_ref,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_hosts.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostKey,
command.displayName, command.providerRef, command.externalRef,
command.managementCredentialRef, command.lifecycleState, actor.userRef,
],
);
const row = requireRow(result, "device_host_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_host.created" : "infrastructure_host.updated",
payload: {
hostRef: `host:${row.id}`,
hostKey: row.host_key,
providerRef: row.provider_ref,
lifecycleState: row.lifecycle_state,
managementCredentialConfigured: row.management_credential_ref != null,
ontologyEntityId: row.ontology_entity_id,
},
});
return { created: row.created === true, host: hostView(row) };
}
async function ensureEndpoint(client, actor, project, command) {
await requireActiveHost(client, project.id, command.hostId);
const result = await client.query(
`insert into device_infrastructure_endpoints (
id, owner_scope_id, project_id, host_id, endpoint_key, purpose,
endpoint_uri, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
on conflict (host_id, endpoint_key) do update set
purpose = excluded.purpose,
endpoint_uri = excluded.endpoint_uri,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_endpoints.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostId,
command.endpointKey, command.purpose, command.endpointUri,
command.lifecycleState, actor.userRef,
],
);
const row = requireRow(result, "device_endpoint_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_endpoint.created" : "infrastructure_endpoint.updated",
payload: {
endpointRef: `endpoint:${row.id}`,
hostRef: `host:${row.host_id}`,
purpose: row.purpose,
lifecycleState: row.lifecycle_state,
},
});
return { created: row.created === true, endpoint: endpointView(row) };
}
async function ensureDeployment(client, actor, project, command) {
await requireActiveHost(client, project.id, command.hostId);
const result = await client.query(
`insert into device_infrastructure_deployments (
id, owner_scope_id, project_id, host_id, deployment_key, display_name,
artifact_ref, artifact_digest, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (project_id, deployment_key) do update set
display_name = excluded.display_name,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_deployments.host_id = excluded.host_id
and device_infrastructure_deployments.artifact_ref = excluded.artifact_ref
and device_infrastructure_deployments.artifact_digest = excluded.artifact_digest
and (
device_infrastructure_deployments.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
)
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostId,
command.deploymentKey, command.displayName, command.artifactRef,
command.artifactDigest, command.lifecycleState, actor.userRef,
],
);
const row = requireRow(result, "device_deployment_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_deployment.created" : "infrastructure_deployment.updated",
payload: {
deploymentRef: `deployment:${row.id}`,
hostRef: `host:${row.host_id}`,
artifactRef: row.artifact_ref,
artifactDigest: row.artifact_digest,
lifecycleState: row.lifecycle_state,
},
});
return { created: row.created === true, deployment: deploymentView(row) };
}
async function ensureServiceInstance(client, actor, project, command) {
await requireActiveHost(client, project.id, command.hostId);
const deployment = await client.query(
`select id, host_id, lifecycle_state
from device_infrastructure_deployments
where id = $1 and project_id = $2
for share`,
[command.deploymentId, project.id],
);
const deploymentRow = requireRow(deployment, "device_deployment_not_found", 404);
if (deploymentRow.host_id !== command.hostId) {
throw domainError("device_service_instance_host_mismatch", 409);
}
if (deploymentRow.lifecycle_state === "retired") {
throw domainError("device_service_instance_deployment_inactive", 409);
}
if (command.edgeId) {
const edge = await client.query(
`select de.id
from device_edges de
where de.id = $1
and exists (
select 1 from device_routes dr
where dr.edge_id = de.id and dr.project_id = $2
)
for share`,
[command.edgeId, project.id],
);
requireRow(edge, "device_service_instance_edge_not_in_project", 409);
}
const result = await client.query(
`insert into device_infrastructure_service_instances (
id, owner_scope_id, project_id, host_id, deployment_id, edge_id,
service_key, display_name, service_role, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
on conflict (host_id, service_key) do update set
display_name = excluded.display_name,
edge_id = excluded.edge_id,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_service_instances.deployment_id = excluded.deployment_id
and device_infrastructure_service_instances.service_role = excluded.service_role
and (
device_infrastructure_service_instances.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
)
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostId,
command.deploymentId, command.edgeId, command.serviceKey,
command.displayName, command.serviceRole, command.lifecycleState,
actor.userRef,
],
);
const row = requireRow(result, "device_service_instance_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_service_instance.created" : "infrastructure_service_instance.updated",
payload: {
serviceInstanceRef: `service-instance:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentRef: `deployment:${row.deployment_id}`,
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
serviceRole: row.service_role,
lifecycleState: row.lifecycle_state,
},
});
return { created: row.created === true, serviceInstance: serviceInstanceView(row) };
}
async function recordHealthObservation(client, actor, project, command) {
const subjectColumn = command.subjectKind === "host"
? "host_id"
: "service_instance_id";
const subjectTable = command.subjectKind === "host"
? "device_infrastructure_hosts"
: "device_infrastructure_service_instances";
const subject = await client.query(
`select id from ${subjectTable}
where id = $1 and project_id = $2 and lifecycle_state <> 'retired'
for share`,
[command.subjectId, project.id],
);
requireRow(subject, "device_health_subject_not_found", 404);
const result = await client.query(
`insert into device_health_observations (
id, owner_scope_id, project_id, ${subjectColumn}, observed_state,
evidence_class, source_ref, schema_ref, evidence_projection,
observed_at, expires_at, recorded_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)
returning *`,
[
randomUUID(), project.owner_scope_id, project.id, command.subjectId,
command.observedState, command.evidenceClass, command.sourceRef,
command.schemaRef, JSON.stringify(command.evidence), command.observedAt,
command.expiresAt, actor.userRef,
],
);
const row = result.rows[0];
await addAudit(client, {
actor,
project,
eventType: "health_observation.recorded",
payload: {
healthObservationRef: `health-observation:${row.id}`,
subjectKind: command.subjectKind,
subjectRef: `${command.subjectKind}:${command.subjectId}`,
observedState: row.observed_state,
evidenceClass: row.evidence_class,
observedAt: toIso(row.observed_at),
expiresAt: toIso(row.expires_at),
ontologyEntityId: row.ontology_entity_id,
},
});
return { recorded: true, healthObservation: healthObservationView(row) };
}
async function requireActiveHost(client, projectId, hostId) {
const result = await client.query(
`select id, lifecycle_state
from device_infrastructure_hosts
where id = $1 and project_id = $2
for share`,
[hostId, projectId],
);
const row = requireRow(result, "device_host_not_found", 404);
if (!new Set(["provisioning", "active"]).has(row.lifecycle_state)) {
throw domainError("device_host_inactive", 409);
}
return row;
}
async function addAudit(client, {
actor,
project,
eventType,
deviceId = null,
payload,
}) {
await client.query(
`insert into device_audit_events (
id, event_type, actor_ref, project_id, device_id, payload
) values ($1, $2, $3, $4, $5, $6::jsonb)`,
[
randomUUID(), eventType, actor.userRef, project.id, deviceId,
JSON.stringify({ projectRef: toProjectRef(project.id), ...payload }),
],
);
}
function assetView(row) {
return {
assetRef: `asset:${row.id}`,
assetKey: row.asset_key,
displayName: row.display_name,
assetTypeRef: row.asset_type_ref,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function assetBindingAudit(row) {
return {
assetBindingRef: `asset-binding:${row.id}`,
deviceRef: `device:${row.device_id}`,
assetRef: `asset:${row.asset_id}`,
bindingKind: row.binding_kind,
validFrom: toIso(row.valid_from),
validTo: toIso(row.valid_to),
provenanceRef: row.provenance_ref,
ontologyEntityId: row.ontology_entity_id,
};
}
function assetBindingView(row) {
return {
...assetBindingAudit(row),
bindingKey: row.binding_key,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function hostView(row) {
return {
hostRef: `host:${row.id}`,
hostKey: row.host_key,
displayName: row.display_name,
providerRef: row.provider_ref ?? null,
externalRef: row.external_ref ?? null,
managementCredentialConfigured: row.management_credential_ref != null,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function endpointView(row) {
return {
endpointRef: `endpoint:${row.id}`,
hostRef: `host:${row.host_id}`,
endpointKey: row.endpoint_key,
purpose: row.purpose,
endpointUri: row.endpoint_uri,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function deploymentView(row) {
return {
deploymentRef: `deployment:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentKey: row.deployment_key,
displayName: row.display_name,
artifactRef: row.artifact_ref,
artifactDigest: row.artifact_digest,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function serviceInstanceView(row) {
return {
serviceInstanceRef: `service-instance:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentRef: `deployment:${row.deployment_id}`,
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
serviceKey: row.service_key,
displayName: row.display_name,
serviceRole: row.service_role,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function healthObservationView(row) {
const subjectKind = row.host_id ? "host" : "service-instance";
return {
healthObservationRef: `health-observation:${row.id}`,
subjectKind,
subjectRef: `${subjectKind}:${row.host_id ?? row.service_instance_id}`,
observedState: row.observed_state,
evidenceClass: row.evidence_class,
sourceRef: row.source_ref,
schemaRef: row.schema_ref,
evidence: row.evidence_projection,
observedAt: toIso(row.observed_at),
expiresAt: toIso(row.expires_at),
ontology: ontologyView(row),
};
}
function ontologyView(row) {
return {
entityId: row.ontology_entity_id,
catalogHash: row.ontology_catalog_hash,
};
}
function requireRow(result, code, statusCode = 409) {
const row = result?.rows?.[0];
if (!row) throw domainError(code, statusCode);
return row;
}
function assertOntologyCommand(commandKind) {
if (!isOntologyManagementCommand(commandKind)) {
throw new TypeError("device_ontology_command_kind_invalid");
}
}
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;
}
@@ -19,6 +19,9 @@ import {
getDeviceProjectWorkspace,
listAccessibleDeviceProjects,
} from "./project-query-repository.mjs";
import {
getDeviceProjectOntologyProjection,
} from "./ontology-query-repository.mjs";
import {
applyInfrastructureManagementCommand,
authorizeInfrastructureManagementReplay,
@@ -36,6 +39,11 @@ import {
import {
isSensitiveReferenceManagementCommand,
} from "./sensitive-reference-management.mjs";
import {
applyOntologyManagementCommand,
authorizeOntologyManagementReplay,
} from "./ontology-repository.mjs";
import { isOntologyManagementCommand } from "./ontology-management.mjs";
import {
assertActorCanManageOwnerScope,
assertGrantMutationAllowed,
@@ -66,6 +74,7 @@ const migrationFiles = [
"013_device_edge_channels.sql",
"014_device_registry_profile_commands.sql",
"015_device_integration_identity.sql",
"016_device_asset_infrastructure_ontology.sql",
];
export class PostgresDeviceRepository {
@@ -181,6 +190,12 @@ export class PostgresDeviceRepository {
);
}
async getProjectOntologyProjection(actor, projectId) {
return this.#executeRead((client) =>
getDeviceProjectOntologyProjection(client, actor, projectId)
);
}
async listActiveEdgeChannelRegistrations(limit = 64) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
throw new TypeError("device_edge_channel_registration_limit_invalid");
@@ -320,6 +335,13 @@ async function completeManagementReceipt(client, receiptId, result) {
}
async function applyManagementCommand(client, { commandKind, actor, command }) {
if (isOntologyManagementCommand(commandKind)) {
return applyOntologyManagementCommand(client, {
commandKind,
actor,
command,
});
}
if (isControlResourceManagementCommand(commandKind)) {
return applyControlResourceManagementCommand(client, {
commandKind,
@@ -364,6 +386,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
}
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
if (isOntologyManagementCommand(commandKind)) {
return authorizeOntologyManagementReplay(client, {
commandKind,
actor,
command,
});
}
if (isControlResourceManagementCommand(commandKind)) {
return authorizeControlResourceManagementReplay(client, {
commandKind,
@@ -3,6 +3,7 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
"project.manage",
"access.manage",
"inventory.read",
"asset.manage",
"device.enroll",
"device.claim",
"device.transfer",
@@ -10,6 +11,9 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
"route.manage",
"binding.manage",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read",
"configuration.manage",
"command.plan",
@@ -53,6 +57,7 @@ const roleCapabilities = Object.freeze({
viewer: Object.freeze([
"project.read",
"inventory.read",
"infrastructure.read",
"telemetry.observe",
"configuration.read",
"audit.read",
@@ -61,6 +66,8 @@ const roleCapabilities = Object.freeze({
"project.read",
"inventory.read",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"configuration.read",
"command.plan",
"command.confirm",
@@ -72,10 +79,14 @@ const roleCapabilities = Object.freeze({
"inventory.read",
"device.enroll",
"device.claim",
"asset.manage",
"collection.manage",
"route.manage",
"binding.manage",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read",
"configuration.manage",
"command.plan",
@@ -88,10 +99,14 @@ const roleCapabilities = Object.freeze({
"inventory.read",
"device.enroll",
"device.claim",
"asset.manage",
"collection.manage",
"route.manage",
"binding.manage",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read",
"configuration.manage",
"command.plan",
@@ -8,7 +8,7 @@ const replayedIntermediateConstraintMigrations = Object.freeze([
"009_device_sensitive_reference_commands.sql",
"011_device_control_resource_commands.sql",
]);
const finalCommandKindMigration = "014_device_registry_profile_commands.sql";
const finalCommandKindMigration = "016_device_asset_infrastructure_ontology.sql";
const finalCommandKinds = Object.freeze([
"owner_scope.ensure",
"project.ensure",
@@ -31,6 +31,14 @@ const finalCommandKinds = Object.freeze([
"device_binding.revoke",
"device_configuration_revision.create",
"device_configuration_desired.set",
"asset.ensure",
"asset_binding.ensure",
"asset_binding.close",
"infrastructure_host.ensure",
"infrastructure_endpoint.ensure",
"infrastructure_deployment.ensure",
"infrastructure_service_instance.ensure",
"health_observation.record",
]);
const migrationUrl = new URL(
"../migrations/003_device_management_commands.sql",
@@ -0,0 +1,97 @@
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";
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
const projectId = "11111111-1111-4111-8111-111111111111";
test("management API forwards a canonical asset command", async () => {
let executed;
const runtime = await startServer({
executeManagementCommand: async (input) => {
executed = input;
return { replayed: false, result: { created: true } };
},
});
try {
const response = await fetch(`${runtime.baseUrl}/internal/v1/management/assets:ensure`, {
method: "POST",
headers: managementHeaders("ontology-asset-0001"),
body: JSON.stringify({
projectRef: `project:${projectId}`,
assetKey: "trike-001",
displayName: "Trike 001",
assetTypeRef: "asset-type:delivery-trike",
}),
});
assert.equal(response.status, 200);
assert.equal(executed.commandKind, "asset.ensure");
assert.equal(executed.command.assetKey, "trike-001");
} finally {
await runtime.close();
}
});
test("query API exposes the ontology projection through Core", async () => {
let actor;
const runtime = await startServer({
getProjectOntologyProjection: async (value, requestedProjectId) => {
actor = value;
assert.equal(requestedProjectId, projectId);
return {
ontology: { catalogHash: "229c61c02a790906" },
assets: [],
hosts: [],
};
},
});
try {
const response = await fetch(
`${runtime.baseUrl}/internal/v1/query/projects/${projectId}/ontology`,
{ headers: managementHeaders("ontology-query-0001") },
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.projection.ontology.catalogHash, "229c61c02a790906");
assert.equal(actor.userRef, "user:test-owner");
} finally {
await runtime.close();
}
});
async function startServer(repositoryOverrides) {
const server = createControlCoreApp({
managementApiEnabled: true,
managementToken,
identifierPepper,
repository: {
health: async () => "ready",
executeManagementCommand: async () => ({ replayed: false, result: {} }),
...repositoryOverrides,
},
});
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(idempotencyKey) {
return {
Authorization: `Bearer ${managementToken}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
"X-NODEDC-User-Ref": "user:test-owner",
"X-NODEDC-Hub-Role": "owner",
"X-NODEDC-Group-Refs": "",
"X-NODEDC-Owner-Scopes": "company=organization:test",
};
}
@@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEVICE_ONTOLOGY_CATALOG_HASH,
normalizeOntologyManagementCommand,
} from "../src/ontology-management.mjs";
import {
ALL_DEVICE_MANAGEMENT_COMMAND_KINDS,
normalizeDeviceManagementCommand,
} from "../src/management-command.mjs";
const projectRef = "project:11111111-1111-4111-8111-111111111111";
const deviceRef = "device:22222222-2222-4222-8222-222222222222";
const assetRef = "asset:33333333-3333-4333-8333-333333333333";
const hostRef = "host:44444444-4444-4444-8444-444444444444";
const deploymentRef = "deployment:55555555-5555-4555-8555-555555555555";
test("publishes the production ontology catalog contract", () => {
assert.equal(DEVICE_ONTOLOGY_CATALOG_HASH, "229c61c02a790906");
for (const kind of [
"asset.ensure",
"asset_binding.ensure",
"infrastructure_host.ensure",
"health_observation.record",
]) {
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true);
}
});
test("normalizes an asset and a temporal tracker binding", () => {
const asset = normalizeDeviceManagementCommand("asset.ensure", {
projectRef,
assetKey: "trike-001",
displayName: "Trike 001",
assetTypeRef: "asset-type:delivery-trike",
});
const binding = normalizeDeviceManagementCommand("asset_binding.ensure", {
projectRef,
bindingKey: "trike-001-primary-tracker",
deviceRef,
assetRef,
bindingKind: "tracking",
validFrom: "2026-08-22T10:00:00.000Z",
provenanceRef: "onboarding:direct-b2",
});
assert.equal(asset.assetKey, "trike-001");
assert.equal(binding.deviceId, deviceRef.slice("device:".length));
assert.equal(binding.assetId, assetRef.slice("asset:".length));
assert.equal(binding.bindingKind, "tracking");
});
test("normalizes provider-neutral host topology without browser credentials", () => {
const host = normalizeOntologyManagementCommand("infrastructure_host.ensure", {
projectRef,
hostKey: "b2-edge-moscow",
displayName: "B2 Edge Moscow",
providerRef: "provider:beget",
externalRef: "provider-resource:vps-123",
managementCredentialRef: "secret-ref:device-core/b2-edge-moscow",
lifecycleState: "active",
});
const deployment = normalizeOntologyManagementCommand(
"infrastructure_deployment.ensure",
{
projectRef,
hostRef,
deploymentKey: "device-edge-001",
displayName: "Device Edge 001",
artifactRef: "artifact:device-edge/1.0.0",
artifactDigest: `sha256:${"a".repeat(64)}`,
},
);
const service = normalizeOntologyManagementCommand(
"infrastructure_service_instance.ensure",
{
projectRef,
hostRef,
deploymentRef,
serviceKey: "device-edge",
displayName: "Device Edge",
serviceRole: "device.edge",
},
);
assert.equal(host.managementCredentialRef.startsWith("secret-ref:"), true);
assert.equal(deployment.hostId, hostRef.slice("host:".length));
assert.equal(service.serviceRole, "device.edge");
assert.equal("password" in host, false);
});
test("rejects credential-bearing endpoints and secret-shaped health evidence", () => {
assert.throws(
() => normalizeOntologyManagementCommand("infrastructure_endpoint.ensure", {
projectRef,
hostRef,
endpointKey: "ssh",
purpose: "management",
endpointUri: "ssh://root:password@example.test:22/",
}),
/device_endpoint_uri_invalid/,
);
assert.throws(
() => normalizeOntologyManagementCommand("health_observation.record", {
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "management_probe",
sourceRef: "probe:device-core",
schemaRef: "schema:health.v1",
evidence: { token: "forbidden" },
observedAt: "2026-08-22T10:00:00.000Z",
expiresAt: "2026-08-22T10:01:00.000Z",
}),
/forbidden_device_field/,
);
});
test("health is a bounded observation and not a permanent online flag", () => {
const command = normalizeOntologyManagementCommand(
"health_observation.record",
{
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "management_probe",
sourceRef: "probe:device-core",
schemaRef: "schema:health.v1",
evidence: { latencyMs: 42 },
observedAt: "2026-08-22T10:00:00.000Z",
expiresAt: "2026-08-22T10:01:00.000Z",
},
);
assert.equal(command.observedState, "reachable");
assert.equal(command.expiresAt, "2026-08-22T10:01:00.000Z");
assert.throws(
() => normalizeOntologyManagementCommand("health_observation.record", {
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "management_probe",
sourceRef: "probe:device-core",
schemaRef: "schema:health.v1",
evidence: {},
observedAt: command.observedAt,
expiresAt: command.observedAt,
}),
/device_health_freshness_window_invalid/,
);
});
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const migrationUrl = new URL(
"../migrations/016_device_asset_infrastructure_ontology.sql",
import.meta.url,
);
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
test("ontology migration is additive and encodes the official entity identifiers", async () => {
const sql = await readFile(migrationUrl, "utf8");
for (const table of [
"device_assets",
"device_asset_bindings",
"device_infrastructure_hosts",
"device_infrastructure_deployments",
"device_infrastructure_service_instances",
"device_health_observations",
]) {
assert.match(sql, new RegExp(`create table if not exists ${table}`, "i"));
}
for (const entityId of [
"asset.asset",
"device.asset_binding",
"infrastructure.host",
"infrastructure.deployment",
"infrastructure.service_instance",
"observation.health_observation",
]) {
assert.equal(sql.includes(`'${entityId}'`), true);
}
assert.equal(sql.includes("229c61c02a790906"), true);
for (const commandKind of [
"asset.ensure",
"asset_binding.ensure",
"asset_binding.close",
"infrastructure_host.ensure",
"infrastructure_endpoint.ensure",
"infrastructure_deployment.ensure",
"infrastructure_service_instance.ensure",
"health_observation.record",
]) {
assert.equal(sql.includes(`'${commandKind}'`), true);
}
assert.doesNotMatch(sql, /insert\s+into\s+device_(?:assets|infrastructure_hosts)/i);
assert.doesNotMatch(sql, /gelios|arusnavi|beget|hetzner|aws/i);
});
test("ontology migration follows integration identity", async () => {
const source = await readFile(repositoryUrl, "utf8");
assert.ok(
source.indexOf("015_device_integration_identity.sql")
< source.indexOf("016_device_asset_infrastructure_ontology.sql"),
);
});