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
@@ -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();
@@ -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);
});
@@ -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);
});
@@ -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, "\\$&");
}
@@ -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) => {
@@ -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",
};
}
@@ -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, "\\$&");
}