feat(device-core): add device ownership lifecycle

This commit is contained in:
Codex
2026-08-10 18:03:56 +03:00
parent 72db23c0e9
commit fceaca9546
17 changed files with 2218 additions and 54 deletions
@@ -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,
@@ -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;
}
@@ -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}`);
}
}
}
@@ -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;
}
@@ -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);
}
@@ -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,