feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,919 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const commandRoutes = new Map([
|
||||
["owner-scopes:ensure", "/internal/v1/management/owner-scopes:ensure"],
|
||||
["projects:ensure", "/internal/v1/management/projects:ensure"],
|
||||
["collections:ensure", "/internal/v1/management/collections:ensure"],
|
||||
["project-grants:upsert", "/internal/v1/management/project-grants:upsert"],
|
||||
["adapter-packages:ensure", "/internal/v1/management/adapter-packages:ensure"],
|
||||
["adapter-versions:register", "/internal/v1/management/adapter-versions:register"],
|
||||
["model-profiles:register", "/internal/v1/management/model-profiles:register"],
|
||||
["edges:ensure", "/internal/v1/management/edges:ensure"],
|
||||
["routes:ensure", "/internal/v1/management/routes:ensure"],
|
||||
["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"],
|
||||
["devices:claim", "/internal/v1/management/devices:claim"],
|
||||
["devices:update", "/internal/v1/management/devices:update"],
|
||||
["device-bindings:ensure", "/internal/v1/management/device-bindings:ensure"],
|
||||
["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"],
|
||||
[
|
||||
"device-configuration-revisions:create",
|
||||
"/internal/v1/management/device-configuration-revisions:create",
|
||||
],
|
||||
[
|
||||
"device-configurations:set-desired",
|
||||
"/internal/v1/management/device-configurations:set-desired",
|
||||
],
|
||||
["commands:service-ping", "/internal/v1/commands:service-ping"],
|
||||
]);
|
||||
|
||||
export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) {
|
||||
const endpoint = normalizeBaseUrl(baseUrl);
|
||||
if (typeof token !== "string" || token.length < 32) {
|
||||
throw serviceError("device_core_token_invalid", 503);
|
||||
}
|
||||
|
||||
async function request(pathname, actor, init = {}) {
|
||||
const response = await fetchImpl(new URL(pathname, endpoint), {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...actorHeaders(actor),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || body?.ok !== true) {
|
||||
throw serviceError(
|
||||
safeCoreError(body?.error),
|
||||
response.status >= 400 && response.status < 600 ? response.status : 502,
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
async listProjects(actor) {
|
||||
return request("/internal/v1/query/projects", actor)
|
||||
.then((body) => body.projects);
|
||||
},
|
||||
async getWorkspace(actor, projectRef) {
|
||||
const projectId = entityId(projectRef, "project");
|
||||
return request(`/internal/v1/query/projects/${projectId}/workspace`, actor)
|
||||
.then((body) => body.workspace);
|
||||
},
|
||||
async execute(command, actor, input, idempotencyKey) {
|
||||
const pathname = commandRoutes.get(command);
|
||||
if (!pathname) throw serviceError("device_manager_command_invalid", 404);
|
||||
if (!/^[\x21-\x7e]{8,256}$/.test(idempotencyKey || "")) {
|
||||
throw serviceError("device_idempotency_key_invalid", 400);
|
||||
}
|
||||
return request(pathname, actor, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
}).then(({ replayed, result }) => ({ replayed, result }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
||||
const ownerScopes = new Map();
|
||||
const projects = new Map();
|
||||
const collections = new Map();
|
||||
const adapterPackages = new Map();
|
||||
const adapterVersions = new Map();
|
||||
const modelProfiles = new Map();
|
||||
const edges = new Map();
|
||||
const routes = new Map();
|
||||
const enrollments = new Map();
|
||||
const devices = new Map();
|
||||
const sessions = new Map();
|
||||
const bindings = new Map();
|
||||
const grants = new Map();
|
||||
const configurationRevisions = new Map();
|
||||
const configurationStates = new Map();
|
||||
const auditEvents = [];
|
||||
const commands = new Map();
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function projectValues(store, projectRef) {
|
||||
return [...store.values()].filter((value) => value.projectRef === projectRef);
|
||||
}
|
||||
|
||||
function audit(actor, projectRef, eventType, refs = {}) {
|
||||
auditEvents.unshift({
|
||||
auditEventRef: `audit-event:${randomUUID()}`,
|
||||
eventType,
|
||||
actorRef: actor.userRef,
|
||||
deviceRef: refs.deviceRef ?? null,
|
||||
discoveryRef: refs.discoveryRef ?? null,
|
||||
projectRef,
|
||||
occurredAt: now(),
|
||||
});
|
||||
}
|
||||
|
||||
function projectSummary(project) {
|
||||
const projectCollections = [...collections.values()]
|
||||
.filter((collection) => collection.projectRef === project.projectRef);
|
||||
return {
|
||||
...project,
|
||||
counts: {
|
||||
devices: projectValues(devices, project.projectRef).length,
|
||||
collections: projectCollections.length,
|
||||
discoveries: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(projectRef) {
|
||||
const project = projects.get(projectRef);
|
||||
if (!project) throw serviceError("device_project_not_found", 404);
|
||||
return {
|
||||
project: projectSummary(project),
|
||||
devices: projectValues(devices, projectRef)
|
||||
.map(({ projectRef: _projectRef, ...device }) => device),
|
||||
discoveries: [],
|
||||
enrollments: projectValues(enrollments, projectRef),
|
||||
collections: projectValues(collections, projectRef)
|
||||
.map(({ projectRef: _projectRef, ...collection }) => collection),
|
||||
adapterPackages: [...adapterPackages.values()],
|
||||
adapterVersions: [...adapterVersions.values()],
|
||||
modelProfiles: [...modelProfiles.values()],
|
||||
edges: [...edges.values()],
|
||||
routes: projectValues(routes, projectRef),
|
||||
sessions: projectValues(sessions, projectRef)
|
||||
.map(({ projectRef: _projectRef, ...session }) => session),
|
||||
bindings: projectValues(bindings, projectRef),
|
||||
configurationRevisions: projectValues(configurationRevisions, projectRef),
|
||||
configurationStates: projectValues(configurationStates, projectRef),
|
||||
commands: projectValues(commands, projectRef),
|
||||
auditEvents: auditEvents.filter((event) => event.projectRef === projectRef),
|
||||
grants: projectValues(grants, projectRef),
|
||||
policies: {
|
||||
commandTransport: fixture === "arusnavi-b2"
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled",
|
||||
commandPlanningApi: fixture === "arusnavi-b2" ? "enabled" : "disabled",
|
||||
identifierProjection: fixture === "arusnavi-b2" ? "authorized-full" : "masked-only",
|
||||
auditPayloadProjection: "metadata-only",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (fixture === "arusnavi-b2") seedArusnaviB2Preview({
|
||||
ownerScopes,
|
||||
projects,
|
||||
devices,
|
||||
modelProfiles,
|
||||
edges,
|
||||
routes,
|
||||
sessions,
|
||||
configurationStates,
|
||||
});
|
||||
else if (fixture != null && fixture !== "") {
|
||||
throw serviceError("device_manager_preview_fixture_invalid", 400);
|
||||
}
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
async listProjects() {
|
||||
return [...projects.values()].map(projectSummary);
|
||||
},
|
||||
async getWorkspace(_actor, projectRef) {
|
||||
return workspace(projectRef);
|
||||
},
|
||||
async execute(command, actor, input) {
|
||||
if (command === "commands:service-ping") {
|
||||
if (fixture !== "arusnavi-b2") {
|
||||
throw serviceError("device_command_transport_disabled", 409);
|
||||
}
|
||||
const device = devices.get(input.deviceRef);
|
||||
if (!device || device.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_command_route_unavailable", 409);
|
||||
}
|
||||
if (typeof input.accessCode !== "string" || !/^\d{6}$/.test(input.accessCode)) {
|
||||
throw serviceError("device_service_ping_access_code_invalid", 400);
|
||||
}
|
||||
const commandRef = `command:${randomUUID()}`;
|
||||
const at = now();
|
||||
const view = {
|
||||
commandRef,
|
||||
projectRef: input.projectRef,
|
||||
deviceRef: input.deviceRef,
|
||||
deviceName: device.displayName,
|
||||
commandKey: `preview-service-ping-${randomUUID()}`,
|
||||
commandCatalogRef: "arusnavi.b2.internal.v1:service-ping",
|
||||
commandType: "service.ping",
|
||||
riskClass: "low",
|
||||
lifecycleState: "queued",
|
||||
plannedAt: at,
|
||||
expiresAt: new Date(Date.now() + Number(input.expiresInSeconds) * 1000).toISOString(),
|
||||
confirmedAt: null,
|
||||
dispatchedAt: null,
|
||||
acknowledgedAt: null,
|
||||
terminalAt: null,
|
||||
terminalReasonCode: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
commands.set(commandRef, view);
|
||||
return { replayed: false, result: view };
|
||||
}
|
||||
if (command === "owner-scopes:ensure") {
|
||||
const key = `${input.scopeKind}:${input.ownerRef}`;
|
||||
const created = !ownerScopes.has(key);
|
||||
const scope = {
|
||||
ownerScopeRef: ownerScopes.get(key)?.ownerScopeRef || `owner-scope:${randomUUID()}`,
|
||||
scopeKind: input.scopeKind,
|
||||
ownerRef: input.ownerRef,
|
||||
displayName: input.displayName,
|
||||
lifecycleState: "active",
|
||||
};
|
||||
ownerScopes.set(key, scope);
|
||||
return { replayed: false, result: { created, ownerScope: scope } };
|
||||
}
|
||||
if (command === "projects:ensure") {
|
||||
const scope = ownerScopes.get(`${input.scopeKind}:${input.ownerRef}`);
|
||||
if (!scope) throw serviceError("device_owner_scope_not_found", 404);
|
||||
const existing = [...projects.values()].find((project) =>
|
||||
project.ownerScope.ownerRef === input.ownerRef
|
||||
&& project.projectKey === input.projectKey
|
||||
);
|
||||
const projectRef = existing?.projectRef || `project:${randomUUID()}`;
|
||||
const project = {
|
||||
projectRef,
|
||||
projectKey: input.projectKey,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
lifecycleState: "active",
|
||||
ownerScope: scope,
|
||||
access: { projectRole: "owner", capabilities: ownerCapabilities },
|
||||
counts: { devices: 0, collections: 0, discoveries: 0 },
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
projects.set(projectRef, project);
|
||||
if (!existing) {
|
||||
const grantRef = `grant:${randomUUID()}`;
|
||||
grants.set(grantRef, {
|
||||
grantRef,
|
||||
projectRef,
|
||||
principalKind: "user",
|
||||
principalRef: actor.userRef,
|
||||
projectRole: "owner",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
audit(actor, projectRef, "project.created");
|
||||
}
|
||||
return { replayed: false, result: { created: !existing, project } };
|
||||
}
|
||||
if (command === "collections:ensure") {
|
||||
const projectRef = input.projectRef;
|
||||
if (!projects.has(projectRef)) throw serviceError("device_project_not_found", 404);
|
||||
const existing = [...collections.values()].find((collection) =>
|
||||
collection.projectRef === projectRef
|
||||
&& collection.collectionKey === input.collectionKey
|
||||
);
|
||||
const collectionRef = existing?.collectionRef || `collection:${randomUUID()}`;
|
||||
const collection = {
|
||||
collectionRef,
|
||||
projectRef,
|
||||
collectionKey: input.collectionKey,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
lifecycleState: "active",
|
||||
memberCount: existing?.memberCount || 0,
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
collections.set(collectionRef, collection);
|
||||
audit(actor, projectRef, createdEvent(existing, "collection"));
|
||||
return { replayed: false, result: { created: !existing, collection } };
|
||||
}
|
||||
if (command === "project-grants:upsert") {
|
||||
if (!projects.has(input.projectRef)) {
|
||||
throw serviceError("device_project_not_found", 404);
|
||||
}
|
||||
const existing = [...grants.values()].find((grant) =>
|
||||
grant.projectRef === input.projectRef
|
||||
&& grant.principalKind === input.principalKind
|
||||
&& grant.principalRef === input.principalRef
|
||||
);
|
||||
const grantRef = existing?.grantRef || `grant:${randomUUID()}`;
|
||||
const grant = {
|
||||
grantRef,
|
||||
projectRef: input.projectRef,
|
||||
principalKind: input.principalKind,
|
||||
principalRef: input.principalRef,
|
||||
projectRole: input.projectRole,
|
||||
capabilityAllow: input.capabilityAllow ?? [],
|
||||
capabilityDeny: input.capabilityDeny ?? [],
|
||||
lifecycleState: input.lifecycleState ?? "active",
|
||||
};
|
||||
grants.set(grantRef, grant);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "project_grant"));
|
||||
return { replayed: false, result: { created: !existing, grant } };
|
||||
}
|
||||
if (command === "adapter-packages:ensure") {
|
||||
requirePlatformOwner(actor);
|
||||
const existing = [...adapterPackages.values()].find(
|
||||
(entry) => entry.packageKey === input.packageKey,
|
||||
);
|
||||
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,
|
||||
displayName: input.displayName,
|
||||
publisherRef: input.publisherRef,
|
||||
lifecycleState: input.lifecycleState ?? "active",
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
adapterPackages.set(adapterPackageRef, adapterPackage);
|
||||
return { replayed: false, result: { created: !existing, adapterPackage } };
|
||||
}
|
||||
if (command === "adapter-versions:register") {
|
||||
requirePlatformOwner(actor);
|
||||
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,
|
||||
version: input.version,
|
||||
runtimePackageRef: input.runtimePackageRef,
|
||||
contentDigest: input.contentDigest,
|
||||
contractVersion: input.contractVersion,
|
||||
capabilities: input.capabilities ?? [],
|
||||
lifecycleState: input.lifecycleState ?? "draft",
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
adapterVersions.set(adapterVersionRef, adapterVersion);
|
||||
return { replayed: false, result: { created: !existing, adapterVersion } };
|
||||
}
|
||||
if (command === "model-profiles:register") {
|
||||
requirePlatformOwner(actor);
|
||||
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,
|
||||
schemaVersion: input.schemaVersion,
|
||||
vendor: input.vendor,
|
||||
model: input.model,
|
||||
deviceType: input.deviceType,
|
||||
protocol: input.protocol,
|
||||
schemaArtifactRef: input.schemaArtifactRef,
|
||||
profileDigest: input.profileDigest,
|
||||
capabilities: input.capabilities ?? [],
|
||||
lifecycleState,
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
modelProfiles.set(input.profileRef, modelProfile);
|
||||
return { replayed: false, result: { created: !existing, modelProfile } };
|
||||
}
|
||||
if (command === "edges:ensure") {
|
||||
requirePlatformOwner(actor);
|
||||
const existing = [...edges.values()].find(
|
||||
(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,
|
||||
displayName: input.displayName,
|
||||
deploymentRef: input.deploymentRef ?? null,
|
||||
lifecycleState: input.lifecycleState ?? "provisioning",
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
edges.set(edgeRef, edge);
|
||||
return { replayed: false, result: { created: !existing, edge } };
|
||||
}
|
||||
if (command === "routes:ensure") {
|
||||
if (!projects.has(input.projectRef)) {
|
||||
throw serviceError("device_project_not_found", 404);
|
||||
}
|
||||
const edge = edges.get(input.edgeRef);
|
||||
const profile = modelProfiles.get(input.modelProfileRef);
|
||||
if (!edge) throw serviceError("device_edge_not_found", 404);
|
||||
if (!profile) throw serviceError("device_model_profile_not_found", 404);
|
||||
const existing = projectValues(routes, input.projectRef).find(
|
||||
(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,
|
||||
routeKey: input.routeKey,
|
||||
displayName: input.displayName,
|
||||
edgeRef: input.edgeRef,
|
||||
edgeName: edge.displayName,
|
||||
modelProfileRef: input.modelProfileRef,
|
||||
profileName: `${profile.vendor} ${profile.model}`,
|
||||
listenerRef: input.listenerRef,
|
||||
protocol: input.protocol,
|
||||
direction: input.direction ?? "telemetry",
|
||||
lifecycleState,
|
||||
sessionCount: 0,
|
||||
activeSessionCount: 0,
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
routes.set(routeRef, route);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "route"));
|
||||
return { replayed: false, result: { created: !existing, route } };
|
||||
}
|
||||
if (command === "device-bindings:ensure") {
|
||||
if (!projects.has(input.projectRef)) {
|
||||
throw serviceError("device_project_not_found", 404);
|
||||
}
|
||||
if (input.source?.kind !== "collection" || !collections.has(input.source.ref)) {
|
||||
throw serviceError("device_binding_source_not_found", 404);
|
||||
}
|
||||
const existing = projectValues(bindings, input.projectRef).find(
|
||||
(entry) => entry.bindingKey === input.bindingKey,
|
||||
);
|
||||
const bindingRef = existing?.bindingRef || `binding:${randomUUID()}`;
|
||||
const source = collections.get(input.source.ref);
|
||||
const binding = {
|
||||
bindingRef,
|
||||
projectRef: input.projectRef,
|
||||
bindingKey: input.bindingKey,
|
||||
displayName: input.displayName,
|
||||
source: {
|
||||
kind: input.source.kind,
|
||||
ref: input.source.ref,
|
||||
displayName: source.name,
|
||||
},
|
||||
target: { kind: input.targetKind, ref: input.targetRef },
|
||||
capabilities: input.capabilities,
|
||||
lifecycleState: "pending_external_approval",
|
||||
sourceApprovedAt: now(),
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
bindings.set(bindingRef, binding);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "device_binding"));
|
||||
return { replayed: false, result: { created: !existing, binding } };
|
||||
}
|
||||
if (command === "device-bindings:revoke") {
|
||||
const binding = bindings.get(input.bindingRef);
|
||||
if (!binding || binding.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_binding_not_found", 404);
|
||||
}
|
||||
const revoked = { ...binding, lifecycleState: "revoked", updatedAt: now() };
|
||||
bindings.set(binding.bindingRef, revoked);
|
||||
audit(actor, input.projectRef, "device_binding.revoked");
|
||||
return { replayed: false, result: { revoked: true, binding: revoked } };
|
||||
}
|
||||
if (command === "device-configuration-revisions:create") {
|
||||
throw serviceError("device_not_found", 404);
|
||||
}
|
||||
if (command === "device-configurations:set-desired") {
|
||||
throw serviceError("device_configuration_revision_not_found", 404);
|
||||
}
|
||||
if (command === "enrollment-intents:ensure") {
|
||||
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);
|
||||
}
|
||||
if (command === "devices:update") {
|
||||
const device = devices.get(input.deviceRef);
|
||||
if (!device || device.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_not_found", 404);
|
||||
}
|
||||
if (typeof input.displayName !== "string" || !input.displayName.trim()) {
|
||||
throw serviceError("device_display_name_invalid", 400);
|
||||
}
|
||||
const updated = {
|
||||
...device,
|
||||
displayName: input.displayName.trim(),
|
||||
integrationDeviceId: typeof input.integrationDeviceId === "string"
|
||||
? input.integrationDeviceId.trim() || null
|
||||
: null,
|
||||
updatedAt: now(),
|
||||
};
|
||||
devices.set(input.deviceRef, updated);
|
||||
audit(actor, input.projectRef, "device.updated");
|
||||
return { replayed: false, result: { updated: true, device: updated } };
|
||||
}
|
||||
throw serviceError("device_manager_command_invalid", 404);
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
ownerScopes,
|
||||
projects,
|
||||
collections,
|
||||
adapterPackages,
|
||||
adapterVersions,
|
||||
modelProfiles,
|
||||
edges,
|
||||
routes,
|
||||
enrollments,
|
||||
devices,
|
||||
sessions,
|
||||
bindings,
|
||||
grants,
|
||||
configurationRevisions,
|
||||
configurationStates,
|
||||
commands,
|
||||
auditEvents,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createdEvent(existing, resource) {
|
||||
return `${resource}.${existing ? "updated" : "created"}`;
|
||||
}
|
||||
|
||||
function requirePlatformOwner(actor) {
|
||||
if (actor?.hubRole !== "owner") {
|
||||
throw serviceError("device_platform_catalog_access_denied", 403);
|
||||
}
|
||||
}
|
||||
|
||||
function seedArusnaviB2Preview({
|
||||
ownerScopes,
|
||||
projects,
|
||||
devices,
|
||||
modelProfiles,
|
||||
edges,
|
||||
routes,
|
||||
sessions,
|
||||
configurationStates,
|
||||
}) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const ownerScopeRef = "owner-scope:78da71d5-f48f-4de0-8e47-729f6d644151";
|
||||
const projectRef = "project:ad7b357c-c7ac-4bf8-a638-c7f956e9aa71";
|
||||
const deviceRef = "device:b6a55921-7888-44b5-a93e-241aa2fdd3d7";
|
||||
const edgeRef = "edge:73da0c42-a641-4559-b8f7-23509b60bfe9";
|
||||
const routeRef = "route:fef9b7a0-a462-4d68-9991-af026203368b";
|
||||
const sessionRef = "session:57ead610-47de-45f7-a42d-fbe4fa0aba38";
|
||||
const scope = {
|
||||
ownerScopeRef,
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:local-device-admin",
|
||||
displayName: "Local Device Admin",
|
||||
lifecycleState: "active",
|
||||
};
|
||||
ownerScopes.set("personal:user:local-device-admin", scope);
|
||||
projects.set(projectRef, {
|
||||
projectRef,
|
||||
projectKey: "arusnavi-b2-preview",
|
||||
name: "ARUSNAVI B2 preview",
|
||||
description: "Локальная визуальная фикстура пилотного ARUSNAVI B2",
|
||||
lifecycleState: "active",
|
||||
ownerScope: scope,
|
||||
access: { projectRole: "owner", capabilities: ownerCapabilities },
|
||||
counts: { devices: 1, collections: 0, discoveries: 0 },
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
modelProfiles.set("arusnavi.b2.internal.v1", {
|
||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||
adapterVersionRef: null,
|
||||
schemaVersion: "1.0.0",
|
||||
vendor: "ARUSNAVI",
|
||||
model: "B2",
|
||||
deviceType: "tracker",
|
||||
protocol: "INTERNAL",
|
||||
schemaArtifactRef: "schema:arusnavi.b2.internal.v1",
|
||||
profileDigest: null,
|
||||
capabilities: ["telemetry", "configuration", "commands"],
|
||||
lifecycleState: "active",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
edges.set(edgeRef, {
|
||||
edgeRef,
|
||||
edgeKey: "preview-edge",
|
||||
displayName: "Preview VPS edge",
|
||||
deploymentRef: "deployment:preview",
|
||||
lifecycleState: "active",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
routes.set(routeRef, {
|
||||
routeRef,
|
||||
projectRef,
|
||||
routeKey: "preview-b2-route",
|
||||
displayName: "B2 direct preview",
|
||||
edgeRef,
|
||||
edgeName: "Preview VPS edge",
|
||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||
profileName: "ARUSNAVI B2",
|
||||
listenerRef: "listener:preview",
|
||||
protocol: "INTERNAL",
|
||||
direction: "bidirectional",
|
||||
lifecycleState: "active",
|
||||
sessionCount: 1,
|
||||
activeSessionCount: 1,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
devices.set(deviceRef, {
|
||||
projectRef,
|
||||
deviceRef,
|
||||
deviceKey: "pilot-b2-preview",
|
||||
displayName: "Пилотный B2",
|
||||
integrationDeviceId: "8028",
|
||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||
lifecycleState: "active",
|
||||
identifier: {
|
||||
kind: "imei",
|
||||
masked: "***********1088",
|
||||
value: "863151070211088",
|
||||
},
|
||||
session: { state: "online", lastSeenAt: timestamp },
|
||||
reported: {
|
||||
observedAt: timestamp,
|
||||
identity: {
|
||||
imei: "863151070211088",
|
||||
iccid1: "****************1111",
|
||||
iccid2: "****************2222",
|
||||
},
|
||||
firmware: { currentVersion: "0.02", appliedAt: timestamp, availableVersion: "0.05" },
|
||||
configuration: {
|
||||
monitoring: {
|
||||
servers: [
|
||||
{ host: "legacy.example.invalid", port: 20623, protocol: "INTERNAL", identity: "0" },
|
||||
{ host: "direct.example.invalid", port: 9921, protocol: "INTERNAL", identity: "0" },
|
||||
],
|
||||
},
|
||||
transmission: { navigation: { position: true, motion: true, hdop: false } },
|
||||
trajectory: {
|
||||
normal: { courseDeltaDegrees: 15, speedDeltaKph: 10, distanceMeters: 15, parkingIntervalSeconds: 15 },
|
||||
roaming: { courseDeltaDegrees: 20, speedDeltaKph: 50, distanceMeters: 1000, parkingIntervalSeconds: 300 },
|
||||
},
|
||||
navigation: {
|
||||
sources: { satellite: true, wifi: false, lbs: false, tag: false },
|
||||
constellations: { gps: true, glonass: true, galileo: false, beidou: false },
|
||||
filter: { minimumSatellites: 4, maximumHdopTimesTen: 30 },
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
navigation: {
|
||||
latitude: "55.7500",
|
||||
longitude: "37.6200",
|
||||
speedKph: 18,
|
||||
altitudeMeters: 156,
|
||||
satellites: 12,
|
||||
courseDegrees: 84,
|
||||
hdop: 1.2,
|
||||
},
|
||||
gsm: { signal: 79, operator: "preview", lac: "masked", cid: "masked" },
|
||||
system: { externalVoltageMv: 13240, internalVoltageMv: 4120, status: "Норма" },
|
||||
},
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
sessions.set(sessionRef, {
|
||||
sessionRef,
|
||||
projectRef,
|
||||
routeRef,
|
||||
routeName: "B2 direct preview",
|
||||
deviceRef,
|
||||
deviceName: "Пилотный B2",
|
||||
protocol: "INTERNAL",
|
||||
lifecycleState: "online",
|
||||
connectedAt: timestamp,
|
||||
lastSeenAt: timestamp,
|
||||
disconnectedAt: null,
|
||||
closeReasonCode: null,
|
||||
frameCount: 1842,
|
||||
byteCount: 734208,
|
||||
});
|
||||
configurationStates.set(deviceRef, {
|
||||
projectRef,
|
||||
deviceRef,
|
||||
deviceName: "Пилотный B2",
|
||||
desiredConfigurationRevisionRef: null,
|
||||
appliedConfigurationRevisionRef: null,
|
||||
appliedAt: null,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
"access.manage",
|
||||
"inventory.read",
|
||||
"device.enroll",
|
||||
"device.claim",
|
||||
"device.transfer",
|
||||
"collection.manage",
|
||||
"route.manage",
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
"command.dispatch",
|
||||
"credential.manage",
|
||||
"audit.read",
|
||||
]);
|
||||
|
||||
function actorHeaders(actor) {
|
||||
if (!actor || typeof actor !== "object") throw serviceError("device_actor_required", 401);
|
||||
return {
|
||||
"X-NODEDC-User-Ref": actor.userRef,
|
||||
"X-NODEDC-Hub-Role": actor.hubRole,
|
||||
"X-NODEDC-Group-Refs": (actor.groupRefs ?? []).join(","),
|
||||
"X-NODEDC-Owner-Scopes": (actor.ownerScopes ?? [])
|
||||
.map((scope) => `${scope.scopeKind}=${scope.ownerRef}`)
|
||||
.join(","),
|
||||
};
|
||||
}
|
||||
|
||||
function entityId(value, prefix) {
|
||||
const match = String(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 serviceError(`device_${prefix}_ref_invalid`, 400);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw serviceError("device_core_url_required", 503);
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
|
||||
throw serviceError("device_core_url_invalid", 503);
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/$/, "") || "/";
|
||||
return url;
|
||||
}
|
||||
|
||||
function safeCoreError(value) {
|
||||
return typeof value === "string" && /^device_[a-z0-9._:-]{2,120}$/.test(value)
|
||||
? value
|
||||
: "device_core_unavailable";
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
Reference in New Issue
Block a user