feat(device-manager): add safe device enrollment

This commit is contained in:
Codex
2026-08-13 11:37:36 +03:00
parent a3385e83c4
commit d6c62da470
6 changed files with 396 additions and 14 deletions
@@ -89,6 +89,7 @@ export function createLocalPreviewDeviceCore() {
const modelProfiles = new Map();
const edges = new Map();
const routes = new Map();
const enrollments = new Map();
const bindings = new Map();
const grants = new Map();
const configurationRevisions = new Map();
@@ -131,7 +132,7 @@ export function createLocalPreviewDeviceCore() {
project: projectSummary(project),
devices: [],
discoveries: [],
enrollments: [],
enrollments: projectValues(enrollments, projectRef),
collections: projectValues(collections, projectRef)
.map(({ projectRef: _projectRef, ...collection }) => collection),
adapterPackages: [...adapterPackages.values()],
@@ -268,6 +269,12 @@ export function createLocalPreviewDeviceCore() {
);
const adapterPackageRef = existing?.adapterPackageRef
|| `adapter-package:${randomUUID()}`;
assertPreviewTransition(
existing?.lifecycleState,
input.lifecycleState ?? "active",
previewTransitions.adapterPackage,
"device_adapter_package_transition_invalid",
);
const adapterPackage = {
adapterPackageRef,
packageKey: input.packageKey,
@@ -282,15 +289,25 @@ export function createLocalPreviewDeviceCore() {
}
if (command === "adapter-versions:register") {
requirePlatformOwner(actor);
if (!adapterPackages.has(input.adapterPackageRef)) {
const adapterPackage = adapterPackages.get(input.adapterPackageRef);
if (!adapterPackage) {
throw serviceError("device_adapter_package_not_found", 404);
}
if (adapterPackage.lifecycleState !== "active") {
throw serviceError("device_adapter_package_inactive", 409);
}
const existing = [...adapterVersions.values()].find((entry) =>
entry.adapterPackageRef === input.adapterPackageRef
&& entry.version === input.version
);
const adapterVersionRef = existing?.adapterVersionRef
|| `adapter-version:${randomUUID()}`;
assertPreviewTransition(
existing?.lifecycleState,
input.lifecycleState ?? "draft",
previewTransitions.catalogVersion,
"device_adapter_version_transition_invalid",
);
const adapterVersion = {
adapterVersionRef,
adapterPackageRef: input.adapterPackageRef,
@@ -308,10 +325,21 @@ export function createLocalPreviewDeviceCore() {
}
if (command === "model-profiles:register") {
requirePlatformOwner(actor);
if (!adapterVersions.has(input.adapterVersionRef)) {
const adapterVersion = adapterVersions.get(input.adapterVersionRef);
if (!adapterVersion) {
throw serviceError("device_adapter_version_not_found", 404);
}
const existing = modelProfiles.get(input.profileRef);
const lifecycleState = input.lifecycleState ?? "draft";
if (lifecycleState === "active" && adapterVersion.lifecycleState !== "active") {
throw serviceError("device_model_profile_adapter_not_active", 409);
}
assertPreviewTransition(
existing?.lifecycleState,
lifecycleState,
previewTransitions.catalogVersion,
"device_model_profile_transition_invalid",
);
const modelProfile = {
modelProfileRef: input.profileRef,
adapterVersionRef: input.adapterVersionRef,
@@ -323,7 +351,7 @@ export function createLocalPreviewDeviceCore() {
schemaArtifactRef: input.schemaArtifactRef,
profileDigest: input.profileDigest,
capabilities: input.capabilities ?? [],
lifecycleState: input.lifecycleState ?? "draft",
lifecycleState,
createdAt: existing?.createdAt || now(),
updatedAt: now(),
};
@@ -336,6 +364,12 @@ export function createLocalPreviewDeviceCore() {
(entry) => entry.edgeKey === input.edgeKey,
);
const edgeRef = existing?.edgeRef || `edge:${randomUUID()}`;
assertPreviewTransition(
existing?.lifecycleState,
input.lifecycleState ?? "provisioning",
previewTransitions.edge,
"device_edge_transition_invalid",
);
const edge = {
edgeRef,
edgeKey: input.edgeKey,
@@ -360,6 +394,19 @@ export function createLocalPreviewDeviceCore() {
(entry) => entry.routeKey === input.routeKey,
);
const routeRef = existing?.routeRef || `route:${randomUUID()}`;
const lifecycleState = input.lifecycleState ?? "draft";
if (
lifecycleState === "active"
&& (edge.lifecycleState !== "active" || profile.lifecycleState !== "active")
) {
throw serviceError("device_route_dependency_not_active", 409);
}
assertPreviewTransition(
existing?.lifecycleState,
lifecycleState,
previewTransitions.route,
"device_route_transition_invalid",
);
const route = {
routeRef,
projectRef: input.projectRef,
@@ -372,7 +419,7 @@ export function createLocalPreviewDeviceCore() {
listenerRef: input.listenerRef,
protocol: input.protocol,
direction: input.direction ?? "telemetry",
lifecycleState: input.lifecycleState ?? "draft",
lifecycleState,
sessionCount: 0,
activeSessionCount: 0,
createdAt: existing?.createdAt || now(),
@@ -432,7 +479,62 @@ export function createLocalPreviewDeviceCore() {
throw serviceError("device_configuration_revision_not_found", 404);
}
if (command === "enrollment-intents:ensure") {
throw serviceError("device_enrollment_secure_input_required", 409);
if (!projects.has(input.projectRef)) {
throw serviceError("device_project_not_found", 404);
}
const route = routes.get(input.routeRef);
if (!route || route.projectRef !== input.projectRef) {
throw serviceError("device_route_not_found", 404);
}
if (route.lifecycleState !== "active") {
throw serviceError("device_enrollment_route_inactive", 409);
}
if (route.modelProfileRef !== input.modelProfileRef) {
throw serviceError("device_enrollment_profile_mismatch", 409);
}
if (
input.identifier?.kind !== "imei"
|| typeof input.identifier.value !== "string"
|| !/^\d{15}$/.test(input.identifier.value)
) {
throw serviceError("restricted_identifier_imei_invalid", 400);
}
const existing = projectValues(enrollments, input.projectRef).find(
(entry) => entry.enrollmentKey === input.enrollmentKey,
);
const enrollmentIntentRef = existing?.enrollmentIntentRef
|| `enrollment-intent:${randomUUID()}`;
const enrollment = {
enrollmentIntentRef,
projectRef: input.projectRef,
enrollmentKey: input.enrollmentKey,
displayName: input.displayName,
routeRef: input.routeRef,
modelProfileRef: input.modelProfileRef,
expectedIdentifier: {
kind: "imei",
masked: `***********${input.identifier.value.slice(-4)}`,
},
lifecycleState: "pending",
observedDiscoveryRef: null,
claimedDeviceRef: null,
expiresAt: input.expiresAt ?? null,
createdAt: existing?.createdAt || now(),
updatedAt: now(),
};
enrollments.set(enrollmentIntentRef, enrollment);
audit(actor, input.projectRef, createdEvent(existing, "enrollment_intent"));
const { expectedIdentifier, ...safeEnrollment } = enrollment;
return {
replayed: false,
result: {
created: !existing,
enrollmentIntent: {
...safeEnrollment,
identifier: expectedIdentifier,
},
},
};
}
if (command === "devices:claim") {
throw serviceError("device_discovery_not_found", 404);
@@ -449,6 +551,7 @@ export function createLocalPreviewDeviceCore() {
modelProfiles,
edges,
routes,
enrollments,
bindings,
grants,
configurationRevisions,
@@ -469,6 +572,37 @@ function requirePlatformOwner(actor) {
}
}
const previewTransitions = Object.freeze({
adapterPackage: Object.freeze({
active: Object.freeze(["active", "retired"]),
retired: Object.freeze(["retired"]),
}),
catalogVersion: Object.freeze({
draft: Object.freeze(["draft", "active", "retired"]),
active: Object.freeze(["active", "retired"]),
retired: Object.freeze(["retired"]),
}),
edge: Object.freeze({
provisioning: Object.freeze(["provisioning", "active", "retired"]),
active: Object.freeze(["active", "suspended", "retired"]),
suspended: Object.freeze(["suspended", "active", "retired"]),
retired: Object.freeze(["retired"]),
}),
route: Object.freeze({
draft: Object.freeze(["draft", "active", "retired"]),
active: Object.freeze(["active", "suspended", "retired"]),
suspended: Object.freeze(["suspended", "active", "retired"]),
retired: Object.freeze(["retired"]),
}),
});
function assertPreviewTransition(previous, next, transitions, code) {
if (!previous) return;
if (!transitions[previous]?.includes(next)) {
throw serviceError(code, 409);
}
}
const ownerCapabilities = Object.freeze([
"project.read",
"project.manage",
@@ -122,7 +122,7 @@ test("local preview is empty and creates resources only through canonical comman
deploymentRef: "deployment:preview-edge",
lifecycleState: "provisioning",
});
await client.execute("routes:ensure", actor, {
const route = await client.execute("routes:ensure", actor, {
projectRef,
routeKey: "preview-route",
displayName: "Preview route",
@@ -133,6 +133,56 @@ test("local preview is empty and creates resources only through canonical comman
direction: "telemetry",
lifecycleState: "draft",
});
await client.execute("adapter-versions:register", platformActor, {
adapterPackageRef: adapterPackage.result.adapterPackage.adapterPackageRef,
version: "1.0.0",
runtimePackageRef: "artifact:generic-tracker:1.0.0",
contentDigest: `sha256:${"a".repeat(64)}`,
contractVersion: "device-adapter.v1",
capabilities: ["telemetry"],
lifecycleState: "active",
});
await client.execute("model-profiles:register", platformActor, {
adapterVersionRef: adapterVersion.result.adapterVersion.adapterVersionRef,
profileRef: "generic.tracker.v1",
schemaVersion: "1.0.0",
vendor: "Generic",
model: "Tracker",
deviceType: "tracker",
protocol: "INTERNAL",
schemaArtifactRef: "schema:generic.tracker.v1",
profileDigest: `sha256:${"b".repeat(64)}`,
capabilities: ["telemetry"],
lifecycleState: "active",
});
await client.execute("edges:ensure", platformActor, {
edgeKey: "preview-edge",
displayName: "Preview Edge",
deploymentRef: "deployment:preview-edge",
lifecycleState: "active",
});
await client.execute("routes:ensure", actor, {
projectRef,
routeKey: "preview-route",
displayName: "Preview route",
edgeRef: edge.result.edge.edgeRef,
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
listenerRef: "listener:preview",
protocol: "INTERNAL",
direction: "telemetry",
lifecycleState: "active",
});
const enrollment = await client.execute("enrollment-intents:ensure", actor, {
projectRef,
enrollmentKey: "preview-device",
routeRef: route.result.route.routeRef,
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
displayName: "Preview device",
identifier: { kind: "imei", value: "123456789012345" },
expiresAt: null,
});
assert.equal(enrollment.result.enrollmentIntent.identifier.masked, "***********2345");
assert.equal(JSON.stringify(enrollment).includes("123456789012345"), false);
const collectionRef = (await client.getWorkspace(actor, projectRef))
.collections[0].collectionRef;
await client.execute("device-bindings:ensure", actor, {
@@ -161,6 +211,8 @@ test("local preview is empty and creates resources only through canonical comman
assert.equal(workspace.collections[0].collectionKey, "field-devices");
assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker");
assert.equal(workspace.routes[0].routeKey, "preview-route");
assert.equal(workspace.routes[0].lifecycleState, "active");
assert.equal(workspace.enrollments[0].expectedIdentifier.masked, "***********2345");
assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval");
assert.equal(workspace.grants.length, 2);
assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created"));