feat(device-core): add control resource ledger

This commit is contained in:
Codex
2026-08-10 19:05:48 +03:00
parent 422ddb020f
commit 43dc9b1f45
15 changed files with 2214 additions and 0 deletions
@@ -0,0 +1,610 @@
begin;
create table if not exists device_resource_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}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
source_kind text not null
check (source_kind in ('device', 'collection')),
device_id uuid references device_instances(id),
collection_id uuid references device_collections(id),
target_kind text not null
check (target_kind ~ '^[a-z][a-z0-9._:-]{1,63}$'),
target_ref text not null
check (length(btrim(target_ref)) between 3 and 256),
capabilities text[] not null default '{}'
check (
cardinality(capabilities) between 1 and 16
and array_position(capabilities, null) is null
),
lifecycle_state text not null default 'pending_external_approval'
check (lifecycle_state in ('pending_external_approval', 'active', 'revoked')),
source_approved_by_ref text not null
check (length(btrim(source_approved_by_ref)) between 3 and 256),
source_approved_at timestamptz not null default now(),
external_approval_ref text
check (
external_approval_ref is null
or length(btrim(external_approval_ref)) between 3 and 256
),
external_approval_digest text
check (
external_approval_digest is null
or external_approval_digest ~ '^sha256:[a-f0-9]{64}$'
),
external_approved_at timestamptz,
revoked_at timestamptz,
revoked_by_ref text
check (
revoked_by_ref is null
or length(btrim(revoked_by_ref)) between 3 and 256
),
revocation_code text
check (
revocation_code is null
or revocation_code ~ '^[a-z][a-z0-9._-]{1,63}$'
),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, binding_key),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id),
foreign key (collection_id, project_id)
references device_collections(id, project_id),
check (
(source_kind = 'device' and device_id is not null and collection_id is null)
or
(source_kind = 'collection' and device_id is null and collection_id is not null)
),
check (
(
lifecycle_state = 'pending_external_approval'
and external_approval_ref is null
and external_approval_digest is null
and external_approved_at is null
and revoked_at is null
and revoked_by_ref is null
and revocation_code is null
)
or
(
lifecycle_state = 'active'
and external_approval_ref is not null
and external_approval_digest is not null
and external_approved_at is not null
and revoked_at is null
and revoked_by_ref is null
and revocation_code is null
)
or
(
lifecycle_state = 'revoked'
and revoked_at is not null
and revoked_by_ref is not null
and revocation_code is not null
)
)
);
create index if not exists device_resource_bindings_project_state_idx
on device_resource_bindings (project_id, lifecycle_state, updated_at desc);
create index if not exists device_resource_bindings_device_state_idx
on device_resource_bindings (device_id, lifecycle_state, updated_at desc)
where device_id is not null;
create or replace function device_assert_binding_source_scope()
returns trigger
language plpgsql
as $$
begin
if new.source_kind = 'device' and not exists (
select 1 from device_instances di
where di.id = new.device_id
and di.owner_scope_id = new.owner_scope_id
and di.project_id = new.project_id
) then
raise foreign_key_violation using
message = 'device_binding_source_scope_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_resource_bindings_source_guard
on device_resource_bindings;
create trigger device_resource_bindings_source_guard
before insert or update of owner_scope_id, project_id, source_kind, device_id, collection_id
on device_resource_bindings
for each row
execute function device_assert_binding_source_scope();
create table if not exists device_configuration_revisions (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
device_id uuid not null references device_instances(id),
revision_number bigint not null check (revision_number > 0),
model_profile_ref text not null references device_model_profiles(profile_ref),
schema_artifact_ref text not null
check (length(btrim(schema_artifact_ref)) between 3 and 256),
configuration_digest text not null
check (configuration_digest ~ '^sha256:[a-f0-9]{64}$'),
configuration jsonb not null
check (
jsonb_typeof(configuration) = 'object'
and octet_length(configuration::text) <= 65536
),
change_summary text
check (change_summary is null or length(change_summary) <= 1000),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
unique (device_id, revision_number),
unique (id, device_id, project_id),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id)
);
create index if not exists device_configuration_revisions_project_idx
on device_configuration_revisions (project_id, device_id, revision_number desc);
create or replace function device_assert_configuration_revision_scope()
returns trigger
language plpgsql
as $$
begin
if not exists (
select 1
from device_instances di
join device_model_profiles dmp
on dmp.profile_ref = di.model_profile_ref
where di.id = new.device_id
and di.owner_scope_id = new.owner_scope_id
and di.project_id = new.project_id
and di.model_profile_ref = new.model_profile_ref
and dmp.schema_artifact_ref = new.schema_artifact_ref
and dmp.lifecycle_state = 'active'
) then
raise foreign_key_violation using
message = 'device_configuration_revision_scope_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_configuration_revisions_scope_guard
on device_configuration_revisions;
create trigger device_configuration_revisions_scope_guard
before insert
on device_configuration_revisions
for each row
execute function device_assert_configuration_revision_scope();
create table if not exists device_configuration_state (
device_id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
desired_revision_id uuid,
applied_revision_id uuid,
applied_at timestamptz,
applied_by_ref text,
updated_at timestamptz not null default now(),
foreign key (device_id, project_id, owner_scope_id)
references device_instances(id, project_id, owner_scope_id),
foreign key (desired_revision_id, device_id, project_id)
references device_configuration_revisions(id, device_id, project_id),
foreign key (applied_revision_id, device_id, project_id)
references device_configuration_revisions(id, device_id, project_id),
check (desired_revision_id is not null or applied_revision_id is not null),
check (
(applied_revision_id is null and applied_at is null and applied_by_ref is null)
or
(applied_revision_id is not null and applied_at is not null and applied_by_ref is not null)
)
);
create table if not exists device_commands (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
device_id uuid not null references device_instances(id),
command_key text not null
check (command_key ~ '^[a-z][a-z0-9-]{1,62}$'),
command_catalog_ref text not null
check (length(btrim(command_catalog_ref)) between 3 and 256),
command_type text not null
check (command_type ~ '^[a-z][a-z0-9._:-]{1,63}$'),
risk_class text not null
check (risk_class in ('low', 'moderate', 'high', 'critical')),
parameters_digest text not null
check (parameters_digest ~ '^sha256:[a-f0-9]{64}$'),
parameters_projection jsonb not null
check (
jsonb_typeof(parameters_projection) = 'object'
and octet_length(parameters_projection::text) <= 16384
),
lifecycle_state text not null default 'draft'
check (lifecycle_state in (
'draft',
'planned',
'awaiting_confirmation',
'queued',
'dispatched',
'acknowledged',
'verified',
'failed',
'expired',
'unknown'
)),
planned_by_ref text not null
check (length(btrim(planned_by_ref)) between 3 and 256),
planned_at timestamptz not null default now(),
expires_at timestamptz not null,
confirmed_by_ref text,
confirmed_at timestamptz,
dispatched_at timestamptz,
transport_message_ref text
check (
transport_message_ref is null
or length(btrim(transport_message_ref)) between 3 and 256
),
acknowledged_at timestamptz,
terminal_at timestamptz,
terminal_reason_code text
check (
terminal_reason_code is null
or terminal_reason_code ~ '^[a-z][a-z0-9._-]{1,63}$'
),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, command_key),
unique (id, device_id, project_id),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id),
check (expires_at > planned_at),
check (
(confirmed_at is null and confirmed_by_ref is null)
or
(confirmed_at is not null and confirmed_by_ref is not null)
),
check (
(
lifecycle_state in ('dispatched', 'acknowledged', 'verified', 'unknown')
and dispatched_at is not null
and transport_message_ref is not null
)
or lifecycle_state not in ('dispatched', 'acknowledged', 'verified', 'unknown')
),
check (
(lifecycle_state in ('acknowledged', 'verified') and acknowledged_at is not null)
or lifecycle_state not in ('acknowledged', 'verified')
),
check (
(
lifecycle_state in ('verified', 'failed', 'expired', 'unknown')
and terminal_at is not null
and terminal_reason_code is not null
)
or
(
lifecycle_state not in ('verified', 'failed', 'expired', 'unknown')
and terminal_at is null
and terminal_reason_code is null
)
)
);
create index if not exists device_commands_project_state_idx
on device_commands (project_id, lifecycle_state, updated_at desc);
create index if not exists device_commands_device_state_idx
on device_commands (device_id, lifecycle_state, updated_at desc);
create table if not exists device_command_events (
id uuid primary key,
command_id uuid not null,
device_id uuid not null,
project_id uuid not null,
sequence_number bigint not null check (sequence_number > 0),
from_state text,
to_state text not null
check (to_state in (
'draft',
'planned',
'awaiting_confirmation',
'queued',
'dispatched',
'acknowledged',
'verified',
'failed',
'expired',
'unknown'
)),
actor_ref text not null
check (length(btrim(actor_ref)) between 3 and 256),
reason_code text not null
check (reason_code ~ '^[a-z][a-z0-9._-]{1,63}$'),
evidence_ref text
check (
evidence_ref is null
or length(btrim(evidence_ref)) between 3 and 256
),
occurred_at timestamptz not null default now(),
unique (command_id, sequence_number),
foreign key (command_id, device_id, project_id)
references device_commands(id, device_id, project_id),
check (sequence_number = 1 or from_state is not null),
check (sequence_number <> 1 or from_state is null),
check (
from_state is null
or from_state in (
'draft',
'planned',
'awaiting_confirmation',
'queued',
'dispatched',
'acknowledged',
'verified',
'failed',
'expired',
'unknown'
)
)
);
create index if not exists device_command_events_command_idx
on device_command_events (command_id, sequence_number);
create or replace function device_assert_command_scope()
returns trigger
language plpgsql
as $$
begin
if not exists (
select 1 from device_instances di
where di.id = new.device_id
and di.owner_scope_id = new.owner_scope_id
and di.project_id = new.project_id
) then
raise foreign_key_violation using message = 'device_command_scope_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_commands_scope_guard on device_commands;
create trigger device_commands_scope_guard
before insert
on device_commands
for each row
execute function device_assert_command_scope();
create or replace function device_assert_command_event_sequence()
returns trigger
language plpgsql
as $$
declare
previous_state text;
begin
if new.sequence_number = 1 then
if new.from_state is not null or new.to_state <> 'draft' then
raise check_violation using message = 'device_command_initial_event_invalid';
end if;
return new;
end if;
select dce.to_state into previous_state
from device_command_events dce
where dce.command_id = new.command_id
and dce.sequence_number = new.sequence_number - 1;
if previous_state is null or previous_state <> new.from_state then
raise check_violation using message = 'device_command_event_sequence_invalid';
end if;
if not (
(new.from_state = 'draft' and new.to_state in ('planned', 'expired'))
or (new.from_state = 'planned' and new.to_state in ('awaiting_confirmation', 'queued', 'expired'))
or (new.from_state = 'awaiting_confirmation' and new.to_state in ('queued', 'expired'))
or (new.from_state = 'queued' and new.to_state in ('dispatched', 'failed', 'expired'))
or (new.from_state = 'dispatched' and new.to_state in ('acknowledged', 'failed', 'unknown'))
or (new.from_state = 'acknowledged' and new.to_state in ('verified', 'failed', 'unknown'))
) then
raise check_violation using message = 'device_command_event_transition_invalid';
end if;
return new;
end
$$;
drop trigger if exists device_command_events_sequence_guard
on device_command_events;
create trigger device_command_events_sequence_guard
before insert
on device_command_events
for each row
execute function device_assert_command_event_sequence();
create or replace function device_assert_command_transition()
returns trigger
language plpgsql
as $$
begin
if old.lifecycle_state = new.lifecycle_state then
return new;
end if;
if not (
(old.lifecycle_state = 'draft' and new.lifecycle_state in ('planned', 'expired'))
or (old.lifecycle_state = 'planned' and new.lifecycle_state in ('awaiting_confirmation', 'queued', 'expired'))
or (old.lifecycle_state = 'awaiting_confirmation' and new.lifecycle_state in ('queued', 'expired'))
or (old.lifecycle_state = 'queued' and new.lifecycle_state in ('dispatched', 'failed', 'expired'))
or (old.lifecycle_state = 'dispatched' and new.lifecycle_state in ('acknowledged', 'failed', 'unknown'))
or (old.lifecycle_state = 'acknowledged' and new.lifecycle_state in ('verified', 'failed', 'unknown'))
) then
raise check_violation using message = 'device_command_transition_invalid';
end if;
return new;
end
$$;
drop trigger if exists device_commands_transition_guard on device_commands;
create trigger device_commands_transition_guard
before update of lifecycle_state
on device_commands
for each row
execute function device_assert_command_transition();
create or replace function device_assert_command_event_projection()
returns trigger
language plpgsql
as $$
declare
current_state text;
latest_event_state text;
begin
select dc.lifecycle_state into current_state
from device_commands dc
where dc.id = new.id;
select dce.to_state into latest_event_state
from device_command_events dce
where dce.command_id = new.id
order by dce.sequence_number desc
limit 1;
if current_state is null or latest_event_state is distinct from current_state then
raise check_violation using message = 'device_command_event_projection_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_commands_event_projection_guard on device_commands;
create constraint trigger device_commands_event_projection_guard
after insert or update of lifecycle_state
on device_commands
deferrable initially deferred
for each row
execute function device_assert_command_event_projection();
create or replace function device_assert_command_current_projection()
returns trigger
language plpgsql
as $$
declare
current_state text;
latest_event_state text;
begin
select dc.lifecycle_state into current_state
from device_commands dc
where dc.id = new.command_id;
select dce.to_state into latest_event_state
from device_command_events dce
where dce.command_id = new.command_id
order by dce.sequence_number desc
limit 1;
if current_state is null or latest_event_state is distinct from current_state then
raise check_violation using message = 'device_command_current_projection_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_command_events_current_projection_guard
on device_command_events;
create constraint trigger device_command_events_current_projection_guard
after insert
on device_command_events
deferrable initially deferred
for each row
execute function device_assert_command_current_projection();
create or replace function device_reject_immutable_mutation()
returns trigger
language plpgsql
as $$
begin
raise check_violation using message = 'device_immutable_record_mutation_forbidden';
end
$$;
drop trigger if exists device_configuration_revisions_immutable_guard
on device_configuration_revisions;
create trigger device_configuration_revisions_immutable_guard
before update or delete or truncate
on device_configuration_revisions
for each statement
execute function device_reject_immutable_mutation();
drop trigger if exists device_command_events_immutable_guard
on device_command_events;
create trigger device_command_events_immutable_guard
before update or delete or truncate
on device_command_events
for each statement
execute function device_reject_immutable_mutation();
drop trigger if exists device_audit_events_immutable_guard
on device_audit_events;
create trigger device_audit_events_immutable_guard
before update or delete or truncate
on device_audit_events
for each statement
execute function device_reject_immutable_mutation();
create or replace function device_require_control_resources_clear_before_transfer()
returns trigger
language plpgsql
as $$
begin
if exists (
select 1 from device_resource_bindings drb
where drb.device_id = old.id
and drb.lifecycle_state in ('pending_external_approval', 'active')
) then
raise check_violation using message = 'device_transfer_active_resource_binding';
end if;
if exists (
select 1 from device_configuration_state dcs
where dcs.device_id = old.id
and dcs.applied_revision_id is not null
) then
raise check_violation using message = 'device_transfer_applied_configuration';
end if;
if exists (
select 1 from device_commands dc
where dc.device_id = old.id
and dc.lifecycle_state not in ('verified', 'failed', 'expired', 'unknown')
) then
raise check_violation using message = 'device_transfer_nonterminal_command';
end if;
return new;
end
$$;
drop trigger if exists device_instances_control_resource_transfer_guard
on device_instances;
create trigger device_instances_control_resource_transfer_guard
before update of owner_scope_id, project_id
on device_instances
for each row
when (
old.owner_scope_id is distinct from new.owner_scope_id
or old.project_id is distinct from new.project_id
)
execute function device_require_control_resources_clear_before_transfer();
commit;
@@ -0,0 +1,31 @@
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.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'
));
commit;
@@ -35,6 +35,16 @@ const managementRoutes = new Map([
"/internal/v1/management/device-credential-bindings:revoke", "/internal/v1/management/device-credential-bindings:revoke",
"device_credential_binding.revoke", "device_credential_binding.revoke",
], ],
["/internal/v1/management/device-bindings:ensure", "device_binding.ensure"],
["/internal/v1/management/device-bindings:revoke", "device_binding.revoke"],
[
"/internal/v1/management/device-configuration-revisions:create",
"device_configuration_revision.create",
],
[
"/internal/v1/management/device-configurations:set-desired",
"device_configuration_desired.set",
],
]); ]);
export function createControlCoreApp({ export function createControlCoreApp({
@@ -0,0 +1,284 @@
import { createHash } from "node:crypto";
import {
DEVICE_BINDING_CAPABILITIES,
assertSafeProjection,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export const DEVICE_CONTROL_RESOURCE_COMMAND_KINDS = Object.freeze([
"device_binding.ensure",
"device_binding.revoke",
"device_configuration_revision.create",
"device_configuration_desired.set",
]);
const commandKindSet = new Set(DEVICE_CONTROL_RESOURCE_COMMAND_KINDS);
const bindingCapabilitySet = new Set(DEVICE_BINDING_CAPABILITIES);
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
const tokenPattern = /^[a-z][a-z0-9._:-]{1,63}$/;
const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/;
const targetRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
const configurationKeyPattern = /^[a-z][a-z0-9._-]{0,63}$/;
const secretReferencePattern = /^(?:ndc-credref:|(?:bearer|basic)\s)|[?&](?:token|secret|password|api[_-]?key)=/i;
export function isControlResourceManagementCommand(kind) {
return commandKindSet.has(kind);
}
export function normalizeControlResourceManagementCommand(kind, input) {
if (!commandKindSet.has(kind)) {
throw new TypeError("device_control_resource_command_kind_invalid");
}
assertPlainObject(input, "device_control_resource_command_invalid");
if (kind === "device_binding.ensure") {
assertAllowedKeys(input, [
"projectRef",
"bindingKey",
"displayName",
"source",
"targetKind",
"targetRef",
"capabilities",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
bindingKey: normalizePattern(
input.bindingKey,
keyPattern,
"device_binding_key_invalid",
),
displayName: normalizeDisplayText(
input.displayName,
160,
"device_binding_name_invalid",
),
source: normalizeBindingSource(input.source),
targetKind: normalizePattern(
input.targetKind,
tokenPattern,
"device_binding_target_kind_invalid",
),
targetRef: normalizeTargetRef(input.targetRef),
capabilities: Object.freeze(normalizeBindingCapabilities(
input.capabilities,
)),
});
}
if (kind === "device_binding.revoke") {
assertAllowedKeys(input, ["projectRef", "bindingRef", "resolutionCode"]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
bindingId: normalizeEntityRef(input.bindingRef, "binding"),
resolutionCode: normalizePattern(
input.resolutionCode,
resolutionPattern,
"device_binding_resolution_code_invalid",
),
});
}
if (kind === "device_configuration_revision.create") {
assertAllowedKeys(input, [
"projectRef",
"deviceRef",
"configuration",
"changeSummary",
]);
const configuration = normalizeDeviceConfiguration(input.configuration);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
deviceId: normalizeEntityRef(input.deviceRef, "device"),
configuration,
configurationDigest: `sha256:${createHash("sha256")
.update(JSON.stringify(configuration), "utf8")
.digest("hex")}`,
changeSummary: normalizeOptionalText(
input.changeSummary,
1000,
"device_configuration_change_summary_invalid",
),
});
}
assertAllowedKeys(input, [
"projectRef",
"deviceRef",
"configurationRevisionRef",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
deviceId: normalizeEntityRef(input.deviceRef, "device"),
configurationRevisionId: normalizeEntityRef(
input.configurationRevisionRef,
"configuration-revision",
),
});
}
export function normalizeDeviceConfiguration(input) {
const normalized = normalizeConfigurationValue(input, 0, "$configuration");
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
throw new TypeError("device_configuration_must_be_object");
}
if (Object.keys(normalized).length === 0) {
throw new TypeError("device_configuration_must_not_be_empty");
}
const serialized = JSON.stringify(normalized);
if (Buffer.byteLength(serialized, "utf8") > 32768) {
throw new TypeError("device_configuration_too_large");
}
assertSafeProjection({ configuration: normalized });
return deepFreeze(normalized);
}
function normalizeBindingSource(input) {
assertPlainObject(input, "device_binding_source_invalid");
assertAllowedKeys(input, ["kind", "ref"]);
if (input.kind === "device") {
return Object.freeze({
kind: "device",
id: normalizeEntityRef(input.ref, "device"),
});
}
if (input.kind === "collection") {
return Object.freeze({
kind: "collection",
id: normalizeEntityRef(input.ref, "collection"),
});
}
throw new TypeError("device_binding_source_kind_invalid");
}
function normalizeBindingCapabilities(input) {
if (!Array.isArray(input) || input.length < 1 || input.length > 16) {
throw new TypeError("device_binding_capabilities_invalid");
}
const normalized = input.map((value) => {
if (typeof value !== "string" || !bindingCapabilitySet.has(value)) {
throw new TypeError("device_binding_capability_invalid");
}
return value;
});
if (new Set(normalized).size !== normalized.length) {
throw new TypeError("device_binding_capabilities_duplicate");
}
return normalized.sort();
}
function normalizeTargetRef(value) {
if (
typeof value !== "string"
|| !targetRefPattern.test(value)
|| secretReferencePattern.test(value)
) {
throw new TypeError("device_binding_target_ref_invalid");
}
assertSafeProjection({ targetRef: value });
return value;
}
function normalizeConfigurationValue(value, depth, path) {
if (depth > 5) throw new TypeError("device_configuration_depth_exceeded");
if (value === null || typeof value === "boolean") return value;
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new TypeError(`device_configuration_number_invalid:${path}`);
}
return value;
}
if (typeof value === "string") {
if (
value.length > 1000
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
) {
throw new TypeError(`device_configuration_string_invalid:${path}`);
}
return value;
}
if (Array.isArray(value)) {
if (value.length > 64) {
throw new TypeError(`device_configuration_array_invalid:${path}`);
}
return value.map((item, index) =>
normalizeConfigurationValue(item, depth + 1, `${path}[${index}]`)
);
}
assertPlainObject(value, `device_configuration_object_invalid:${path}`);
const keys = Object.keys(value);
if (keys.length > 64) {
throw new TypeError(`device_configuration_object_invalid:${path}`);
}
const normalized = {};
for (const key of keys.sort()) {
if (!configurationKeyPattern.test(key)) {
throw new TypeError(`device_configuration_key_invalid:${path}.${key}`);
}
normalized[key] = normalizeConfigurationValue(
value[key],
depth + 1,
`${path}.${key}`,
);
}
return normalized;
}
function normalizeEntityRef(value, prefix) {
if (typeof value !== "string") {
throw new TypeError(`device_${prefix}_ref_invalid`);
}
const match = value.match(new RegExp(
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
"i",
));
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
return match[1].toLowerCase();
}
function normalizePattern(value, pattern, code) {
if (typeof value !== "string" || !pattern.test(value)) {
throw new TypeError(code);
}
return value;
}
function normalizeDisplayText(value, maxLength, code) {
if (typeof value !== "string") throw new TypeError(code);
const normalized = value.trim();
if (
normalized.length < 1
|| normalized.length > maxLength
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)
) {
throw new TypeError(code);
}
return normalized;
}
function normalizeOptionalText(value, maxLength, code) {
if (value == null || value === "") return null;
return normalizeDisplayText(value, maxLength, code);
}
function assertPlainObject(value, code) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(code);
}
}
function assertAllowedKeys(input, allowed) {
const allowedSet = new Set(allowed);
for (const key of Object.keys(input)) {
if (!allowedSet.has(key)) {
throw new TypeError(`device_management_command_field_unexpected:${key}`);
}
}
}
function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
return value;
}
@@ -0,0 +1,498 @@
import { randomUUID } from "node:crypto";
import {
isControlResourceManagementCommand,
} from "./control-resource-management.mjs";
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
import { toProjectRef } from "./project-management.mjs";
export async function applyControlResourceManagementCommand(
client,
{ commandKind, actor, command },
) {
if (!isControlResourceManagementCommand(commandKind)) {
throw new TypeError("device_control_resource_command_kind_invalid");
}
if (commandKind === "device_binding.ensure") {
return ensureBinding(client, actor, command);
}
if (commandKind === "device_binding.revoke") {
return revokeBinding(client, actor, command);
}
if (commandKind === "device_configuration_revision.create") {
return createConfigurationRevision(client, actor, command);
}
return setDesiredConfiguration(client, actor, command);
}
export async function authorizeControlResourceManagementReplay(
client,
{ commandKind, actor, command },
) {
if (!isControlResourceManagementCommand(commandKind)) {
throw new TypeError("device_control_resource_command_kind_invalid");
}
const capability = commandKind.startsWith("device_binding.")
? "binding.manage"
: "configuration.manage";
await findProjectWithCapability(
client,
actor,
command.projectId,
capability,
);
if (command.deviceId) {
const current = await client.query(
`select project_id from device_instances where id = $1`,
[command.deviceId],
);
const currentProjectId = current.rows[0]?.project_id;
if (!currentProjectId) throw domainError("device_not_found", 404);
if (currentProjectId !== command.projectId) {
await findProjectWithCapability(
client,
actor,
currentProjectId,
capability,
);
}
}
}
async function ensureBinding(client, actor, command) {
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
"binding.manage",
);
const source = await findBindingSource(client, command);
const bindingId = randomUUID();
const result = await client.query(
`insert into device_resource_bindings (
id,
owner_scope_id,
project_id,
binding_key,
display_name,
source_kind,
device_id,
collection_id,
target_kind,
target_ref,
capabilities,
source_approved_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
on conflict (project_id, binding_key) do update set
display_name = excluded.display_name,
capabilities = excluded.capabilities,
updated_at = now()
where device_resource_bindings.lifecycle_state = 'pending_external_approval'
and device_resource_bindings.source_kind = excluded.source_kind
and device_resource_bindings.device_id is not distinct from excluded.device_id
and device_resource_bindings.collection_id is not distinct from excluded.collection_id
and device_resource_bindings.target_kind = excluded.target_kind
and device_resource_bindings.target_ref = excluded.target_ref
returning id, owner_scope_id, project_id, binding_key, display_name,
source_kind, device_id, collection_id, target_kind, target_ref,
capabilities, lifecycle_state, source_approved_at, created_at, updated_at,
(xmax = 0) as created`,
[
bindingId,
project.owner_scope_id,
project.id,
command.bindingKey,
command.displayName,
command.source.kind,
command.source.kind === "device" ? source.id : null,
command.source.kind === "collection" ? source.id : null,
command.targetKind,
command.targetRef,
command.capabilities,
actor.userRef,
],
);
const binding = result.rows[0];
if (!binding) throw domainError("device_binding_identity_conflict", 409);
await addAudit(client, {
eventType: binding.created
? "device_binding.created"
: "device_binding.updated",
actorRef: actor.userRef,
projectId: project.id,
deviceId: binding.device_id,
payload: {
bindingRef: `binding:${binding.id}`,
projectRef: toProjectRef(project.id),
bindingKey: binding.binding_key,
sourceKind: binding.source_kind,
sourceRef: bindingSourceRef(binding),
targetKind: binding.target_kind,
targetRef: binding.target_ref,
lifecycleState: binding.lifecycle_state,
},
});
return {
created: binding.created === true,
binding: bindingView(binding),
};
}
async function revokeBinding(client, actor, command) {
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
"binding.manage",
);
const result = await client.query(
`update device_resource_bindings
set lifecycle_state = 'revoked',
revoked_at = now(),
revoked_by_ref = $3,
revocation_code = $4,
updated_at = now()
where id = $1
and project_id = $2
and lifecycle_state <> 'revoked'
returning id, owner_scope_id, project_id, binding_key, display_name,
source_kind, device_id, collection_id, target_kind, target_ref,
capabilities, lifecycle_state, source_approved_at, created_at, updated_at`,
[command.bindingId, project.id, actor.userRef, command.resolutionCode],
);
const binding = result.rows[0];
if (!binding) throw domainError("device_binding_not_found", 404);
await addAudit(client, {
eventType: "device_binding.revoked",
actorRef: actor.userRef,
projectId: project.id,
deviceId: binding.device_id,
payload: {
bindingRef: `binding:${binding.id}`,
projectRef: toProjectRef(project.id),
sourceKind: binding.source_kind,
sourceRef: bindingSourceRef(binding),
targetKind: binding.target_kind,
targetRef: binding.target_ref,
lifecycleState: binding.lifecycle_state,
resolutionCode: command.resolutionCode,
},
});
return {
revoked: true,
binding: bindingView(binding),
resolutionCode: command.resolutionCode,
};
}
async function createConfigurationRevision(client, actor, command) {
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
"configuration.manage",
);
const device = await findDirectDeviceForUpdate(client, command);
const profileResult = await client.query(
`select profile_ref, schema_artifact_ref, lifecycle_state
from device_model_profiles
where profile_ref = $1
for share`,
[device.model_profile_ref],
);
const profile = profileResult.rows[0];
if (
!profile
|| profile.lifecycle_state !== "active"
|| !profile.schema_artifact_ref
) {
throw domainError("device_configuration_profile_unavailable", 409);
}
const nextResult = await client.query(
`select coalesce(max(revision_number), 0) + 1 as next_revision
from device_configuration_revisions
where device_id = $1`,
[device.id],
);
const revisionNumber = Number(nextResult.rows[0]?.next_revision);
if (!Number.isSafeInteger(revisionNumber) || revisionNumber < 1) {
throw domainError("device_configuration_revision_sequence_invalid", 409);
}
const revisionId = randomUUID();
const inserted = await client.query(
`insert into device_configuration_revisions (
id,
owner_scope_id,
project_id,
device_id,
revision_number,
model_profile_ref,
schema_artifact_ref,
configuration_digest,
configuration,
change_summary,
created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11)
returning id, owner_scope_id, project_id, device_id, revision_number,
model_profile_ref, schema_artifact_ref, configuration_digest,
configuration, change_summary, created_at`,
[
revisionId,
project.owner_scope_id,
project.id,
device.id,
revisionNumber,
profile.profile_ref,
profile.schema_artifact_ref,
command.configurationDigest,
JSON.stringify(command.configuration),
command.changeSummary,
actor.userRef,
],
);
const revision = inserted.rows[0];
if (!revision) {
throw domainError("device_configuration_revision_insert_failed", 409);
}
await addAudit(client, {
eventType: "device_configuration_revision.created",
actorRef: actor.userRef,
projectId: project.id,
deviceId: device.id,
payload: {
deviceRef: `device:${device.id}`,
projectRef: toProjectRef(project.id),
configurationRevisionRef: `configuration-revision:${revision.id}`,
revisionNumber: Number(revision.revision_number),
modelProfileRef: revision.model_profile_ref,
schemaArtifactRef: revision.schema_artifact_ref,
configurationDigest: revision.configuration_digest,
},
});
return {
created: true,
configurationRevision: configurationRevisionView(revision),
};
}
async function setDesiredConfiguration(client, actor, command) {
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
"configuration.manage",
);
const device = await findDirectDeviceForUpdate(client, command);
const revisionResult = await client.query(
`select id, project_id, device_id, revision_number,
model_profile_ref, schema_artifact_ref, configuration_digest,
configuration, change_summary, created_at
from device_configuration_revisions
where id = $1 and device_id = $2 and project_id = $3
for share`,
[command.configurationRevisionId, device.id, project.id],
);
const revision = revisionResult.rows[0];
if (!revision) throw domainError("device_configuration_revision_not_found", 404);
const currentResult = await client.query(
`select desired_revision_id, applied_revision_id
from device_configuration_state
where device_id = $1
for update`,
[device.id],
);
const current = currentResult.rows[0] ?? null;
if (current?.desired_revision_id === revision.id) {
return {
changed: false,
configurationState: configurationStateView({
device_id: device.id,
project_id: project.id,
desired_revision_id: revision.id,
applied_revision_id: current.applied_revision_id,
}),
};
}
const stateResult = await client.query(
`insert into device_configuration_state (
device_id,
owner_scope_id,
project_id,
desired_revision_id
) values ($1, $2, $3, $4)
on conflict (device_id) do update set
owner_scope_id = excluded.owner_scope_id,
project_id = excluded.project_id,
desired_revision_id = excluded.desired_revision_id,
updated_at = now()
returning device_id, project_id, desired_revision_id, applied_revision_id`,
[device.id, project.owner_scope_id, project.id, revision.id],
);
const state = stateResult.rows[0];
if (!state) throw domainError("device_configuration_state_update_failed", 409);
await addAudit(client, {
eventType: "device_configuration.desired_changed",
actorRef: actor.userRef,
projectId: project.id,
deviceId: device.id,
payload: {
deviceRef: `device:${device.id}`,
projectRef: toProjectRef(project.id),
configurationRevisionRef: `configuration-revision:${revision.id}`,
previousConfigurationRevisionRef: current?.desired_revision_id
? `configuration-revision:${current.desired_revision_id}`
: null,
configurationDigest: revision.configuration_digest,
},
});
return {
changed: true,
configurationState: configurationStateView(state),
};
}
async function findBindingSource(client, command) {
if (command.source.kind === "device") {
return findDirectDeviceForUpdate(client, {
projectId: command.projectId,
deviceId: command.source.id,
});
}
const result = await client.query(
`select id, project_id, lifecycle_state
from device_collections
where id = $1 and project_id = $2
for share`,
[command.source.id, command.projectId],
);
const collection = result.rows[0];
if (!collection) throw domainError("device_collection_not_found", 404);
if (collection.lifecycle_state !== "active") {
throw domainError("device_collection_inactive", 409);
}
return collection;
}
async function findDirectDeviceForUpdate(client, command) {
const result = await client.query(
`select id, contour_id, owner_scope_id, project_id,
model_profile_ref, lifecycle_state
from device_instances
where id = $1
for update`,
[command.deviceId],
);
const device = result.rows[0];
if (!device) throw domainError("device_not_found", 404);
if (
device.contour_id
|| !device.owner_scope_id
|| !device.project_id
|| device.project_id !== command.projectId
) {
throw domainError("device_control_resource_project_mismatch", 409);
}
if (device.lifecycle_state === "retired") {
throw domainError("device_control_resource_lifecycle_blocked", 409);
}
return device;
}
async function addAudit(client, {
eventType,
actorRef,
projectId,
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,
actorRef,
projectId,
deviceId,
JSON.stringify(payload),
],
);
}
function bindingView(row) {
return {
bindingRef: `binding:${row.id}`,
projectRef: toProjectRef(row.project_id),
bindingKey: row.binding_key,
displayName: row.display_name,
source: {
kind: row.source_kind,
ref: bindingSourceRef(row),
},
target: {
kind: row.target_kind,
ref: row.target_ref,
},
capabilities: row.capabilities ?? [],
lifecycleState: row.lifecycle_state,
sourceApprovedAt: toIso(row.source_approved_at),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function bindingSourceRef(row) {
return row.source_kind === "device"
? `device:${row.device_id}`
: `collection:${row.collection_id}`;
}
function configurationRevisionView(row) {
return {
configurationRevisionRef: `configuration-revision:${row.id}`,
deviceRef: `device:${row.device_id}`,
projectRef: toProjectRef(row.project_id),
revisionNumber: Number(row.revision_number),
modelProfileRef: row.model_profile_ref,
schemaArtifactRef: row.schema_artifact_ref,
configurationDigest: row.configuration_digest,
configuration: row.configuration,
changeSummary: row.change_summary ?? null,
createdAt: toIso(row.created_at),
};
}
function configurationStateView(row) {
return {
deviceRef: `device:${row.device_id}`,
projectRef: toProjectRef(row.project_id),
desiredConfigurationRevisionRef: row.desired_revision_id
? `configuration-revision:${row.desired_revision_id}`
: null,
appliedConfigurationRevisionRef: row.applied_revision_id
? `configuration-revision:${row.applied_revision_id}`
: null,
};
}
function toIso(value) {
return new Date(value).toISOString();
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -371,6 +371,47 @@ async function transferDevice(client, actor, command) {
throw domainError("device_transfer_active_credential_binding", 409); throw domainError("device_transfer_active_credential_binding", 409);
} }
const activeResourceBindings = await client.query(
`select exists (
select 1 from device_resource_bindings
where device_id = $1
and lifecycle_state in ('pending_external_approval', 'active')
) as active`,
[device.id],
);
if (activeResourceBindings.rows[0]?.active === true) {
throw domainError("device_transfer_active_resource_binding", 409);
}
const configurationState = await client.query(
`select desired_revision_id, applied_revision_id
from device_configuration_state
where device_id = $1
for update`,
[device.id],
);
if (configurationState.rows[0]?.applied_revision_id) {
throw domainError("device_transfer_applied_configuration", 409);
}
const nonterminalCommands = await client.query(
`select exists (
select 1 from device_commands
where device_id = $1
and lifecycle_state not in ('verified', 'failed', 'expired', 'unknown')
) as active`,
[device.id],
);
if (nonterminalCommands.rows[0]?.active === true) {
throw domainError("device_transfer_nonterminal_command", 409);
}
const clearedConfiguration = await client.query(
`delete from device_configuration_state
where device_id = $1 and applied_revision_id is null`,
[device.id],
);
const detached = await client.query( const detached = await client.query(
`delete from device_collection_members `delete from device_collection_members
where device_id = $1 and project_id = $2`, where device_id = $1 and project_id = $2`,
@@ -437,6 +478,7 @@ async function transferDevice(client, actor, command) {
targetProjectRef: toProjectRef(targetProject.id), targetProjectRef: toProjectRef(targetProject.id),
detachedCollectionCount: Number(detached.rowCount || 0), detachedCollectionCount: Number(detached.rowCount || 0),
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0), transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0,
}; };
await addAudit(client, { await addAudit(client, {
eventType: "device.transferred_out", eventType: "device.transferred_out",
@@ -459,6 +501,7 @@ async function transferDevice(client, actor, command) {
ownershipTransitionRef: `ownership-transition:${transitionId}`, ownershipTransitionRef: `ownership-transition:${transitionId}`,
detachedCollectionCount: Number(detached.rowCount || 0), detachedCollectionCount: Number(detached.rowCount || 0),
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0), transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0,
}; };
} }
@@ -3,6 +3,11 @@ import {
isInfrastructureManagementCommand, isInfrastructureManagementCommand,
normalizeInfrastructureManagementCommand, normalizeInfrastructureManagementCommand,
} from "./infrastructure-management.mjs"; } from "./infrastructure-management.mjs";
import {
DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
isControlResourceManagementCommand,
normalizeControlResourceManagementCommand,
} from "./control-resource-management.mjs";
import { import {
DEVICE_LIFECYCLE_COMMAND_KINDS, DEVICE_LIFECYCLE_COMMAND_KINDS,
isLifecycleManagementCommand, isLifecycleManagementCommand,
@@ -23,9 +28,13 @@ export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
...DEVICE_INFRASTRUCTURE_COMMAND_KINDS, ...DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
...DEVICE_LIFECYCLE_COMMAND_KINDS, ...DEVICE_LIFECYCLE_COMMAND_KINDS,
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS, ...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
]); ]);
export function normalizeDeviceManagementCommand(kind, input) { export function normalizeDeviceManagementCommand(kind, input) {
if (isControlResourceManagementCommand(kind)) {
return normalizeControlResourceManagementCommand(kind, input);
}
if (isSensitiveReferenceManagementCommand(kind)) { if (isSensitiveReferenceManagementCommand(kind)) {
return normalizeSensitiveReferenceManagementCommand(kind, input); return normalizeSensitiveReferenceManagementCommand(kind, input);
} }
@@ -7,6 +7,13 @@ import pg from "pg";
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs"; import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
import { observeQuarantineDiscovery } from "./discovery-repository.mjs"; import { observeQuarantineDiscovery } from "./discovery-repository.mjs";
import {
applyControlResourceManagementCommand,
authorizeControlResourceManagementReplay,
} from "./control-resource-repository.mjs";
import {
isControlResourceManagementCommand,
} from "./control-resource-management.mjs";
import { import {
applyInfrastructureManagementCommand, applyInfrastructureManagementCommand,
authorizeInfrastructureManagementReplay, authorizeInfrastructureManagementReplay,
@@ -43,6 +50,8 @@ const migrationFiles = [
"007_device_lifecycle_commands.sql", "007_device_lifecycle_commands.sql",
"008_device_sensitive_references.sql", "008_device_sensitive_references.sql",
"009_device_sensitive_reference_commands.sql", "009_device_sensitive_reference_commands.sql",
"010_device_control_resources.sql",
"011_device_control_resource_commands.sql",
]; ];
export class PostgresDeviceRepository { export class PostgresDeviceRepository {
@@ -226,6 +235,13 @@ async function completeManagementReceipt(client, receiptId, result) {
} }
async function applyManagementCommand(client, { commandKind, actor, command }) { async function applyManagementCommand(client, { commandKind, actor, command }) {
if (isControlResourceManagementCommand(commandKind)) {
return applyControlResourceManagementCommand(client, {
commandKind,
actor,
command,
});
}
if (isSensitiveReferenceManagementCommand(commandKind)) { if (isSensitiveReferenceManagementCommand(commandKind)) {
return applySensitiveReferenceManagementCommand(client, { return applySensitiveReferenceManagementCommand(client, {
commandKind, commandKind,
@@ -263,6 +279,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
} }
async function authorizeManagementReplay(client, { commandKind, actor, command }) { async function authorizeManagementReplay(client, { commandKind, actor, command }) {
if (isControlResourceManagementCommand(commandKind)) {
return authorizeControlResourceManagementReplay(client, {
commandKind,
actor,
command,
});
}
if (isSensitiveReferenceManagementCommand(commandKind)) { if (isSensitiveReferenceManagementCommand(commandKind)) {
return authorizeSensitiveReferenceManagementReplay(client, { return authorizeSensitiveReferenceManagementReplay(client, {
commandKind, commandKind,
@@ -11,6 +11,7 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
"binding.manage", "binding.manage",
"telemetry.observe", "telemetry.observe",
"configuration.read", "configuration.read",
"configuration.manage",
"command.plan", "command.plan",
"command.confirm", "command.confirm",
"command.dispatch", "command.dispatch",
@@ -76,6 +77,7 @@ const roleCapabilities = Object.freeze({
"binding.manage", "binding.manage",
"telemetry.observe", "telemetry.observe",
"configuration.read", "configuration.read",
"configuration.manage",
"command.plan", "command.plan",
"audit.read", "audit.read",
]), ]),
@@ -91,6 +93,7 @@ const roleCapabilities = Object.freeze({
"binding.manage", "binding.manage",
"telemetry.observe", "telemetry.observe",
"configuration.read", "configuration.read",
"configuration.manage",
"command.plan", "command.plan",
"command.confirm", "command.confirm",
"command.dispatch", "command.dispatch",
@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
normalizeControlResourceManagementCommand,
} from "../src/control-resource-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 collectionRef = "collection:33333333-3333-4333-8333-333333333333";
const bindingRef = "binding:44444444-4444-4444-8444-444444444444";
const revisionRef =
"configuration-revision:55555555-5555-4555-8555-555555555555";
test("control resource commands join the strict idempotent surface", () => {
for (const kind of DEVICE_CONTROL_RESOURCE_COMMAND_KINDS) {
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true);
}
assert.equal(
normalizeDeviceManagementCommand(
"device_binding.ensure",
bindingInput(),
).projectId,
projectRef.slice("project:".length),
);
});
test("binding input is source-scoped and cannot claim external approval", () => {
const command = normalizeControlResourceManagementCommand(
"device_binding.ensure",
bindingInput(),
);
assert.deepEqual(command.source, {
kind: "collection",
id: collectionRef.slice("collection:".length),
});
assert.deepEqual(command.capabilities, ["inspect", "observe"]);
assert.equal("lifecycleState" in command, false);
assert.equal("externalApprovalRef" in command, false);
assert.throws(
() => normalizeControlResourceManagementCommand(
"device_binding.ensure",
{ ...bindingInput(), externalApprovalRef: "approval:forged" },
),
/device_management_command_field_unexpected:externalApprovalRef/,
);
assert.throws(
() => normalizeControlResourceManagementCommand(
"device_binding.ensure",
{ ...bindingInput(), targetRef: "ndc-credref:must-not-be-a-target" },
),
/device_binding_target_ref_invalid/,
);
});
test("binding revoke uses only project, binding and bounded reason refs", () => {
const command = normalizeControlResourceManagementCommand(
"device_binding.revoke",
{
projectRef,
bindingRef,
resolutionCode: "operator.unbound",
},
);
assert.equal(command.bindingId, bindingRef.slice("binding:".length));
assert.equal(command.resolutionCode, "operator.unbound");
});
test("configuration is canonical, bounded and secret-free before hashing", () => {
const first = normalizeControlResourceManagementCommand(
"device_configuration_revision.create",
{
projectRef,
deviceRef,
configuration: {
reporting_interval_seconds: 15,
motion: { enabled: true, threshold: 3.5 },
channels: ["gps", "voltage"],
},
changeSummary: "Pilot reporting profile",
},
);
const reordered = normalizeControlResourceManagementCommand(
"device_configuration_revision.create",
{
projectRef,
deviceRef,
configuration: {
channels: ["gps", "voltage"],
motion: { threshold: 3.5, enabled: true },
reporting_interval_seconds: 15,
},
changeSummary: "Pilot reporting profile",
},
);
assert.equal(first.configurationDigest, reordered.configurationDigest);
assert.equal(Object.isFrozen(first.configuration.motion), true);
assert.throws(
() => normalizeControlResourceManagementCommand(
"device_configuration_revision.create",
{
projectRef,
deviceRef,
configuration: { api_token: "forbidden" },
},
),
/forbidden_device_field/,
);
assert.throws(
() => normalizeControlResourceManagementCommand(
"device_configuration_revision.create",
{
projectRef,
deviceRef,
configuration: { tracker_imei: "000000000000001" },
},
),
/safe_projection_contains_unmasked_imei/,
);
});
test("desired configuration binds one exact immutable revision", () => {
const command = normalizeControlResourceManagementCommand(
"device_configuration_desired.set",
{
projectRef,
deviceRef,
configurationRevisionRef: revisionRef,
},
);
assert.equal(command.deviceId, deviceRef.slice("device:".length));
assert.equal(
command.configurationRevisionId,
revisionRef.slice("configuration-revision:".length),
);
assert.equal("applied" in command, false);
});
function bindingInput() {
return {
projectRef,
bindingKey: "robot2b-map",
displayName: "Robot2B map binding",
source: { kind: "collection", ref: collectionRef },
targetKind: "foundry.application",
targetRef: "foundry-application:robot2b-test",
capabilities: ["observe", "inspect"],
};
}
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const schemaUrl = new URL(
"../migrations/010_device_control_resources.sql",
import.meta.url,
);
const commandsUrl = new URL(
"../migrations/011_device_control_resource_commands.sql",
import.meta.url,
);
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
const appUrl = new URL("../src/app.mjs", import.meta.url);
test("control resource schema separates bindings, revisions and current state", async () => {
const sql = await readFile(schemaUrl, "utf8");
assert.match(sql, /create table if not exists device_resource_bindings/);
assert.match(sql, /pending_external_approval/);
assert.match(sql, /external_approval_digest/);
assert.match(sql, /device_binding_source_scope_mismatch/);
assert.match(sql, /create table if not exists device_configuration_revisions/);
assert.match(sql, /create table if not exists device_configuration_state/);
assert.match(sql, /device_configuration_revision_scope_mismatch/);
assert.match(sql, /unique \(device_id, revision_number\)/);
});
test("command ledger has honest ordered states without a transport API", async () => {
const sql = await readFile(schemaUrl, "utf8");
const app = await readFile(appUrl, "utf8");
assert.match(sql, /create table if not exists device_commands/);
assert.match(sql, /create table if not exists device_command_events/);
for (const state of [
"draft",
"planned",
"awaiting_confirmation",
"queued",
"dispatched",
"acknowledged",
"verified",
"failed",
"expired",
"unknown",
]) {
assert.match(sql, new RegExp(`'${state}'`));
}
assert.match(sql, /device_command_initial_event_invalid/);
assert.match(sql, /device_command_event_sequence_invalid/);
assert.match(sql, /device_command_event_transition_invalid/);
assert.match(sql, /device_command_event_projection_mismatch/);
assert.match(sql, /device_command_events_current_projection_guard/);
assert.match(sql, /device_command_current_projection_mismatch/);
assert.doesNotMatch(app, /device-commands:(?:plan|confirm|dispatch)/);
});
test("configuration, command history and audit are append-only", async () => {
const sql = await readFile(schemaUrl, "utf8");
for (const table of [
"device_configuration_revisions",
"device_command_events",
"device_audit_events",
]) {
assert.match(
sql,
new RegExp(`${table}_immutable_guard[\\s\\S]*before update or delete or truncate`),
);
}
assert.match(sql, /device_immutable_record_mutation_forbidden/);
assert.match(sql, /device_transfer_active_resource_binding/);
assert.match(sql, /device_transfer_applied_configuration/);
assert.match(sql, /device_transfer_nonterminal_command/);
});
test("control resource schema contains no seeded device or raw secret material", async () => {
const sql = await readFile(schemaUrl, "utf8");
assert.doesNotMatch(sql, /insert\s+into/i);
assert.doesNotMatch(sql, /dcctouch|arusnavi|gelios|\bb2\b|imei/i);
assert.doesNotMatch(sql, /password\s+text|token\s+text|secret\s+text|raw_command|raw_packet/i);
});
test("commands extend receipts only after their schema", async () => {
const commands = await readFile(commandsUrl, "utf8");
const repository = await readFile(repositoryUrl, "utf8");
for (const kind of [
"device_binding.ensure",
"device_binding.revoke",
"device_configuration_revision.create",
"device_configuration_desired.set",
]) {
assert.match(commands, new RegExp(`'${kind.replace(".", "\\.")}'`));
}
const schemaIndex = repository.indexOf("010_device_control_resources.sql");
const commandsIndex = repository.indexOf(
"011_device_control_resource_commands.sql",
);
assert.notEqual(schemaIndex, -1);
assert.notEqual(commandsIndex, -1);
assert.ok(schemaIndex < commandsIndex);
});
@@ -0,0 +1,313 @@
import assert from "node:assert/strict";
import test from "node:test";
import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs";
import { normalizeDeviceManagementCommand } from "../src/management-command.mjs";
import { PostgresDeviceRepository } from "../src/postgres-repository.mjs";
import { normalizeManagementActor } from "../src/project-management.mjs";
const now = new Date("2026-08-10T00:00:00.000Z");
const projectId = "11111111-1111-4111-8111-111111111111";
const ownerId = "22222222-2222-4222-8222-222222222222";
const deviceId = "33333333-3333-4333-8333-333333333333";
const collectionId = "44444444-4444-4444-8444-444444444444";
const bindingId = "55555555-5555-4555-8555-555555555555";
const revisionId = "66666666-6666-4666-8666-666666666666";
test("creates only a pending collection binding owned by the source project", async () => {
const actor = managementActor();
const command = normalizeDeviceManagementCommand("device_binding.ensure", {
projectRef: `project:${projectId}`,
bindingKey: "robot2b-map",
displayName: "Robot2B map binding",
source: { kind: "collection", ref: `collection:${collectionId}` },
targetKind: "foundry.application",
targetRef: "foundry-application:robot2b-test",
capabilities: ["observe", "inspect"],
});
const client = scriptedClient([
step("begin"),
receiptStep("receipt-binding"),
projectStep(),
grantsStep(actor),
step("from device_collections", {
rows: [{ id: collectionId, project_id: projectId, lifecycle_state: "active" }],
}),
step("insert into device_resource_bindings", {
rows: [bindingRow({ source_kind: "collection", device_id: null })],
}),
step("insert into device_audit_events"),
step("update device_management_command_receipts"),
step("commit"),
]);
const repository = repositoryWithClient(client);
const result = await repository.executeManagementCommand(commandInput({
actor,
commandKind: "device_binding.ensure",
command,
digestCharacter: "a",
}));
assert.equal(result.result.binding.lifecycleState, "pending_external_approval");
assert.equal(result.result.binding.source.ref, `collection:${collectionId}`);
assert.equal("externalApprovalRef" in result.result.binding, false);
assertSafeProjection(result.result);
assert.equal(client.remaining(), 0);
});
test("creates an immutable configuration revision from the active profile schema", async () => {
const actor = managementActor();
const command = createConfigurationCommand();
const client = scriptedClient([
step("begin"),
receiptStep("receipt-configuration-revision"),
projectStep(),
grantsStep(actor),
step("from device_instances", { rows: [deviceRow()] }),
step("from device_model_profiles", {
rows: [{
profile_ref: "vendor.model.protocol.v1",
schema_artifact_ref: "schema:vendor.model.protocol.v1",
lifecycle_state: "active",
}],
}),
step("from device_configuration_revisions", {
rows: [{ next_revision: "1" }],
}),
step("insert into device_configuration_revisions", {
rows: [configurationRevisionRow({
configuration_digest: command.configurationDigest,
configuration: command.configuration,
})],
}),
step("insert into device_audit_events"),
step("update device_management_command_receipts"),
step("commit"),
]);
const repository = repositoryWithClient(client);
const result = await repository.executeManagementCommand(commandInput({
actor,
commandKind: "device_configuration_revision.create",
command,
digestCharacter: "b",
}));
assert.equal(result.result.configurationRevision.revisionNumber, 1);
assert.deepEqual(result.result.configurationRevision.configuration, {
reporting_interval_seconds: 15,
});
assert.equal(
result.result.configurationRevision.schemaArtifactRef,
"schema:vendor.model.protocol.v1",
);
assertSafeProjection(result.result);
assert.equal(client.remaining(), 0);
});
test("sets desired configuration without claiming runtime apply", async () => {
const actor = managementActor();
const command = normalizeDeviceManagementCommand(
"device_configuration_desired.set",
{
projectRef: `project:${projectId}`,
deviceRef: `device:${deviceId}`,
configurationRevisionRef: `configuration-revision:${revisionId}`,
},
);
const client = scriptedClient([
step("begin"),
receiptStep("receipt-configuration-desired"),
projectStep(),
grantsStep(actor),
step("from device_instances", { rows: [deviceRow()] }),
step("from device_configuration_revisions", {
rows: [configurationRevisionRow()],
}),
step("from device_configuration_state", { rows: [] }),
step("insert into device_configuration_state", {
rows: [{
device_id: deviceId,
project_id: projectId,
desired_revision_id: revisionId,
applied_revision_id: null,
}],
}),
step("insert into device_audit_events"),
step("update device_management_command_receipts"),
step("commit"),
]);
const repository = repositoryWithClient(client);
const result = await repository.executeManagementCommand(commandInput({
actor,
commandKind: "device_configuration_desired.set",
command,
digestCharacter: "c",
}));
assert.equal(result.result.changed, true);
assert.equal(
result.result.configurationState.desiredConfigurationRevisionRef,
`configuration-revision:${revisionId}`,
);
assert.equal(
result.result.configurationState.appliedConfigurationRevisionRef,
null,
);
assert.equal("applied" in result.result, false);
assert.equal(client.remaining(), 0);
});
function createConfigurationCommand() {
return normalizeDeviceManagementCommand(
"device_configuration_revision.create",
{
projectRef: `project:${projectId}`,
deviceRef: `device:${deviceId}`,
configuration: { reporting_interval_seconds: 15 },
changeSummary: "Pilot reporting profile",
},
);
}
function managementActor() {
return normalizeManagementActor({
userRef: "user:device-engineer",
hubRole: "member",
groupRefs: [],
ownerScopes: [],
});
}
function projectStep() {
return step("from device_projects p", {
rows: [{
id: projectId,
owner_scope_id: ownerId,
lifecycle_state: "active",
scope_kind: "company",
owner_ref: "client:example-company",
owner_display_name: "Example Company",
owner_lifecycle_state: "active",
}],
});
}
function grantsStep(actor) {
return step("from device_project_grants", {
rows: [{
id: "77777777-7777-4777-8777-777777777777",
principal_kind: "user",
principal_ref: actor.userRef,
project_role: "engineer",
capability_allow: [],
capability_deny: [],
lifecycle_state: "active",
}],
});
}
function deviceRow() {
return {
id: deviceId,
contour_id: null,
owner_scope_id: ownerId,
project_id: projectId,
model_profile_ref: "vendor.model.protocol.v1",
lifecycle_state: "claimed",
};
}
function bindingRow(overrides = {}) {
return {
id: bindingId,
owner_scope_id: ownerId,
project_id: projectId,
binding_key: "robot2b-map",
display_name: "Robot2B map binding",
source_kind: "device",
device_id: deviceId,
collection_id: collectionId,
target_kind: "foundry.application",
target_ref: "foundry-application:robot2b-test",
capabilities: ["inspect", "observe"],
lifecycle_state: "pending_external_approval",
source_approved_at: now,
created_at: now,
updated_at: now,
created: true,
...overrides,
};
}
function configurationRevisionRow(overrides = {}) {
return {
id: revisionId,
owner_scope_id: ownerId,
project_id: projectId,
device_id: deviceId,
revision_number: "1",
model_profile_ref: "vendor.model.protocol.v1",
schema_artifact_ref: "schema:vendor.model.protocol.v1",
configuration_digest: `sha256:${"d".repeat(64)}`,
configuration: { reporting_interval_seconds: 15 },
change_summary: "Pilot reporting profile",
created_at: now,
...overrides,
};
}
function receiptStep(id) {
return step("insert into device_management_command_receipts", {
rows: [{ id }],
});
}
function commandInput({ actor, commandKind, command, digestCharacter }) {
return {
idempotencyKey: `phase25-${commandKind.replaceAll(".", "-")}-0001`,
commandKind,
requestDigest: `sha256:${digestCharacter.repeat(64)}`,
actor,
command,
};
}
function repositoryWithClient(client) {
return new PostgresDeviceRepository({
pool: {
query: async () => ({ rows: [] }),
connect: async () => client,
end: async () => undefined,
},
});
}
function step(includes, result = { rows: [] }) {
return { includes, result };
}
function scriptedClient(steps) {
const queue = [...steps];
return {
released: false,
async query(sql) {
const next = queue.shift();
assert.ok(next, `Unexpected query: ${sql}`);
assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i"));
return next.result;
},
release() {
this.released = true;
},
remaining() {
return queue.length;
},
};
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -111,6 +111,10 @@ test("authorized transfer preserves history and detaches source collections", as
grantsStep(actor, "owner"), grantsStep(actor, "owner"),
step("from device_sessions", { rows: [{ active: false }] }), step("from device_sessions", { rows: [{ active: false }] }),
step("from device_credential_bindings", { rows: [{ active: false }] }), step("from device_credential_bindings", { rows: [{ active: false }] }),
step("from device_resource_bindings", { rows: [{ active: false }] }),
step("from device_configuration_state", { rows: [] }),
step("from device_commands", { rows: [{ active: false }] }),
step("delete from device_configuration_state", { rows: [], rowCount: 0 }),
step("delete from device_collection_members", { rows: [], rowCount: 2 }), step("delete from device_collection_members", { rows: [], rowCount: 2 }),
step("update device_instances", { step("update device_instances", {
rows: [deviceRow({ rows: [deviceRow({
@@ -139,6 +143,7 @@ test("authorized transfer preserves history and detaches source collections", as
assert.equal(result.result.device.projectRef, `project:${targetProjectId}`); assert.equal(result.result.device.projectRef, `project:${targetProjectId}`);
assert.equal(result.result.detachedCollectionCount, 2); assert.equal(result.result.detachedCollectionCount, 2);
assert.equal(result.result.transferredIdentifierCount, 1); assert.equal(result.result.transferredIdentifierCount, 1);
assert.equal(result.result.clearedDesiredConfiguration, false);
assert.equal(client.remaining(), 0); assert.equal(client.remaining(), 0);
assert.equal(client.released, true); assert.equal(client.released, true);
}); });
@@ -173,6 +178,104 @@ test("transfer fails closed while a credential binding is active", async () => {
assert.equal(client.released, true); assert.equal(client.released, true);
}); });
test("transfer fails closed while a resource binding is pending approval", async () => {
const actor = managementActor("owner");
const command = transferCommand();
const client = scriptedClient([
step("begin"),
receiptStep("receipt-transfer-resource-bound"),
step("from device_instances", { rows: [deviceRow()] }),
projectStep(sourceProjectId, sourceOwnerId),
grantsStep(actor, "owner"),
projectStep(targetProjectId, targetOwnerId),
grantsStep(actor, "owner"),
step("from device_sessions", { rows: [{ active: false }] }),
step("from device_credential_bindings", { rows: [{ active: false }] }),
step("from device_resource_bindings", { rows: [{ active: true }] }),
step("rollback"),
]);
const repository = repositoryWithClient(client);
await assert.rejects(
repository.executeManagementCommand(commandInput({
actor,
commandKind: "device.transfer",
command,
digestCharacter: "1",
})),
/device_transfer_active_resource_binding/,
);
assert.equal(client.remaining(), 0);
});
test("transfer fails closed with applied configuration", async () => {
const actor = managementActor("owner");
const command = transferCommand();
const client = scriptedClient([
step("begin"),
receiptStep("receipt-transfer-applied-config"),
step("from device_instances", { rows: [deviceRow()] }),
projectStep(sourceProjectId, sourceOwnerId),
grantsStep(actor, "owner"),
projectStep(targetProjectId, targetOwnerId),
grantsStep(actor, "owner"),
step("from device_sessions", { rows: [{ active: false }] }),
step("from device_credential_bindings", { rows: [{ active: false }] }),
step("from device_resource_bindings", { rows: [{ active: false }] }),
step("from device_configuration_state", {
rows: [{
desired_revision_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
applied_revision_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
}],
}),
step("rollback"),
]);
const repository = repositoryWithClient(client);
await assert.rejects(
repository.executeManagementCommand(commandInput({
actor,
commandKind: "device.transfer",
command,
digestCharacter: "2",
})),
/device_transfer_applied_configuration/,
);
assert.equal(client.remaining(), 0);
});
test("transfer fails closed with a nonterminal command", async () => {
const actor = managementActor("owner");
const command = transferCommand();
const client = scriptedClient([
step("begin"),
receiptStep("receipt-transfer-command-active"),
step("from device_instances", { rows: [deviceRow()] }),
projectStep(sourceProjectId, sourceOwnerId),
grantsStep(actor, "owner"),
projectStep(targetProjectId, targetOwnerId),
grantsStep(actor, "owner"),
step("from device_sessions", { rows: [{ active: false }] }),
step("from device_credential_bindings", { rows: [{ active: false }] }),
step("from device_resource_bindings", { rows: [{ active: false }] }),
step("from device_configuration_state", { rows: [] }),
step("from device_commands", { rows: [{ active: true }] }),
step("rollback"),
]);
const repository = repositoryWithClient(client);
await assert.rejects(
repository.executeManagementCommand(commandInput({
actor,
commandKind: "device.transfer",
command,
digestCharacter: "3",
})),
/device_transfer_nonterminal_command/,
);
assert.equal(client.remaining(), 0);
});
test("reject resolves both quarantine and enrollment without exposing a digest", async () => { test("reject resolves both quarantine and enrollment without exposing a digest", async () => {
const actor = managementActor("member"); const actor = managementActor("member");
const command = normalizeDeviceManagementCommand("discovery.reject", { const command = normalizeDeviceManagementCommand("discovery.reject", {
@@ -15,8 +15,11 @@ test("management surface is internal, POST-only and disabled by default", async
assert.match(source, /\/internal\/v1\/management\/projects:ensure/); assert.match(source, /\/internal\/v1\/management\/projects:ensure/);
assert.match(source, /\/internal\/v1\/management\/collections:ensure/); assert.match(source, /\/internal\/v1\/management\/collections:ensure/);
assert.match(source, /\/internal\/v1\/management\/project-grants:upsert/); assert.match(source, /\/internal\/v1\/management\/project-grants:upsert/);
assert.match(source, /\/internal\/v1\/management\/device-bindings:ensure/);
assert.match(source, /\/internal\/v1\/management\/device-configuration-revisions:create/);
assert.match(source, /request\.method === "POST" && managementCommandKind/); assert.match(source, /request\.method === "POST" && managementCommandKind/);
assert.doesNotMatch(source, /\/api\/public\/.*management/); assert.doesNotMatch(source, /\/api\/public\/.*management/);
assert.doesNotMatch(source, /device-commands:(?:plan|confirm|dispatch)/);
}); });
test("management token remains file-backed and is not enabled by current Compose", async () => { test("management token remains file-backed and is not enabled by current Compose", async () => {
@@ -138,6 +138,28 @@ test("matching group grants combine bounded operator and engineer capabilities",
assert.equal(access.capabilities.includes("access.manage"), false); assert.equal(access.capabilities.includes("access.manage"), false);
}); });
test("configuration mutation belongs to engineer and admin, not operator", () => {
const engineer = resolveProjectAccess({
actor: actor({ groupRefs: ["group:engineers"] }),
grants: [grant({
principalKind: "group",
principalRef: "group:engineers",
projectRole: "engineer",
})],
});
const operator = resolveProjectAccess({
actor: actor({ groupRefs: ["group:operators"] }),
grants: [grant({
principalKind: "group",
principalRef: "group:operators",
projectRole: "operator",
})],
});
assert.equal(engineer.capabilities.includes("configuration.manage"), true);
assert.equal(operator.capabilities.includes("configuration.manage"), false);
});
test("Hub ceiling and explicit deny prevent privilege escalation", () => { test("Hub ceiling and explicit deny prevent privilege escalation", () => {
const ownerGrant = grant({ const ownerGrant = grant({
grantRef: "grant:owner", grantRef: "grant:owner",