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
+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",