From fceaca9546f4034fbdbd4dd7841f957e331e7468 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 18:03:56 +0300 Subject: [PATCH] feat(device-core): add device ownership lifecycle --- .../device-protocol-contract/src/index.mjs | 18 + .../test/contract.test.mjs | 13 + .../006_device_lifecycle_ownership.sql | 326 ++++++++++ .../007_device_lifecycle_commands.sql | 25 + .../services/device-control-core/src/app.mjs | 6 + .../src/discovery-repository.mjs | 304 +++++++++ .../src/lifecycle-management.mjs | 139 +++++ .../src/lifecycle-repository.mjs | 575 ++++++++++++++++++ .../src/management-command.mjs | 9 + .../src/postgres-repository.mjs | 81 +-- .../device-control-core/test/app.test.mjs | 2 + ...evice-lifecycle-command-migration.test.mjs | 40 ++ .../test/device-lifecycle-migration.test.mjs | 60 ++ .../test/discovery-repository.test.mjs | 199 ++++++ .../test/infrastructure-app.test.mjs | 40 ++ .../test/lifecycle-management.test.mjs | 111 ++++ .../test/lifecycle-repository.test.mjs | 324 ++++++++++ 17 files changed, 2218 insertions(+), 54 deletions(-) create mode 100644 device-plane/services/device-control-core/migrations/006_device_lifecycle_ownership.sql create mode 100644 device-plane/services/device-control-core/migrations/007_device_lifecycle_commands.sql create mode 100644 device-plane/services/device-control-core/src/discovery-repository.mjs create mode 100644 device-plane/services/device-control-core/src/lifecycle-management.mjs create mode 100644 device-plane/services/device-control-core/src/lifecycle-repository.mjs create mode 100644 device-plane/services/device-control-core/test/device-lifecycle-command-migration.test.mjs create mode 100644 device-plane/services/device-control-core/test/device-lifecycle-migration.test.mjs create mode 100644 device-plane/services/device-control-core/test/discovery-repository.test.mjs create mode 100644 device-plane/services/device-control-core/test/lifecycle-management.test.mjs create mode 100644 device-plane/services/device-control-core/test/lifecycle-repository.test.mjs diff --git a/device-plane/packages/device-protocol-contract/src/index.mjs b/device-plane/packages/device-protocol-contract/src/index.mjs index 038edc5..1383fdd 100644 --- a/device-plane/packages/device-protocol-contract/src/index.mjs +++ b/device-plane/packages/device-protocol-contract/src/index.mjs @@ -48,6 +48,9 @@ export function normalizeDiscoverySignal(input) { } const sessionRef = normalizeOpaqueRef(input.sessionRef, "session_ref"); + const routeRef = input.routeRef == null + ? undefined + : normalizeEntityRef(input.routeRef, "route", "route_ref"); const modelProfileRef = normalizeOpaqueRef( input.modelProfileRef, "model_profile_ref", @@ -60,6 +63,7 @@ export function normalizeDiscoverySignal(input) { return Object.freeze({ schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, sessionRef, + ...(routeRef ? { routeRef } : {}), modelProfileRef, protocol, observedAt, @@ -79,6 +83,7 @@ export function toSafeDiscoveryView(signal, options = {}) { return Object.freeze({ schemaVersion: DEVICE_DISCOVERY_VIEW_SCHEMA, ...(discoveryRef ? { discoveryRef } : {}), + ...(normalized.routeRef ? { routeRef: normalized.routeRef } : {}), modelProfileRef: normalized.modelProfileRef, protocol: normalized.protocol, observedAt: normalized.observedAt, @@ -216,6 +221,19 @@ function normalizeOpaqueRef(value, label) { return value; } +function normalizeEntityRef(value, prefix, label) { + if ( + typeof value !== "string" + || !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", + ).test(value) + ) { + throw new TypeError(`${label}_invalid`); + } + return value.toLowerCase(); +} + function normalizeUpperToken(value, label) { if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) { throw new TypeError(`${label}_invalid`); diff --git a/device-plane/packages/device-protocol-contract/test/contract.test.mjs b/device-plane/packages/device-protocol-contract/test/contract.test.mjs index 9f93d48..ca28097 100644 --- a/device-plane/packages/device-protocol-contract/test/contract.test.mjs +++ b/device-plane/packages/device-protocol-contract/test/contract.test.mjs @@ -48,6 +48,19 @@ test("safe discovery projection masks the restricted identifier", () => { assertSafeProjection(view); }); +test("route-bound discovery preserves only a validated opaque route reference", () => { + const routeRef = "route:11111111-1111-4111-8111-111111111111"; + const signal = normalizeDiscoverySignal({ ...fakeSignal, routeRef }); + const view = toSafeDiscoveryView(signal); + + assert.equal(signal.routeRef, routeRef); + assert.equal(view.routeRef, routeRef); + assert.throws( + () => normalizeDiscoverySignal({ ...fakeSignal, routeRef: "route:generic" }), + /route_ref_invalid/, + ); +}); + test("identifier hashing requires a strong process-only pepper", () => { const identifier = { kind: "imei", value: fakeImei }; assert.throws( diff --git a/device-plane/services/device-control-core/migrations/006_device_lifecycle_ownership.sql b/device-plane/services/device-control-core/migrations/006_device_lifecycle_ownership.sql new file mode 100644 index 0000000..8076cee --- /dev/null +++ b/device-plane/services/device-control-core/migrations/006_device_lifecycle_ownership.sql @@ -0,0 +1,326 @@ +begin; + +create unique index if not exists device_projects_id_owner_scope_idx + on device_projects (id, owner_scope_id); + +alter table device_instances + alter column contour_id drop not null, + add column if not exists owner_scope_id uuid references device_owner_scopes(id), + add column if not exists device_key text + check ( + device_key is null + or device_key ~ '^[a-z][a-z0-9-]{1,62}$' + ); + +create unique index if not exists device_instances_project_key_idx + on device_instances (project_id, device_key) + where device_key is not null; + +create unique index if not exists device_instances_id_project_owner_idx + on device_instances (id, project_id, owner_scope_id); + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'device_instances_project_owner_fk' + and conrelid = 'device_instances'::regclass + ) then + alter table device_instances + add constraint device_instances_project_owner_fk + foreign key (project_id, owner_scope_id) + references device_projects(id, owner_scope_id) + not valid; + end if; + if not exists ( + select 1 from pg_constraint + where conname = 'device_instances_ownership_mode_check' + and conrelid = 'device_instances'::regclass + ) then + alter table device_instances + add constraint device_instances_ownership_mode_check + check ( + ( + owner_scope_id is not null + and project_id is not null + ) + or + ( + owner_scope_id is null + and project_id is null + and contour_id is not null + ) + ) not valid; + end if; +end +$$; + +alter table device_discoveries + add column if not exists session_ref text + check ( + session_ref is null + or length(btrim(session_ref)) between 3 and 256 + ), + add column if not exists project_id uuid references device_projects(id), + add column if not exists route_id uuid, + add column if not exists enrollment_intent_id uuid, + add column if not exists resolution_code text + check ( + resolution_code is null + or resolution_code ~ '^[a-z][a-z0-9._-]{1,63}$' + ), + add column if not exists resolved_at timestamptz, + add column if not exists resolved_by_ref text + check ( + resolved_by_ref is null + or length(btrim(resolved_by_ref)) between 3 and 256 + ); + +create unique index if not exists device_discoveries_id_project_idx + on device_discoveries (id, project_id); + +create index if not exists device_discoveries_route_state_seen_idx + on device_discoveries (route_id, lifecycle_state, last_observed_at desc) + where route_id is not null; + +create unique index if not exists device_enrollment_intents_context_idx + on device_enrollment_intents ( + id, + project_id, + route_id, + model_profile_ref + ); + +create unique index if not exists device_enrollment_intents_active_identity_idx + on device_enrollment_intents ( + expected_identifier_kind, + expected_identifier_digest, + model_profile_ref + ) + where lifecycle_state in ('pending', 'observed', 'claimed'); + +alter table device_enrollment_intents + add column if not exists observed_discovery_id uuid, + add column if not exists observed_at timestamptz, + add column if not exists claimed_at timestamptz, + add column if not exists resolution_code text + check ( + resolution_code is null + or resolution_code ~ '^[a-z][a-z0-9._-]{1,63}$' + ), + add column if not exists resolved_at timestamptz, + add column if not exists resolved_by_ref text + check ( + resolved_by_ref is null + or length(btrim(resolved_by_ref)) between 3 and 256 + ); + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'device_discoveries_route_context_fk' + and conrelid = 'device_discoveries'::regclass + ) then + alter table device_discoveries + add constraint device_discoveries_route_context_fk + foreign key (route_id, project_id, model_profile_ref) + references device_routes(id, project_id, model_profile_ref) + not valid; + end if; + if not exists ( + select 1 from pg_constraint + where conname = 'device_discoveries_enrollment_context_fk' + and conrelid = 'device_discoveries'::regclass + ) then + alter table device_discoveries + add constraint device_discoveries_enrollment_context_fk + foreign key ( + enrollment_intent_id, + project_id, + route_id, + model_profile_ref + ) references device_enrollment_intents ( + id, + project_id, + route_id, + model_profile_ref + ) not valid; + end if; + if not exists ( + select 1 from pg_constraint + where conname = 'device_discoveries_route_context_check' + and conrelid = 'device_discoveries'::regclass + ) then + alter table device_discoveries + add constraint device_discoveries_route_context_check + check ( + (project_id is null and route_id is null) + or + (project_id is not null and route_id is not null) + ) not valid; + end if; + if not exists ( + select 1 from pg_constraint + where conname = 'device_discoveries_enrollment_context_check' + and conrelid = 'device_discoveries'::regclass + ) then + alter table device_discoveries + add constraint device_discoveries_enrollment_context_check + check ( + enrollment_intent_id is null + or (project_id is not null and route_id is not null) + ) not valid; + end if; + if not exists ( + select 1 from pg_constraint + where conname = 'device_enrollment_observed_discovery_fk' + and conrelid = 'device_enrollment_intents'::regclass + ) then + alter table device_enrollment_intents + add constraint device_enrollment_observed_discovery_fk + foreign key (observed_discovery_id, project_id) + references device_discoveries(id, project_id) + not valid; + end if; + if not exists ( + select 1 from pg_constraint + where conname = 'device_enrollment_lifecycle_evidence_check' + and conrelid = 'device_enrollment_intents'::regclass + ) then + alter table device_enrollment_intents + add constraint device_enrollment_lifecycle_evidence_check + check ( + lifecycle_state not in ('observed', 'claimed') + or (observed_discovery_id is not null and observed_at is not null) + ) not valid; + end if; +end +$$; + +alter table device_sessions + drop constraint if exists device_sessions_device_id_project_id_fkey; + +alter table device_enrollment_intents + drop constraint if exists device_enrollment_intents_claimed_device_id_project_id_fkey; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'device_sessions_device_id_fk' + and conrelid = 'device_sessions'::regclass + ) then + alter table device_sessions + add constraint device_sessions_device_id_fk + foreign key (device_id) references device_instances(id) + not valid; + end if; + if not exists ( + select 1 from pg_constraint + where conname = 'device_enrollment_claimed_device_id_fk' + and conrelid = 'device_enrollment_intents'::regclass + ) then + alter table device_enrollment_intents + add constraint device_enrollment_claimed_device_id_fk + foreign key (claimed_device_id) references device_instances(id) + not valid; + end if; +end +$$; + +create or replace function device_assert_session_current_project() +returns trigger +language plpgsql +as $$ +begin + if new.device_id is not null and not exists ( + select 1 from device_instances di + where di.id = new.device_id + and di.project_id = new.project_id + ) then + raise foreign_key_violation using + message = 'device_session_project_mismatch'; + end if; + return new; +end +$$; + +drop trigger if exists device_sessions_current_project_guard + on device_sessions; + +create trigger device_sessions_current_project_guard +before insert or update of device_id, project_id +on device_sessions +for each row +execute function device_assert_session_current_project(); + +create or replace function device_assert_enrollment_current_project() +returns trigger +language plpgsql +as $$ +begin + if new.claimed_device_id is not null and not exists ( + select 1 from device_instances di + where di.id = new.claimed_device_id + and di.project_id = new.project_id + ) then + raise foreign_key_violation using + message = 'device_enrollment_project_mismatch'; + end if; + return new; +end +$$; + +drop trigger if exists device_enrollment_current_project_guard + on device_enrollment_intents; + +create trigger device_enrollment_current_project_guard +before insert or update of claimed_device_id, project_id +on device_enrollment_intents +for each row +execute function device_assert_enrollment_current_project(); + +create table if not exists device_ownership_transitions ( + id uuid primary key, + device_id uuid not null references device_instances(id), + transition_kind text not null + check (transition_kind in ('claim', 'transfer')), + source_owner_scope_id uuid, + source_project_id uuid, + target_owner_scope_id uuid not null, + target_project_id uuid not null, + actor_ref text not null + check (length(btrim(actor_ref)) between 3 and 256), + occurred_at timestamptz not null default now(), + foreign key (source_project_id, source_owner_scope_id) + references device_projects(id, owner_scope_id), + foreign key (target_project_id, target_owner_scope_id) + references device_projects(id, owner_scope_id), + check ( + ( + transition_kind = 'claim' + and source_owner_scope_id is null + and source_project_id is null + ) + or + ( + transition_kind = 'transfer' + and source_owner_scope_id is not null + and source_project_id is not null + and ( + source_owner_scope_id <> target_owner_scope_id + or source_project_id <> target_project_id + ) + ) + ) +); + +create unique index if not exists device_ownership_single_claim_idx + on device_ownership_transitions (device_id) + where transition_kind = 'claim'; + +create index if not exists device_ownership_device_time_idx + on device_ownership_transitions (device_id, occurred_at desc); + +commit; diff --git a/device-plane/services/device-control-core/migrations/007_device_lifecycle_commands.sql b/device-plane/services/device-control-core/migrations/007_device_lifecycle_commands.sql new file mode 100644 index 0000000..90881e0 --- /dev/null +++ b/device-plane/services/device-control-core/migrations/007_device_lifecycle_commands.sql @@ -0,0 +1,25 @@ +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' + )); + +commit; diff --git a/device-plane/services/device-control-core/src/app.mjs b/device-plane/services/device-control-core/src/app.mjs index 4ce040a..e468e75 100644 --- a/device-plane/services/device-control-core/src/app.mjs +++ b/device-plane/services/device-control-core/src/app.mjs @@ -23,6 +23,10 @@ const managementRoutes = new Map([ ["/internal/v1/management/edges:ensure", "edge.ensure"], ["/internal/v1/management/routes:ensure", "route.ensure"], ["/internal/v1/management/enrollment-intents:ensure", "enrollment_intent.ensure"], + ["/internal/v1/management/devices:claim", "device.claim"], + ["/internal/v1/management/devices:transfer", "device.transfer"], + ["/internal/v1/management/discoveries:reject", "discovery.reject"], + ["/internal/v1/management/discoveries:expire", "discovery.expire"], ]); export function createControlCoreApp({ @@ -154,6 +158,8 @@ export function createControlCoreApp({ const discovery = await repository.upsertQuarantineDiscovery({ identifierDigest, safeView, + sessionRef: signal.sessionRef, + routeRef: signal.routeRef ?? null, }); return writeJson(response, discovery.created ? 201 : 200, { ok: true, diff --git a/device-plane/services/device-control-core/src/discovery-repository.mjs b/device-plane/services/device-control-core/src/discovery-repository.mjs new file mode 100644 index 0000000..f8bd595 --- /dev/null +++ b/device-plane/services/device-control-core/src/discovery-repository.mjs @@ -0,0 +1,304 @@ +import { randomUUID } from "node:crypto"; + +export async function observeQuarantineDiscovery({ + pool, + identifierDigest, + safeView, + sessionRef, + routeRef = null, +}) { + if ((safeView.routeRef ?? null) !== routeRef) { + throw new TypeError("device_discovery_route_ref_mismatch"); + } + const routeId = routeRef == null ? null : parseEntityRef(routeRef, "route"); + const client = await pool.connect(); + try { + await client.query("begin"); + const route = routeId == null + ? null + : await findActiveRoute(client, routeId, safeView); + const enrollment = route == null + ? null + : await findMatchingEnrollment(client, { + route, + identifierDigest, + safeView, + }); + const result = await client.query( + `insert into device_discoveries ( + id, + identifier_kind, + identifier_digest, + identifier_masked, + model_profile_ref, + protocol, + lifecycle_state, + first_observed_at, + last_observed_at, + evidence, + session_ref, + project_id, + route_id, + enrollment_intent_id + ) values ( + $1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb, + $9, $10, $11, $12 + ) + on conflict (identifier_kind, identifier_digest, model_profile_ref) + do update set + last_observed_at = case + when device_discoveries.lifecycle_state = 'claimed' + and excluded.route_id is distinct from device_discoveries.route_id + then device_discoveries.last_observed_at + else greatest( + device_discoveries.last_observed_at, + excluded.last_observed_at + ) + end, + evidence = case + when device_discoveries.lifecycle_state = 'claimed' + and excluded.route_id is distinct from device_discoveries.route_id + then device_discoveries.evidence + else excluded.evidence + end, + session_ref = case + when device_discoveries.lifecycle_state = 'claimed' + and excluded.route_id is distinct from device_discoveries.route_id + then device_discoveries.session_ref + else excluded.session_ref + end, + project_id = case + when device_discoveries.lifecycle_state = 'claimed' + then device_discoveries.project_id + when excluded.enrollment_intent_id is not null + and ( + device_discoveries.enrollment_intent_id is null + or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id + or device_discoveries.lifecycle_state in ('rejected', 'expired') + ) then excluded.project_id + when device_discoveries.project_id is null + then excluded.project_id + else device_discoveries.project_id + end, + route_id = case + when device_discoveries.lifecycle_state = 'claimed' + then device_discoveries.route_id + when excluded.enrollment_intent_id is not null + and ( + device_discoveries.enrollment_intent_id is null + or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id + or device_discoveries.lifecycle_state in ('rejected', 'expired') + ) then excluded.route_id + when device_discoveries.route_id is null + then excluded.route_id + else device_discoveries.route_id + end, + enrollment_intent_id = case + when device_discoveries.lifecycle_state = 'claimed' + then device_discoveries.enrollment_intent_id + when excluded.enrollment_intent_id is not null + and ( + device_discoveries.enrollment_intent_id is null + or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id + or device_discoveries.lifecycle_state in ('rejected', 'expired') + ) then excluded.enrollment_intent_id + else device_discoveries.enrollment_intent_id + end, + lifecycle_state = case + when device_discoveries.lifecycle_state = 'claimed' then 'claimed' + when excluded.enrollment_intent_id is not null + and device_discoveries.lifecycle_state in ('rejected', 'expired') + then 'quarantine' + else device_discoveries.lifecycle_state + end, + resolution_code = case + when excluded.enrollment_intent_id is not null + and device_discoveries.lifecycle_state in ('rejected', 'expired') + then null + else device_discoveries.resolution_code + end, + resolved_at = case + when excluded.enrollment_intent_id is not null + and device_discoveries.lifecycle_state in ('rejected', 'expired') + then null + else device_discoveries.resolved_at + end, + resolved_by_ref = case + when excluded.enrollment_intent_id is not null + and device_discoveries.lifecycle_state in ('rejected', 'expired') + then null + else device_discoveries.resolved_by_ref + end, + updated_at = now() + returning id, lifecycle_state, model_profile_ref, protocol, + identifier_kind, identifier_masked, first_observed_at, + last_observed_at, evidence, project_id, route_id, + enrollment_intent_id, (xmax = 0) as created`, + [ + randomUUID(), + safeView.identifier.kind, + identifierDigest, + safeView.identifier.masked, + safeView.modelProfileRef, + safeView.protocol, + safeView.observedAt, + JSON.stringify(safeView.evidence), + sessionRef, + route?.project_id ?? null, + route?.id ?? null, + enrollment?.id ?? null, + ], + ); + const row = result.rows[0]; + if ( + enrollment + && row.enrollment_intent_id !== enrollment.id + ) { + throw domainError("device_discovery_enrollment_conflict", 409); + } + if (enrollment && row.lifecycle_state === "quarantine") { + const observed = await client.query( + `update device_enrollment_intents + set lifecycle_state = 'observed', + observed_discovery_id = $2, + observed_at = greatest(coalesce(observed_at, $3), $3), + resolution_code = null, + resolved_at = null, + resolved_by_ref = null, + updated_at = now() + where id = $1 + and lifecycle_state in ('pending', 'observed') + returning id`, + [enrollment.id, row.id, safeView.observedAt], + ); + if (!observed.rows[0]) { + throw domainError("device_enrollment_not_observable", 409); + } + } + await client.query("commit"); + return { + created: row.created === true, + value: discoveryView(row), + }; + } catch (error) { + await client.query("rollback").catch(() => undefined); + throw error; + } finally { + client.release(); + } +} + +async function findActiveRoute(client, routeId, safeView) { + const result = await client.query( + `select id, project_id, model_profile_ref, protocol, lifecycle_state + from device_routes + where id = $1 + for share`, + [routeId], + ); + const route = result.rows[0]; + if (!route) throw domainError("device_discovery_route_not_found", 404); + if (route.lifecycle_state !== "active") { + throw domainError("device_discovery_route_inactive", 409); + } + if ( + route.model_profile_ref !== safeView.modelProfileRef + || route.protocol !== safeView.protocol + ) { + throw domainError("device_discovery_route_profile_mismatch", 409); + } + return route; +} + +async function findMatchingEnrollment(client, { + route, + identifierDigest, + safeView, +}) { + await client.query( + `update device_enrollment_intents + set lifecycle_state = 'expired', + resolution_code = 'deadline_elapsed', + resolved_at = $6, + updated_at = now() + where project_id = $1 + and route_id = $2 + and model_profile_ref = $3 + and expected_identifier_kind = $4 + and expected_identifier_digest = $5 + and lifecycle_state = 'pending' + and expires_at is not null + and expires_at <= $6`, + [ + route.project_id, + route.id, + safeView.modelProfileRef, + safeView.identifier.kind, + identifierDigest, + safeView.observedAt, + ], + ); + const result = await client.query( + `select id, project_id, route_id, model_profile_ref, lifecycle_state + from device_enrollment_intents + where project_id = $1 + and route_id = $2 + and model_profile_ref = $3 + and expected_identifier_kind = $4 + and expected_identifier_digest = $5 + and lifecycle_state in ('pending', 'observed') + and (expires_at is null or expires_at > $6) + for update`, + [ + route.project_id, + route.id, + safeView.modelProfileRef, + safeView.identifier.kind, + identifierDigest, + safeView.observedAt, + ], + ); + if (result.rows.length > 1) { + throw domainError("device_enrollment_identity_ambiguous", 409); + } + return result.rows[0] ?? null; +} + +function discoveryView(row) { + return { + schemaVersion: "nodedc.device.discovery-view.v1", + discoveryRef: `discovery:${row.id}`, + ...(row.route_id ? { routeRef: `route:${row.route_id}` } : {}), + ...(row.enrollment_intent_id + ? { enrollmentIntentRef: `enrollment-intent:${row.enrollment_intent_id}` } + : {}), + modelProfileRef: row.model_profile_ref, + protocol: row.protocol, + observedAt: new Date(row.last_observed_at).toISOString(), + lifecycleState: row.lifecycle_state, + identifier: { + kind: row.identifier_kind, + masked: row.identifier_masked, + }, + evidence: row.evidence, + commandTransport: "disabled", + }; +} + +function parseEntityRef(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 domainError(code, statusCode) { + const error = new Error(code); + error.statusCode = statusCode; + return error; +} diff --git a/device-plane/services/device-control-core/src/lifecycle-management.mjs b/device-plane/services/device-control-core/src/lifecycle-management.mjs new file mode 100644 index 0000000..9c8be20 --- /dev/null +++ b/device-plane/services/device-control-core/src/lifecycle-management.mjs @@ -0,0 +1,139 @@ +export const DEVICE_LIFECYCLE_COMMAND_KINDS = Object.freeze([ + "device.claim", + "device.transfer", + "discovery.reject", + "discovery.expire", +]); + +const commandKindSet = new Set(DEVICE_LIFECYCLE_COMMAND_KINDS); +const keyPattern = /^[a-z][a-z0-9-]{1,62}$/; +const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/; + +export function isLifecycleManagementCommand(kind) { + return commandKindSet.has(kind); +} + +export function normalizeLifecycleManagementCommand(kind, input) { + if (!commandKindSet.has(kind)) { + throw new TypeError("device_lifecycle_command_kind_invalid"); + } + assertPlainObject(input); + + if (kind === "device.claim") { + assertAllowedKeys(input, [ + "projectRef", + "enrollmentIntentRef", + "discoveryRef", + "deviceKey", + "displayName", + ]); + return Object.freeze({ + projectId: normalizeEntityRef(input.projectRef, "project"), + enrollmentIntentId: normalizeEntityRef( + input.enrollmentIntentRef, + "enrollment-intent", + ), + discoveryId: normalizeEntityRef(input.discoveryRef, "discovery"), + deviceKey: normalizePattern( + input.deviceKey, + keyPattern, + "device_key_invalid", + ), + displayName: normalizeDisplayText(input.displayName, 160), + }); + } + + if (kind === "device.transfer") { + assertAllowedKeys(input, [ + "deviceRef", + "sourceProjectRef", + "targetProjectRef", + "targetDeviceKey", + ]); + const sourceProjectId = normalizeEntityRef( + input.sourceProjectRef, + "project", + ); + const targetProjectId = normalizeEntityRef( + input.targetProjectRef, + "project", + ); + if (sourceProjectId === targetProjectId) { + throw new TypeError("device_transfer_target_same_as_source"); + } + return Object.freeze({ + deviceId: normalizeEntityRef(input.deviceRef, "device"), + sourceProjectId, + targetProjectId, + targetDeviceKey: normalizePattern( + input.targetDeviceKey, + keyPattern, + "device_transfer_target_key_invalid", + ), + }); + } + + assertAllowedKeys(input, [ + "projectRef", + "discoveryRef", + "resolutionCode", + ]); + return Object.freeze({ + projectId: normalizeEntityRef(input.projectRef, "project"), + discoveryId: normalizeEntityRef(input.discoveryRef, "discovery"), + resolutionCode: normalizePattern( + input.resolutionCode, + resolutionPattern, + "device_discovery_resolution_code_invalid", + ), + }); +} + +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) { + if (typeof value !== "string") { + throw new TypeError("device_display_name_invalid"); + } + const normalized = value.trim(); + if ( + normalized.length < 1 + || normalized.length > maxLength + || /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized) + ) { + throw new TypeError("device_display_name_invalid"); + } + return normalized; +} + +function assertPlainObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("device_lifecycle_command_invalid"); + } +} + +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}`); + } + } +} diff --git a/device-plane/services/device-control-core/src/lifecycle-repository.mjs b/device-plane/services/device-control-core/src/lifecycle-repository.mjs new file mode 100644 index 0000000..11ee8f9 --- /dev/null +++ b/device-plane/services/device-control-core/src/lifecycle-repository.mjs @@ -0,0 +1,575 @@ +import { randomUUID } from "node:crypto"; + +import { isLifecycleManagementCommand } from "./lifecycle-management.mjs"; +import { + assertProjectCapability, + toProjectRef, +} from "./project-management.mjs"; + +export async function applyLifecycleManagementCommand( + client, + { commandKind, actor, command }, +) { + if (!isLifecycleManagementCommand(commandKind)) { + throw new TypeError("device_lifecycle_command_kind_invalid"); + } + if (commandKind === "device.claim") { + return claimDevice(client, actor, command); + } + if (commandKind === "device.transfer") { + return transferDevice(client, actor, command); + } + return resolveDiscovery(client, actor, command, commandKind); +} + +export async function authorizeLifecycleManagementReplay( + client, + { commandKind, actor, command }, +) { + if (!isLifecycleManagementCommand(commandKind)) { + throw new TypeError("device_lifecycle_command_kind_invalid"); + } + if (commandKind === "device.transfer") { + await findProjectWithCapability( + client, + actor, + command.sourceProjectId, + "device.transfer", + ); + await findProjectWithCapability( + client, + actor, + command.targetProjectId, + "device.transfer", + ); + return; + } + + await findProjectWithCapability( + client, + actor, + command.projectId, + "device.claim", + ); + if (commandKind === "device.claim") { + const current = await client.query( + `select di.project_id + from device_discoveries dd + join device_instances di on di.id = dd.claimed_device_id + where dd.id = $1`, + [command.discoveryId], + ); + const currentProjectId = current.rows[0]?.project_id; + if (currentProjectId && currentProjectId !== command.projectId) { + await findProjectWithCapability( + client, + actor, + currentProjectId, + "device.claim", + ); + } + } +} + +async function claimDevice(client, actor, command) { + const project = await findProjectWithCapability( + client, + actor, + command.projectId, + "device.claim", + ); + const enrollment = await findEnrollmentForUpdate( + client, + command.projectId, + command.enrollmentIntentId, + ); + const discovery = await findDiscoveryForUpdate( + client, + command.projectId, + command.discoveryId, + ); + assertClaimEvidence(command, enrollment, discovery); + + const deviceId = randomUUID(); + const inserted = await client.query( + `insert into device_instances ( + id, + contour_id, + owner_scope_id, + project_id, + device_key, + model_profile_ref, + display_name, + identifier_kind, + identifier_digest, + identifier_masked, + lifecycle_state + ) values ($1, null, $2, $3, $4, $5, $6, $7, $8, $9, 'claimed') + returning id, owner_scope_id, project_id, device_key, + model_profile_ref, display_name, identifier_kind, + identifier_masked, lifecycle_state, created_at, updated_at`, + [ + deviceId, + project.owner_scope_id, + project.id, + command.deviceKey, + discovery.model_profile_ref, + command.displayName, + discovery.identifier_kind, + discovery.identifier_digest, + discovery.identifier_masked, + ], + ); + const device = inserted.rows[0]; + if (!device) throw domainError("device_claim_insert_failed", 409); + + const claimedDiscovery = await client.query( + `update device_discoveries + set lifecycle_state = 'claimed', + claimed_device_id = $2, + claimed_at = now(), + claimed_by = $3, + resolution_code = 'claimed', + resolved_at = now(), + resolved_by_ref = $3, + updated_at = now() + where id = $1 + and lifecycle_state = 'quarantine' + and enrollment_intent_id = $4 + returning id`, + [discovery.id, device.id, actor.userRef, enrollment.id], + ); + if (!claimedDiscovery.rows[0]) { + throw domainError("device_discovery_not_claimable", 409); + } + + const claimedEnrollment = await client.query( + `update device_enrollment_intents + set lifecycle_state = 'claimed', + claimed_device_id = $2, + claimed_at = now(), + resolution_code = 'claimed', + resolved_at = now(), + resolved_by_ref = $3, + updated_at = now() + where id = $1 + and lifecycle_state = 'observed' + and observed_discovery_id = $4 + and (expires_at is null or expires_at > now()) + returning id`, + [enrollment.id, device.id, actor.userRef, discovery.id], + ); + if (!claimedEnrollment.rows[0]) { + throw domainError("device_enrollment_not_claimable", 409); + } + + const transitionId = randomUUID(); + await client.query( + `insert into device_ownership_transitions ( + id, + device_id, + transition_kind, + target_owner_scope_id, + target_project_id, + actor_ref + ) values ($1, $2, 'claim', $3, $4, $5)`, + [ + transitionId, + device.id, + project.owner_scope_id, + project.id, + actor.userRef, + ], + ); + await addAudit(client, { + eventType: "device.claimed", + actorRef: actor.userRef, + projectId: project.id, + deviceId: device.id, + discoveryId: discovery.id, + payload: { + deviceRef: `device:${device.id}`, + projectRef: toProjectRef(project.id), + enrollmentIntentRef: `enrollment-intent:${enrollment.id}`, + discoveryRef: `discovery:${discovery.id}`, + ownershipTransitionRef: `ownership-transition:${transitionId}`, + modelProfileRef: device.model_profile_ref, + }, + }); + return { + created: true, + device: deviceView(device, project), + enrollmentIntentRef: `enrollment-intent:${enrollment.id}`, + discoveryRef: `discovery:${discovery.id}`, + ownershipTransitionRef: `ownership-transition:${transitionId}`, + }; +} + +async function resolveDiscovery(client, actor, command, commandKind) { + const project = await findProjectWithCapability( + client, + actor, + command.projectId, + "device.claim", + ); + const discovery = await findDiscoveryForUpdate( + client, + command.projectId, + command.discoveryId, + ); + if ( + discovery.lifecycle_state !== "quarantine" + || !discovery.enrollment_intent_id + ) { + throw domainError("device_discovery_not_resolvable", 409); + } + const enrollment = await findEnrollmentForUpdate( + client, + command.projectId, + discovery.enrollment_intent_id, + ); + if ( + enrollment.lifecycle_state !== "observed" + || enrollment.observed_discovery_id !== discovery.id + ) { + throw domainError("device_enrollment_not_resolvable", 409); + } + + const discoveryState = commandKind === "discovery.reject" + ? "rejected" + : "expired"; + const enrollmentState = commandKind === "discovery.reject" + ? "cancelled" + : "expired"; + await client.query( + `update device_discoveries + set lifecycle_state = $2, + resolution_code = $3, + resolved_at = now(), + resolved_by_ref = $4, + updated_at = now() + where id = $1 and lifecycle_state = 'quarantine'`, + [discovery.id, discoveryState, command.resolutionCode, actor.userRef], + ); + await client.query( + `update device_enrollment_intents + set lifecycle_state = $2, + resolution_code = $3, + resolved_at = now(), + resolved_by_ref = $4, + updated_at = now() + where id = $1 and lifecycle_state = 'observed'`, + [enrollment.id, enrollmentState, command.resolutionCode, actor.userRef], + ); + await addAudit(client, { + eventType: `discovery.${discoveryState}`, + actorRef: actor.userRef, + projectId: project.id, + discoveryId: discovery.id, + payload: { + projectRef: toProjectRef(project.id), + discoveryRef: `discovery:${discovery.id}`, + enrollmentIntentRef: `enrollment-intent:${enrollment.id}`, + lifecycleState: discoveryState, + resolutionCode: command.resolutionCode, + }, + }); + return { + discovery: { + discoveryRef: `discovery:${discovery.id}`, + projectRef: toProjectRef(project.id), + enrollmentIntentRef: `enrollment-intent:${enrollment.id}`, + lifecycleState: discoveryState, + identifier: { + kind: discovery.identifier_kind, + masked: discovery.identifier_masked, + }, + resolutionCode: command.resolutionCode, + }, + }; +} + +async function transferDevice(client, actor, command) { + const device = await findDeviceForUpdate(client, command.deviceId); + if (device.project_id !== command.sourceProjectId) { + throw domainError("device_transfer_source_mismatch", 409); + } + if (!device.owner_scope_id || !device.project_id || device.contour_id) { + throw domainError("device_transfer_legacy_ownership_unsupported", 409); + } + if (["online", "retired"].includes(device.lifecycle_state)) { + throw domainError("device_transfer_lifecycle_blocked", 409); + } + + const sourceProject = await findProjectWithCapability( + client, + actor, + command.sourceProjectId, + "device.transfer", + ); + const targetProject = await findProjectWithCapability( + client, + actor, + command.targetProjectId, + "device.transfer", + ); + if (sourceProject.owner_scope_id !== device.owner_scope_id) { + throw domainError("device_transfer_owner_mismatch", 409); + } + + const activeSessions = await client.query( + `select exists ( + select 1 from device_sessions + where device_id = $1 + and lifecycle_state in ('connecting', 'online', 'closing') + ) as active`, + [device.id], + ); + if (activeSessions.rows[0]?.active === true) { + throw domainError("device_transfer_active_session", 409); + } + + const detached = await client.query( + `delete from device_collection_members + where device_id = $1 and project_id = $2`, + [device.id, sourceProject.id], + ); + const updated = await client.query( + `update device_instances + set owner_scope_id = $2, + project_id = $3, + device_key = $4, + updated_at = now() + where id = $1 + returning id, contour_id, owner_scope_id, project_id, device_key, + model_profile_ref, display_name, identifier_kind, + identifier_masked, lifecycle_state, created_at, updated_at`, + [ + device.id, + targetProject.owner_scope_id, + targetProject.id, + command.targetDeviceKey, + ], + ); + const moved = updated.rows[0]; + if (!moved) throw domainError("device_transfer_update_failed", 409); + + const transitionId = randomUUID(); + await client.query( + `insert into device_ownership_transitions ( + id, + device_id, + transition_kind, + source_owner_scope_id, + source_project_id, + target_owner_scope_id, + target_project_id, + actor_ref + ) values ($1, $2, 'transfer', $3, $4, $5, $6, $7)`, + [ + transitionId, + device.id, + sourceProject.owner_scope_id, + sourceProject.id, + targetProject.owner_scope_id, + targetProject.id, + actor.userRef, + ], + ); + const auditPayload = { + deviceRef: `device:${device.id}`, + ownershipTransitionRef: `ownership-transition:${transitionId}`, + sourceProjectRef: toProjectRef(sourceProject.id), + targetProjectRef: toProjectRef(targetProject.id), + detachedCollectionCount: Number(detached.rowCount || 0), + }; + await addAudit(client, { + eventType: "device.transferred_out", + actorRef: actor.userRef, + projectId: sourceProject.id, + deviceId: device.id, + payload: auditPayload, + }); + await addAudit(client, { + eventType: "device.transferred_in", + actorRef: actor.userRef, + projectId: targetProject.id, + deviceId: device.id, + payload: auditPayload, + }); + return { + transferred: true, + device: deviceView(moved, targetProject), + sourceProjectRef: toProjectRef(sourceProject.id), + ownershipTransitionRef: `ownership-transition:${transitionId}`, + detachedCollectionCount: Number(detached.rowCount || 0), + }; +} + +async function findProjectWithCapability(client, actor, projectId, capability) { + const result = await client.query( + `select p.id, p.owner_scope_id, p.lifecycle_state, + os.scope_kind, os.owner_ref, os.display_name as owner_display_name, + os.lifecycle_state as owner_lifecycle_state + from device_projects p + join device_owner_scopes os on os.id = p.owner_scope_id + where p.id = $1 + for share of p, os`, + [projectId], + ); + const project = result.rows[0]; + if (!project) throw domainError("device_project_not_found", 404); + if (project.owner_lifecycle_state !== "active") { + throw domainError("device_owner_scope_inactive", 409); + } + if (project.lifecycle_state !== "active") { + throw domainError("device_project_inactive", 409); + } + const grants = await client.query( + `select id, principal_kind, principal_ref, project_role, + capability_allow, capability_deny, lifecycle_state + from device_project_grants + where project_id = $1 + order by created_at, id + for share`, + [projectId], + ); + assertProjectCapability( + actor, + grants.rows.map((grant) => ({ + grantRef: `grant:${grant.id}`, + principalKind: grant.principal_kind, + principalRef: grant.principal_ref, + projectRole: grant.project_role, + capabilityAllow: grant.capability_allow ?? [], + capabilityDeny: grant.capability_deny ?? [], + lifecycleState: grant.lifecycle_state, + })), + capability, + ); + return project; +} + +async function findEnrollmentForUpdate(client, projectId, enrollmentId) { + const result = await client.query( + `select id, project_id, route_id, model_profile_ref, + expected_identifier_kind, expected_identifier_digest, + expected_identifier_masked, lifecycle_state, + observed_discovery_id, claimed_device_id, expires_at + from device_enrollment_intents + where id = $1 and project_id = $2 + for update`, + [enrollmentId, projectId], + ); + if (!result.rows[0]) { + throw domainError("device_enrollment_intent_not_found", 404); + } + return result.rows[0]; +} + +async function findDiscoveryForUpdate(client, projectId, discoveryId) { + const result = await client.query( + `select id, project_id, route_id, enrollment_intent_id, + model_profile_ref, protocol, identifier_kind, identifier_digest, + identifier_masked, lifecycle_state, claimed_device_id + from device_discoveries + where id = $1 and project_id = $2 + for update`, + [discoveryId, projectId], + ); + if (!result.rows[0]) throw domainError("device_discovery_not_found", 404); + return result.rows[0]; +} + +async function findDeviceForUpdate(client, deviceId) { + const result = await client.query( + `select id, contour_id, owner_scope_id, project_id, device_key, + model_profile_ref, display_name, identifier_kind, + identifier_masked, lifecycle_state, created_at, updated_at + from device_instances + where id = $1 + for update`, + [deviceId], + ); + if (!result.rows[0]) throw domainError("device_not_found", 404); + return result.rows[0]; +} + +function assertClaimEvidence(command, enrollment, discovery) { + if ( + enrollment.lifecycle_state !== "observed" + || enrollment.observed_discovery_id !== discovery.id + || discovery.lifecycle_state !== "quarantine" + || discovery.enrollment_intent_id !== enrollment.id + || discovery.project_id !== command.projectId + || discovery.route_id !== enrollment.route_id + || discovery.model_profile_ref !== enrollment.model_profile_ref + || discovery.identifier_kind !== enrollment.expected_identifier_kind + || discovery.identifier_digest !== enrollment.expected_identifier_digest + || discovery.identifier_masked !== enrollment.expected_identifier_masked + ) { + throw domainError("device_claim_evidence_mismatch", 409); + } +} + +async function addAudit(client, { + eventType, + actorRef, + projectId, + deviceId = null, + discoveryId = null, + payload, +}) { + await client.query( + `insert into device_audit_events ( + id, + event_type, + actor_ref, + project_id, + device_id, + discovery_id, + payload + ) values ($1, $2, $3, $4, $5, $6, $7::jsonb)`, + [ + randomUUID(), + eventType, + actorRef, + projectId, + deviceId, + discoveryId, + JSON.stringify(payload), + ], + ); +} + +function deviceView(row, project) { + return { + deviceRef: `device:${row.id}`, + deviceKey: row.device_key, + projectRef: toProjectRef(row.project_id), + ownerScope: { + ownerScopeRef: `owner-scope:${row.owner_scope_id}`, + scopeKind: project.scope_kind, + ownerRef: project.owner_ref, + }, + modelProfileRef: row.model_profile_ref, + displayName: row.display_name, + identifier: { + kind: row.identifier_kind, + masked: row.identifier_masked, + }, + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function toIso(value) { + return new Date(value).toISOString(); +} + +function domainError(code, statusCode) { + const error = new Error(code); + error.statusCode = statusCode; + return error; +} diff --git a/device-plane/services/device-control-core/src/management-command.mjs b/device-plane/services/device-control-core/src/management-command.mjs index 5c89c84..773ad7f 100644 --- a/device-plane/services/device-control-core/src/management-command.mjs +++ b/device-plane/services/device-control-core/src/management-command.mjs @@ -3,6 +3,11 @@ import { isInfrastructureManagementCommand, normalizeInfrastructureManagementCommand, } from "./infrastructure-management.mjs"; +import { + DEVICE_LIFECYCLE_COMMAND_KINDS, + isLifecycleManagementCommand, + normalizeLifecycleManagementCommand, +} from "./lifecycle-management.mjs"; import { DEVICE_MANAGEMENT_COMMAND_KINDS, normalizeManagementCommand, @@ -11,9 +16,13 @@ import { export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([ ...DEVICE_MANAGEMENT_COMMAND_KINDS, ...DEVICE_INFRASTRUCTURE_COMMAND_KINDS, + ...DEVICE_LIFECYCLE_COMMAND_KINDS, ]); export function normalizeDeviceManagementCommand(kind, input) { + if (isLifecycleManagementCommand(kind)) { + return normalizeLifecycleManagementCommand(kind, input); + } if (isInfrastructureManagementCommand(kind)) { return normalizeInfrastructureManagementCommand(kind, input); } diff --git a/device-plane/services/device-control-core/src/postgres-repository.mjs b/device-plane/services/device-control-core/src/postgres-repository.mjs index 05f21e9..50a3a24 100644 --- a/device-plane/services/device-control-core/src/postgres-repository.mjs +++ b/device-plane/services/device-control-core/src/postgres-repository.mjs @@ -6,11 +6,17 @@ import { fileURLToPath } from "node:url"; import pg from "pg"; import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs"; +import { observeQuarantineDiscovery } from "./discovery-repository.mjs"; import { applyInfrastructureManagementCommand, authorizeInfrastructureManagementReplay, } from "./infrastructure-repository.mjs"; import { isInfrastructureManagementCommand } from "./infrastructure-management.mjs"; +import { isLifecycleManagementCommand } from "./lifecycle-management.mjs"; +import { + applyLifecycleManagementCommand, + authorizeLifecycleManagementReplay, +} from "./lifecycle-repository.mjs"; import { assertActorCanManageOwnerScope, assertGrantMutationAllowed, @@ -26,6 +32,8 @@ const migrationFiles = [ "003_device_management_commands.sql", "004_device_registry_foundation.sql", "005_device_registry_commands.sql", + "006_device_lifecycle_ownership.sql", + "007_device_lifecycle_commands.sql", ]; export class PostgresDeviceRepository { @@ -89,60 +97,11 @@ export class PostgresDeviceRepository { return "ready"; } - async upsertQuarantineDiscovery({ identifierDigest, safeView }) { - const result = await this.pool.query( - `insert into device_discoveries ( - id, - identifier_kind, - identifier_digest, - identifier_masked, - model_profile_ref, - protocol, - lifecycle_state, - first_observed_at, - last_observed_at, - evidence - ) values ($1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb) - on conflict (identifier_kind, identifier_digest, model_profile_ref) - do update set - last_observed_at = greatest( - device_discoveries.last_observed_at, - excluded.last_observed_at - ), - evidence = excluded.evidence, - updated_at = now() - returning id, lifecycle_state, model_profile_ref, protocol, - identifier_kind, identifier_masked, first_observed_at, - last_observed_at, (xmax = 0) as created`, - [ - randomUUID(), - safeView.identifier.kind, - identifierDigest, - safeView.identifier.masked, - safeView.modelProfileRef, - safeView.protocol, - safeView.observedAt, - JSON.stringify(safeView.evidence), - ], - ); - const row = result.rows[0]; - return { - created: row.created === true, - value: { - schemaVersion: "nodedc.device.discovery-view.v1", - discoveryRef: `discovery:${row.id}`, - modelProfileRef: row.model_profile_ref, - protocol: row.protocol, - observedAt: new Date(row.last_observed_at).toISOString(), - lifecycleState: row.lifecycle_state, - identifier: { - kind: row.identifier_kind, - masked: row.identifier_masked, - }, - evidence: safeView.evidence, - commandTransport: "disabled", - }, - }; + async upsertQuarantineDiscovery(input) { + return observeQuarantineDiscovery({ + pool: this.pool, + ...input, + }); } async executeManagementCommand({ @@ -258,6 +217,13 @@ async function completeManagementReceipt(client, receiptId, result) { } async function applyManagementCommand(client, { commandKind, actor, command }) { + if (isLifecycleManagementCommand(commandKind)) { + return applyLifecycleManagementCommand(client, { + commandKind, + actor, + command, + }); + } if (isInfrastructureManagementCommand(commandKind)) { return applyInfrastructureManagementCommand(client, { commandKind, @@ -281,6 +247,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) { } async function authorizeManagementReplay(client, { commandKind, actor, command }) { + if (isLifecycleManagementCommand(commandKind)) { + return authorizeLifecycleManagementReplay(client, { + commandKind, + actor, + command, + }); + } if (isInfrastructureManagementCommand(commandKind)) { return authorizeInfrastructureManagementReplay(client, { commandKind, diff --git a/device-plane/services/device-control-core/test/app.test.mjs b/device-plane/services/device-control-core/test/app.test.mjs index 2435acd..434ca13 100644 --- a/device-plane/services/device-control-core/test/app.test.mjs +++ b/device-plane/services/device-control-core/test/app.test.mjs @@ -310,6 +310,8 @@ test("authenticated ingest stores only digest and returns a masked view", async assert.equal(serialized.includes(fakeImei), false); assert.equal(body.discovery.identifier.masked, "***********0001"); assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/); + assert.equal(stored.sessionRef, "session:test-001"); + assert.equal(stored.routeRef, null); assert.equal(JSON.stringify(stored).includes(fakeImei), false); } finally { await runtime.close(); diff --git a/device-plane/services/device-control-core/test/device-lifecycle-command-migration.test.mjs b/device-plane/services/device-control-core/test/device-lifecycle-command-migration.test.mjs new file mode 100644 index 0000000..2a1bdca --- /dev/null +++ b/device-plane/services/device-control-core/test/device-lifecycle-command-migration.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const migrationUrl = new URL( + "../migrations/007_device_lifecycle_commands.sql", + import.meta.url, +); +const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); + +test("lifecycle command migration extends the durable receipt allowlist", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + for (const kind of [ + "device.claim", + "device.transfer", + "discovery.reject", + "discovery.expire", + ]) { + assert.match(sql, new RegExp(`'${kind.replace(".", "\\.")}'`)); + } +}); + +test("lifecycle command migration contains no runtime entity or secret", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + assert.doesNotMatch(sql, /insert\s+into/i); + assert.doesNotMatch(sql, /dcctouch|arusnavi|\bb2\b|imei|gelios/i); + assert.doesNotMatch(sql, /password|secret|credential|private_key/i); +}); + +test("repository applies lifecycle commands after ownership schema", async () => { + const source = await readFile(repositoryUrl, "utf8"); + const lifecycleIndex = source.indexOf("006_device_lifecycle_ownership.sql"); + const commandsIndex = source.indexOf("007_device_lifecycle_commands.sql"); + + assert.notEqual(lifecycleIndex, -1); + assert.notEqual(commandsIndex, -1); + assert.ok(lifecycleIndex < commandsIndex); +}); diff --git a/device-plane/services/device-control-core/test/device-lifecycle-migration.test.mjs b/device-plane/services/device-control-core/test/device-lifecycle-migration.test.mjs new file mode 100644 index 0000000..912207f --- /dev/null +++ b/device-plane/services/device-control-core/test/device-lifecycle-migration.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const migrationUrl = new URL( + "../migrations/006_device_lifecycle_ownership.sql", + import.meta.url, +); +const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); + +test("lifecycle migration separates direct ownership from legacy contours", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + assert.match(sql, /alter column contour_id drop not null/); + assert.match(sql, /add column if not exists owner_scope_id uuid/); + assert.match(sql, /device_instances_project_owner_fk/); + assert.match(sql, /device_instances_ownership_mode_check/); + assert.match(sql, /references device_projects\(id, owner_scope_id\)/); + assert.match(sql, /\) not valid;/); +}); + +test("route-bound discovery and enrollment evidence are DB constrained", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + assert.match(sql, /device_discoveries_route_context_fk/); + assert.match(sql, /device_discoveries_enrollment_context_fk/); + assert.match(sql, /device_enrollment_observed_discovery_fk/); + assert.match(sql, /device_enrollment_intents_active_identity_idx/); + assert.match(sql, /where lifecycle_state in \('pending', 'observed', 'claimed'\)/); +}); + +test("ownership history supports transfer without rewriting session provenance", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + assert.match(sql, /create table if not exists device_ownership_transitions/); + assert.match(sql, /transition_kind in \('claim', 'transfer'\)/); + assert.match(sql, /device_ownership_single_claim_idx/); + assert.match(sql, /device_assert_session_current_project/); + assert.match(sql, /device_assert_enrollment_current_project/); + assert.match(sql, /drop constraint if exists device_sessions_device_id_project_id_fkey/); +}); + +test("lifecycle migration contains no tenant, device, route or credential seed", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + assert.doesNotMatch(sql, /insert\s+into/i); + assert.doesNotMatch(sql, /dcctouch|arusnavi|\bb2\b|imei|gelios/i); + assert.doesNotMatch(sql, /155\.212\.|device\.nodedc\.ru|synology/i); + assert.doesNotMatch(sql, /password|secret|private_key|credential_ref/i); +}); + +test("repository applies lifecycle migration after registry commands", async () => { + const source = await readFile(repositoryUrl, "utf8"); + const commandsIndex = source.indexOf("005_device_registry_commands.sql"); + const lifecycleIndex = source.indexOf("006_device_lifecycle_ownership.sql"); + + assert.notEqual(commandsIndex, -1); + assert.notEqual(lifecycleIndex, -1); + assert.ok(commandsIndex < lifecycleIndex); +}); diff --git a/device-plane/services/device-control-core/test/discovery-repository.test.mjs b/device-plane/services/device-control-core/test/discovery-repository.test.mjs new file mode 100644 index 0000000..68c5c21 --- /dev/null +++ b/device-plane/services/device-control-core/test/discovery-repository.test.mjs @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs"; +import { observeQuarantineDiscovery } from "../src/discovery-repository.mjs"; + +const observedAt = "2026-08-10T00:00:00.000Z"; +const projectId = "11111111-1111-4111-8111-111111111111"; +const routeId = "22222222-2222-4222-8222-222222222222"; +const enrollmentId = "33333333-3333-4333-8333-333333333333"; +const discoveryId = "44444444-4444-4444-8444-444444444444"; +const identifierDigest = `hmac-sha256:${"a".repeat(64)}`; + +test("observation expires stale intents before matching an identity", async () => { + const source = await readFile( + new URL("../src/discovery-repository.mjs", import.meta.url), + "utf8", + ); + + assert.match(source, /expires_at <= \$6/); + assert.match(source, /expires_at is null or expires_at > \$6/); + assert.match(source, /resolution_code = 'deadline_elapsed'/); +}); + +test("legacy discovery remains unbound quarantine without a route reference", async () => { + const client = scriptedClient([ + step("begin"), + step("insert into device_discoveries", { + rows: [discoveryRow({ project_id: null, route_id: null })], + }), + step("commit"), + ]); + + const result = await observeQuarantineDiscovery({ + pool: poolWithClient(client), + identifierDigest, + safeView: safeView(), + sessionRef: "session:legacy-test", + }); + + assert.equal(result.created, true); + assert.equal("routeRef" in result.value, false); + assert.equal("enrollmentIntentRef" in result.value, false); + assertSafeProjection(result.value); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("route-bound discovery atomically observes only its matching enrollment", async () => { + const client = scriptedClient([ + step("begin"), + step("from device_routes", { + rows: [{ + id: routeId, + project_id: projectId, + model_profile_ref: "vendor.model.protocol.v1", + protocol: "GENERIC_TCP", + lifecycle_state: "active", + }], + }), + step("update device_enrollment_intents"), + step("from device_enrollment_intents", { + rows: [{ + id: enrollmentId, + project_id: projectId, + route_id: routeId, + model_profile_ref: "vendor.model.protocol.v1", + lifecycle_state: "pending", + }], + }), + step("insert into device_discoveries", { + rows: [discoveryRow({ + project_id: projectId, + route_id: routeId, + enrollment_intent_id: enrollmentId, + })], + }), + step("update device_enrollment_intents", { + rows: [{ id: enrollmentId }], + }), + step("commit"), + ]); + + const result = await observeQuarantineDiscovery({ + pool: poolWithClient(client), + identifierDigest, + safeView: safeView(`route:${routeId}`), + sessionRef: "session:route-test", + routeRef: `route:${routeId}`, + }); + + assert.equal(result.value.routeRef, `route:${routeId}`); + assert.equal( + result.value.enrollmentIntentRef, + `enrollment-intent:${enrollmentId}`, + ); + assertSafeProjection(result.value); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("inactive or mismatched routes fail before a discovery is stored", async () => { + const client = scriptedClient([ + step("begin"), + step("from device_routes", { + rows: [{ + id: routeId, + project_id: projectId, + model_profile_ref: "other.profile.v1", + protocol: "OTHER_TCP", + lifecycle_state: "active", + }], + }), + step("rollback"), + ]); + + await assert.rejects( + observeQuarantineDiscovery({ + pool: poolWithClient(client), + identifierDigest, + safeView: safeView(`route:${routeId}`), + sessionRef: "session:mismatch-test", + routeRef: `route:${routeId}`, + }), + /device_discovery_route_profile_mismatch/, + ); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +function safeView(routeRef = null) { + return { + schemaVersion: "nodedc.device.discovery-view.v1", + ...(routeRef ? { routeRef } : {}), + modelProfileRef: "vendor.model.protocol.v1", + protocol: "GENERIC_TCP", + observedAt, + lifecycleState: "quarantine", + identifier: { kind: "serial", masked: "********0001" }, + evidence: { + transport: "tcp", + bytesObserved: 32, + framingStatus: "verified", + specificationRef: "vendor.protocol.v1", + }, + commandTransport: "disabled", + }; +} + +function discoveryRow(overrides = {}) { + return { + id: discoveryId, + lifecycle_state: "quarantine", + model_profile_ref: "vendor.model.protocol.v1", + protocol: "GENERIC_TCP", + identifier_kind: "serial", + identifier_masked: "********0001", + first_observed_at: new Date(observedAt), + last_observed_at: new Date(observedAt), + evidence: safeView().evidence, + project_id: null, + route_id: null, + enrollment_intent_id: null, + created: true, + ...overrides, + }; +} + +function poolWithClient(client) { + return { connect: async () => client }; +} + +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, "\\$&"); +} diff --git a/device-plane/services/device-control-core/test/infrastructure-app.test.mjs b/device-plane/services/device-control-core/test/infrastructure-app.test.mjs index 8ee44a9..179860b 100644 --- a/device-plane/services/device-control-core/test/infrastructure-app.test.mjs +++ b/device-plane/services/device-control-core/test/infrastructure-app.test.mjs @@ -76,6 +76,46 @@ test("management API exposes no user-owned session mutation", async () => { } }); +test("management API forwards claim as evidence references without identity input", async () => { + let executed; + const runtime = await startServer({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async (input) => { + executed = input; + return { replayed: false, result: { created: true } }; + }, + }, + }); + try { + const response = await fetch( + `${runtime.baseUrl}/internal/v1/management/devices:claim`, + { + method: "POST", + headers: managementHeaders(), + body: JSON.stringify({ + projectRef: "project:11111111-1111-4111-8111-111111111111", + enrollmentIntentRef: + "enrollment-intent:22222222-2222-4222-8222-222222222222", + discoveryRef: "discovery:33333333-3333-4333-8333-333333333333", + deviceKey: "pilot-device", + displayName: "Pilot device", + }), + }, + ); + + assert.equal(response.status, 200); + assert.equal(executed.commandKind, "device.claim"); + assert.equal(executed.command.deviceKey, "pilot-device"); + assert.equal("identifier" in executed.command, false); + assert.equal("credentialRef" in executed.command, false); + } finally { + await runtime.close(); + } +}); + async function startServer(options) { const server = createControlCoreApp(options); await new Promise((resolve, reject) => { diff --git a/device-plane/services/device-control-core/test/lifecycle-management.test.mjs b/device-plane/services/device-control-core/test/lifecycle-management.test.mjs new file mode 100644 index 0000000..22db9c8 --- /dev/null +++ b/device-plane/services/device-control-core/test/lifecycle-management.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEVICE_LIFECYCLE_COMMAND_KINDS, + normalizeLifecycleManagementCommand, +} from "../src/lifecycle-management.mjs"; +import { + ALL_DEVICE_MANAGEMENT_COMMAND_KINDS, + normalizeDeviceManagementCommand, +} from "../src/management-command.mjs"; + +const projectRef = "project:11111111-1111-4111-8111-111111111111"; +const targetProjectRef = "project:22222222-2222-4222-8222-222222222222"; +const discoveryRef = "discovery:33333333-3333-4333-8333-333333333333"; +const enrollmentIntentRef = + "enrollment-intent:44444444-4444-4444-8444-444444444444"; +const deviceRef = "device:55555555-5555-4555-8555-555555555555"; + +test("lifecycle commands join the same strict management command surface", () => { + for (const kind of DEVICE_LIFECYCLE_COMMAND_KINDS) { + assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true); + } + assert.equal( + normalizeDeviceManagementCommand("device.claim", claimInput()).projectId, + projectRef.slice("project:".length), + ); +}); + +test("claim accepts only opaque evidence references and presentation fields", () => { + const command = normalizeLifecycleManagementCommand( + "device.claim", + claimInput(), + ); + + assert.equal( + command.enrollmentIntentId, + enrollmentIntentRef.slice("enrollment-intent:".length), + ); + assert.equal(command.discoveryId, discoveryRef.slice("discovery:".length)); + assert.equal(command.deviceKey, "pilot-device"); + assert.equal("identifier" in command, false); + assert.equal("credentialRef" in command, false); +}); + +test("claim rejects raw identity and credential-shaped input", () => { + assert.throws( + () => normalizeLifecycleManagementCommand("device.claim", { + ...claimInput(), + identifier: "000000000000001", + }), + /device_management_command_field_unexpected:identifier/, + ); + assert.throws( + () => normalizeLifecycleManagementCommand("device.claim", { + ...claimInput(), + credentialRef: "secret:test", + }), + /device_management_command_field_unexpected:credentialRef/, + ); +}); + +test("transfer binds both project boundaries and rejects a no-op", () => { + const command = normalizeLifecycleManagementCommand("device.transfer", { + deviceRef, + sourceProjectRef: projectRef, + targetProjectRef, + targetDeviceKey: "transferred-device", + }); + + assert.equal(command.deviceId, deviceRef.slice("device:".length)); + assert.notEqual(command.sourceProjectId, command.targetProjectId); + assert.throws( + () => normalizeLifecycleManagementCommand("device.transfer", { + deviceRef, + sourceProjectRef: projectRef, + targetProjectRef: projectRef, + targetDeviceKey: "same-project", + }), + /device_transfer_target_same_as_source/, + ); +}); + +test("reject and expire require bounded machine-readable resolution codes", () => { + for (const kind of ["discovery.reject", "discovery.expire"]) { + const command = normalizeLifecycleManagementCommand(kind, { + projectRef, + discoveryRef, + resolutionCode: "operator.identity_mismatch", + }); + assert.equal(command.resolutionCode, "operator.identity_mismatch"); + } + assert.throws( + () => normalizeLifecycleManagementCommand("discovery.reject", { + projectRef, + discoveryRef, + resolutionCode: "free form reason is forbidden", + }), + /device_discovery_resolution_code_invalid/, + ); +}); + +function claimInput() { + return { + projectRef, + enrollmentIntentRef, + discoveryRef, + deviceKey: "pilot-device", + displayName: "Pilot device", + }; +} diff --git a/device-plane/services/device-control-core/test/lifecycle-repository.test.mjs b/device-plane/services/device-control-core/test/lifecycle-repository.test.mjs new file mode 100644 index 0000000..af322b3 --- /dev/null +++ b/device-plane/services/device-control-core/test/lifecycle-repository.test.mjs @@ -0,0 +1,324 @@ +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 sourceProjectId = "11111111-1111-4111-8111-111111111111"; +const targetProjectId = "22222222-2222-4222-8222-222222222222"; +const sourceOwnerId = "33333333-3333-4333-8333-333333333333"; +const targetOwnerId = "44444444-4444-4444-8444-444444444444"; +const enrollmentId = "55555555-5555-4555-8555-555555555555"; +const discoveryId = "66666666-6666-4666-8666-666666666666"; +const deviceId = "77777777-7777-4777-8777-777777777777"; +const routeId = "88888888-8888-4888-8888-888888888888"; +const identifierDigest = `hmac-sha256:${"a".repeat(64)}`; + +test("claims only matching observed enrollment evidence into direct ownership", async () => { + const actor = managementActor("member"); + const command = normalizeDeviceManagementCommand("device.claim", { + projectRef: `project:${sourceProjectId}`, + enrollmentIntentRef: `enrollment-intent:${enrollmentId}`, + discoveryRef: `discovery:${discoveryId}`, + deviceKey: "pilot-device", + displayName: "Pilot device", + }); + const client = scriptedClient([ + step("begin"), + receiptStep("receipt-claim"), + projectStep(sourceProjectId, sourceOwnerId), + grantsStep(actor, "engineer"), + step("from device_enrollment_intents", { + rows: [enrollmentRow()], + }), + step("from device_discoveries", { + rows: [discoveryRow()], + }), + step("insert into device_instances", { + rows: [deviceRow({ + owner_scope_id: sourceOwnerId, + project_id: sourceProjectId, + device_key: command.deviceKey, + display_name: command.displayName, + })], + }), + step("update device_discoveries", { rows: [{ id: discoveryId }] }), + step("update device_enrollment_intents", { rows: [{ id: enrollmentId }] }), + step("insert into device_ownership_transitions"), + 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.claim", + command, + digestCharacter: "b", + })); + + assert.equal(result.result.device.projectRef, `project:${sourceProjectId}`); + assert.equal(result.result.device.identifier.masked, "********0001"); + assert.equal(JSON.stringify(result.result).includes(identifierDigest), false); + assertSafeProjection(result.result); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("transfer requires explicit authority in the target project", async () => { + const actor = managementActor("owner"); + const command = transferCommand(); + const client = scriptedClient([ + step("begin"), + receiptStep("receipt-transfer-denied"), + step("from device_instances", { rows: [deviceRow()] }), + projectStep(sourceProjectId, sourceOwnerId), + grantsStep(actor, "owner"), + projectStep(targetProjectId, targetOwnerId), + step("from device_project_grants", { rows: [] }), + step("rollback"), + ]); + const repository = repositoryWithClient(client); + + await assert.rejects( + repository.executeManagementCommand(commandInput({ + actor, + commandKind: "device.transfer", + command, + digestCharacter: "c", + })), + /device_project_capability_denied/, + ); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("authorized transfer preserves history and detaches source collections", async () => { + const actor = managementActor("owner"); + const command = transferCommand(); + const client = scriptedClient([ + step("begin"), + receiptStep("receipt-transfer"), + 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("delete from device_collection_members", { rows: [], rowCount: 2 }), + step("update device_instances", { + rows: [deviceRow({ + owner_scope_id: targetOwnerId, + project_id: targetProjectId, + device_key: command.targetDeviceKey, + })], + }), + step("insert into device_ownership_transitions"), + step("insert into device_audit_events"), + 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.transfer", + command, + digestCharacter: "d", + })); + + assert.equal(result.result.transferred, true); + assert.equal(result.result.device.projectRef, `project:${targetProjectId}`); + assert.equal(result.result.detachedCollectionCount, 2); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("reject resolves both quarantine and enrollment without exposing a digest", async () => { + const actor = managementActor("member"); + const command = normalizeDeviceManagementCommand("discovery.reject", { + projectRef: `project:${sourceProjectId}`, + discoveryRef: `discovery:${discoveryId}`, + resolutionCode: "operator.identity_mismatch", + }); + const client = scriptedClient([ + step("begin"), + receiptStep("receipt-reject"), + projectStep(sourceProjectId, sourceOwnerId), + grantsStep(actor, "engineer"), + step("from device_discoveries", { rows: [discoveryRow()] }), + step("from device_enrollment_intents", { rows: [enrollmentRow()] }), + step("update device_discoveries"), + step("update device_enrollment_intents"), + 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: "discovery.reject", + command, + digestCharacter: "e", + })); + + assert.equal(result.result.discovery.lifecycleState, "rejected"); + assert.equal(JSON.stringify(result.result).includes(identifierDigest), false); + assertSafeProjection(result.result); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +function transferCommand() { + return normalizeDeviceManagementCommand("device.transfer", { + deviceRef: `device:${deviceId}`, + sourceProjectRef: `project:${sourceProjectId}`, + targetProjectRef: `project:${targetProjectId}`, + targetDeviceKey: "transferred-device", + }); +} + +function enrollmentRow() { + return { + id: enrollmentId, + project_id: sourceProjectId, + route_id: routeId, + model_profile_ref: "vendor.model.protocol.v1", + expected_identifier_kind: "serial", + expected_identifier_digest: identifierDigest, + expected_identifier_masked: "********0001", + lifecycle_state: "observed", + observed_discovery_id: discoveryId, + claimed_device_id: null, + }; +} + +function discoveryRow() { + return { + id: discoveryId, + project_id: sourceProjectId, + route_id: routeId, + enrollment_intent_id: enrollmentId, + model_profile_ref: "vendor.model.protocol.v1", + protocol: "GENERIC_TCP", + identifier_kind: "serial", + identifier_digest: identifierDigest, + identifier_masked: "********0001", + lifecycle_state: "quarantine", + claimed_device_id: null, + }; +} + +function deviceRow(overrides = {}) { + return { + id: deviceId, + contour_id: null, + owner_scope_id: sourceOwnerId, + project_id: sourceProjectId, + device_key: "pilot-device", + model_profile_ref: "vendor.model.protocol.v1", + display_name: "Pilot device", + identifier_kind: "serial", + identifier_masked: "********0001", + lifecycle_state: "claimed", + created_at: now, + updated_at: now, + ...overrides, + }; +} + +function managementActor(hubRole) { + return normalizeManagementActor({ + userRef: "user:lifecycle-operator", + hubRole, + groupRefs: [], + ownerScopes: [], + }); +} + +function projectStep(projectId, ownerScopeId) { + return step("from device_projects p", { + rows: [{ + id: projectId, + owner_scope_id: ownerScopeId, + lifecycle_state: "active", + scope_kind: "company", + owner_ref: `client:${ownerScopeId}`, + owner_display_name: "Example Company", + owner_lifecycle_state: "active", + }], + }); +} + +function grantsStep(actor, projectRole) { + return step("from device_project_grants", { + rows: [{ + id: "99999999-9999-4999-8999-999999999999", + principal_kind: "user", + principal_ref: actor.userRef, + project_role: projectRole, + capability_allow: [], + capability_deny: [], + lifecycle_state: "active", + }], + }); +} + +function receiptStep(id) { + return step("insert into device_management_command_receipts", { + rows: [{ id }], + }); +} + +function commandInput({ actor, commandKind, command, digestCharacter }) { + return { + idempotencyKey: `phase24-${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, "\\$&"); +}