diff --git a/docs/DEVICE_INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md b/docs/DEVICE_INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md index d201590..432ddb7 100644 --- a/docs/DEVICE_INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md +++ b/docs/DEVICE_INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md @@ -1,115 +1,132 @@ -# Device Infrastructure Host ontology candidate +# Device Core asset and infrastructure ontology contract + +Status: **canonical foundation implemented** -Status: **candidate, not canonical** Date: 2026-08-22 -## Why this is a candidate +Production ontology catalog hash: `229c61c02a790906` -The live read-only NODE.DC ontology catalog currently has no canonical generic -compute host, deployment, service instance or health-observation entities. Its -`integration.connection` entity models provider-account connections and must -not be reused for a VPS merely to unblock a screen. +## Authority -Device Core therefore does not mint local identifiers and present them as -official ontology. The first shipped projection is limited to the already -canonical product relationship that exists in Device Core today: +Device Core consumes the official read-only NODE.DC Ontology Core catalog. It +does not mint local ontology identifiers. The production catalog now publishes +the `asset`, `device`, `infrastructure` and `observation` packages required by +this runtime foundation. + +Canonical entities used by Device Core: + +| Entity | Runtime meaning | +| --- | --- | +| `asset.asset` | Stable business or physical object whose identity survives tracker replacement | +| `device.tracking_device` | A tracker registered through Device Core enrollment and claim | +| `device.asset_binding` | Temporal, evidenced Device-to-Asset association | +| `infrastructure.host` | Provider-neutral physical or virtual compute host | +| `infrastructure.endpoint` | Credential-free management, monitoring or service endpoint | +| `infrastructure.deployment` | Desired immutable artifact deployment on a host | +| `infrastructure.service_instance` | Runtime service realizing a deployment on a host | +| `observation.health_observation` | Time-bounded health evidence with source and freshness | +| `observation.position_observation` | Canonical position evidence for Foundry/map projections | + +`infrastructure.host` is deliberately distinct from +`integration.connection`, `infrastructure.endpoint`, +`infrastructure.deployment` and Device Edge registration. + +## Direct B2 onboarding + +Direct B2 registration reuses the existing secure Device Core lifecycle: ```text -Device Project -> Route -> Edge registration -> pinned Core↔Edge channel +adapter + model profile + route + | + v +enrollment intent (IMEI -> HMAC digest + masked projection) + | + v +quarantine discovery -> claim -> device.tracking_device | - +-> opaque deploymentRef + v +asset.asset <- temporal device.asset_binding ``` -The UI calls this projection `VPS / Edge host` and derives current reachability -from the live channel supervisor. It does not expose channel endpoints, trust -material, certificates or credentials. +No raw IMEI, provider credential or device secret is stored in the ontology or +returned to the browser. The current Gelios flow remains a legacy integration; +new B2 trackers can be enrolled directly and attached to the same stable Asset +model without making Gelios the identity authority. -## Proposed canonical concepts +Closing a binding records `valid_to` and actor evidence. Tracker replacement +therefore changes the active binding, not the Asset identity or its history. -The following names are discussion handles only. Final identifiers, scopes and -relation direction must be published by the official ontology owner before -Device Core persists them. - -| Candidate concept | Scope | Required meaning | -| --- | --- | --- | -| `infrastructure.host` | owner + project visibility | A physical or virtual compute host independent of provider and workload | -| `infrastructure.endpoint` | host | A bounded management or service endpoint without credentials | -| `infrastructure.deployment` | owner/project | An immutable desired deployment of an artifact or workload | -| `infrastructure.service_instance` | host + deployment | A runtime instance produced by a deployment | -| `infrastructure.health_observation` | observed entity | A time-bounded observation with source, timestamp and evidence class | -| `infrastructure.management_session` | actor + host | An expiring, audited brokered management session | - -Proposed relations: +## VPS and Edge topology ```text -device.project --uses--> infrastructure.host -infrastructure.host --exposes--> infrastructure.endpoint -infrastructure.deployment --targets--> infrastructure.host -infrastructure.service_instance --runs-on--> infrastructure.host -infrastructure.service_instance --realizes--> infrastructure.deployment -device.edge-registration --runs-on--> infrastructure.host -device.route --terminates-at--> device.edge-registration -infrastructure.health-observation --observes--> host|endpoint|service-instance -infrastructure.management-session --targets--> infrastructure.host +device.project -> infrastructure.host -> infrastructure.endpoint + | + v + infrastructure.deployment + | + v + infrastructure.service_instance + | + +---- optional link ----> device.edge-registration ``` -## State is faceted, not flattened +Provider names and provider resource IDs are annotations. They never define +the host identity. A management credential is an opaque `secret-ref:*`; the +secret value is not accepted by the API, stored in these tables or projected +to the Manager. -A single `online` flag is insufficient and would hardcode the current VPS -case. Each host projection needs independent facets: +An Edge link is allowed only when the Edge is already used by a route in the +same Device project. This prevents a generic host screen from silently taking +ownership of a platform-wide Edge registration. -- lifecycle: `provisioning | active | suspended | retired`; -- reachability: `reachable | degraded | unreachable | unobserved`; -- management access: `available | denied | expired | unconfigured`; -- workload health: per service instance, never inferred from host ping alone; -- observation freshness: `observedAt`, TTL and source; -- desired/actual configuration: immutable revision refs and reconciliation - state, with secrets represented only by opaque secret refs. +## Health and monitoring -This allows one host to be reachable while a service is unhealthy, or a -service to be healthy while interactive management access is intentionally -disabled. +Health is append-only evidence, not a mutable `online` property. Each +observation has: -## Registration workflow after ontology publication +- subject: Host or Service Instance; +- state: `reachable`, `degraded` or `unreachable`; +- evidence class and schema reference; +- source reference; +- `observed_at` and mandatory `expires_at`; +- bounded, secret-free evidence projection. -1. Create or select an owner-scoped host identity. -2. Grant project visibility through a canonical project-host relation. -3. Attach a provider-neutral endpoint projection and an opaque credential ref. -4. Run a bounded reachability probe through a server-side worker. -5. Register deployments and discovered service instances as separate entities. -6. Link an Edge registration to the host when that role is actually deployed. -7. Emit health observations with TTL instead of mutating a permanent `online` - property. +The read projection returns `unobserved` when evidence is missing or stale. +Missing evidence is never converted into `unreachable`. -Provider fields such as Beget, Hetzner or AWS remain annotations or provider -relations. They never change the host identity or the UI information model. +## API surface + +Idempotent management commands: + +- `assets:ensure`; +- `asset-bindings:ensure` and `asset-bindings:close`; +- `infrastructure-hosts:ensure`; +- `infrastructure-endpoints:ensure`; +- `infrastructure-deployments:ensure`; +- `infrastructure-service-instances:ensure`; +- `health-observations:record`. + +The safe read projection is: + +```text +GET /internal/v1/query/projects/{project-id}/ontology +``` + +It returns the catalog hash, assets and temporal bindings, provider-neutral +infrastructure inventory, and freshness-aware health projections. It never +returns credential references themselves. ## Console boundary -An unrestricted WebSSH terminal is not part of the first slice. If introduced, -it must be a server-side session broker with all of the following properties: +An unrestricted WebSSH terminal remains disabled. A future console requires a +separate `infrastructure.management_session` broker with short-lived sessions, +server-side credentials, command authorization, immutable audit, output limits, +redaction and explicit break-glass controls. A direct browser-to-SSH connection +is outside the Device Core security boundary. -- short-lived session and explicit target selection; -- authorization checked at session creation and command execution; -- no private key or password delivered to the browser; -- bounded command catalog by default; -- immutable actor/target/timing/exit-code audit; -- output size limits and secret redaction; -- explicit break-glass mode for arbitrary commands; -- automatic expiry, revocation and concurrent-session limits. +## Next projection -The broker must reference `infrastructure.management_session` after that -concept becomes canonical. A browser shell iframe or direct browser-to-SSH -connection is outside the Device Core security boundary. - -## Delivery sequence - -1. **Shipped candidate UI projection:** VPS/Edge hosts, route relationships and - live pinned-channel state from existing Device Core records. -2. **Ontology gate:** publish host/deployment/service/health entities and - relations in the official ontology package. -3. **Inventory:** add provider-neutral host registration and project grants. -4. **Monitoring:** persist bounded health observations from a dedicated worker. -5. **Configuration:** immutable desired revisions and reconciliation receipts. -6. **Management sessions:** bounded command runner first; audited break-glass - console only after a separate threat-model review. +Foundry consumes canonical Asset, Device, temporal binding and Observation +relations. Spatial layers are produced only from current +`observation.position_observation` evidence; Hosts without spatial evidence do +not appear on a map merely because they exist in inventory. diff --git a/services/device-control-core/migrations/016_device_asset_infrastructure_ontology.sql b/services/device-control-core/migrations/016_device_asset_infrastructure_ontology.sql new file mode 100644 index 0000000..2b681d1 --- /dev/null +++ b/services/device-control-core/migrations/016_device_asset_infrastructure_ontology.sql @@ -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; diff --git a/services/device-control-core/src/app.mjs b/services/device-control-core/src/app.mjs index 8a1c047..ea74153 100644 --- a/services/device-control-core/src/app.mjs +++ b/services/device-control-core/src/app.mjs @@ -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"]), diff --git a/services/device-control-core/src/management-command.mjs b/services/device-control-core/src/management-command.mjs index 9dc8072..3f7924b 100644 --- a/services/device-control-core/src/management-command.mjs +++ b/services/device-control-core/src/management-command.mjs @@ -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); } diff --git a/services/device-control-core/src/ontology-management.mjs b/services/device-control-core/src/ontology-management.mjs new file mode 100644 index 0000000..95736f5 --- /dev/null +++ b/services/device-control-core/src/ontology-management.mjs @@ -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}`); + } + } +} diff --git a/services/device-control-core/src/ontology-query-repository.mjs b/services/device-control-core/src/ontology-query-repository.mjs new file mode 100644 index 0000000..908f653 --- /dev/null +++ b/services/device-control-core/src/ontology-query-repository.mjs @@ -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(); +} diff --git a/services/device-control-core/src/ontology-repository.mjs b/services/device-control-core/src/ontology-repository.mjs new file mode 100644 index 0000000..0fd1ba4 --- /dev/null +++ b/services/device-control-core/src/ontology-repository.mjs @@ -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; +} diff --git a/services/device-control-core/src/postgres-repository.mjs b/services/device-control-core/src/postgres-repository.mjs index a663698..3d51ff3 100644 --- a/services/device-control-core/src/postgres-repository.mjs +++ b/services/device-control-core/src/postgres-repository.mjs @@ -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, diff --git a/services/device-control-core/src/project-management.mjs b/services/device-control-core/src/project-management.mjs index b9862ca..93d066e 100644 --- a/services/device-control-core/src/project-management.mjs +++ b/services/device-control-core/src/project-management.mjs @@ -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", diff --git a/services/device-control-core/test/management-command-migration.test.mjs b/services/device-control-core/test/management-command-migration.test.mjs index 41021c5..826a6dd 100644 --- a/services/device-control-core/test/management-command-migration.test.mjs +++ b/services/device-control-core/test/management-command-migration.test.mjs @@ -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", diff --git a/services/device-control-core/test/ontology-app.test.mjs b/services/device-control-core/test/ontology-app.test.mjs new file mode 100644 index 0000000..f34d25c --- /dev/null +++ b/services/device-control-core/test/ontology-app.test.mjs @@ -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", + }; +} diff --git a/services/device-control-core/test/ontology-management.test.mjs b/services/device-control-core/test/ontology-management.test.mjs new file mode 100644 index 0000000..22f4554 --- /dev/null +++ b/services/device-control-core/test/ontology-management.test.mjs @@ -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/, + ); +}); diff --git a/services/device-control-core/test/ontology-migration.test.mjs b/services/device-control-core/test/ontology-migration.test.mjs new file mode 100644 index 0000000..9d3393f --- /dev/null +++ b/services/device-control-core/test/ontology-migration.test.mjs @@ -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"), + ); +});