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;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createDeviceCoreClient,
|
||||
createLocalPreviewDeviceCore,
|
||||
} from "./device-core-client.mjs";
|
||||
|
||||
const token = "device-core-test-token-that-is-never-exposed";
|
||||
const actor = Object.freeze({
|
||||
userRef: "user:device-admin",
|
||||
hubRole: "admin",
|
||||
groupRefs: ["group:device-engineers"],
|
||||
ownerScopes: [{ scopeKind: "personal", ownerRef: "user:device-admin" }],
|
||||
});
|
||||
const platformActor = Object.freeze({ ...actor, hubRole: "owner" });
|
||||
|
||||
test("Device Core client creates trusted actor headers and keeps its token server-side", async () => {
|
||||
const calls = [];
|
||||
const client = createDeviceCoreClient({
|
||||
baseUrl: "http://device-control-core:3210",
|
||||
token,
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return jsonResponse(200, { ok: true, projects: [] });
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await client.listProjects(actor), []);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.headers.Authorization, `Bearer ${token}`);
|
||||
assert.equal(calls[0].init.headers["X-NODEDC-User-Ref"], actor.userRef);
|
||||
assert.equal(calls[0].init.headers["X-NODEDC-Hub-Role"], "admin");
|
||||
assert.equal(calls[0].init.headers["X-NODEDC-Group-Refs"], "group:device-engineers");
|
||||
assert.equal(
|
||||
calls[0].init.headers["X-NODEDC-Owner-Scopes"],
|
||||
"personal=user:device-admin",
|
||||
);
|
||||
assert.equal(JSON.stringify(await client.listProjects(actor)).includes(token), false);
|
||||
});
|
||||
|
||||
test("Device Core client accepts only canonical commands and entity refs", async () => {
|
||||
const client = createDeviceCoreClient({
|
||||
baseUrl: "http://127.0.0.1:3210",
|
||||
token,
|
||||
fetchImpl: async () => jsonResponse(200, { ok: true, replayed: false, result: {} }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
client.execute("raw:proxy", actor, {}, "device-manager-12345678"),
|
||||
/device_manager_command_invalid/,
|
||||
);
|
||||
await assert.rejects(
|
||||
client.getWorkspace(actor, "project:not-a-uuid"),
|
||||
/device_project_ref_invalid/,
|
||||
);
|
||||
await assert.rejects(
|
||||
client.execute("projects:ensure", actor, {}, "short"),
|
||||
/device_idempotency_key_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("local preview is empty and creates resources only through canonical commands", async () => {
|
||||
const client = createLocalPreviewDeviceCore();
|
||||
assert.deepEqual(await client.listProjects(actor), []);
|
||||
|
||||
const owner = await client.execute("owner-scopes:ensure", actor, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: actor.userRef,
|
||||
displayName: "Device Admin",
|
||||
});
|
||||
assert.equal(owner.result.created, true);
|
||||
|
||||
const created = await client.execute("projects:ensure", actor, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: actor.userRef,
|
||||
projectKey: "sandbox",
|
||||
name: "Device sandbox",
|
||||
description: null,
|
||||
});
|
||||
assert.equal(created.result.created, true);
|
||||
const projectRef = created.result.project.projectRef;
|
||||
|
||||
await client.execute("collections:ensure", actor, {
|
||||
projectRef,
|
||||
collectionKey: "field-devices",
|
||||
name: "Field devices",
|
||||
description: null,
|
||||
});
|
||||
|
||||
const adapterPackage = await client.execute("adapter-packages:ensure", platformActor, {
|
||||
packageKey: "generic-tracker",
|
||||
displayName: "Generic tracker",
|
||||
publisherRef: "publisher:nodedc",
|
||||
lifecycleState: "active",
|
||||
});
|
||||
const adapterVersion = 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: "draft",
|
||||
});
|
||||
const modelProfile = 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: "draft",
|
||||
});
|
||||
const edge = await client.execute("edges:ensure", platformActor, {
|
||||
edgeKey: "preview-edge",
|
||||
displayName: "Preview Edge",
|
||||
deploymentRef: "deployment:preview-edge",
|
||||
lifecycleState: "provisioning",
|
||||
});
|
||||
const route = 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: "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, {
|
||||
projectRef,
|
||||
bindingKey: "preview-binding",
|
||||
displayName: "Preview binding",
|
||||
source: { kind: "collection", ref: collectionRef },
|
||||
targetKind: "foundry.application",
|
||||
targetRef: "application:preview-map",
|
||||
capabilities: ["observe"],
|
||||
});
|
||||
await client.execute("project-grants:upsert", actor, {
|
||||
projectRef,
|
||||
principalKind: "group",
|
||||
principalRef: "group:preview-viewers",
|
||||
projectRole: "viewer",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
|
||||
const projects = await client.listProjects(actor);
|
||||
assert.equal(projects.length, 1);
|
||||
assert.equal(projects[0].counts.collections, 1);
|
||||
const workspace = await client.getWorkspace(actor, projectRef);
|
||||
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"));
|
||||
assert.equal(workspace.policies.commandTransport, "disabled");
|
||||
assert.deepEqual(workspace.devices, []);
|
||||
});
|
||||
|
||||
test("explicit B2 preview fixture is isolated from the empty canonical preview", async () => {
|
||||
const client = createLocalPreviewDeviceCore({ fixture: "arusnavi-b2" });
|
||||
const projects = await client.listProjects(actor);
|
||||
assert.equal(projects.length, 1);
|
||||
assert.equal(projects[0].counts.devices, 1);
|
||||
const workspace = await client.getWorkspace(actor, projects[0].projectRef);
|
||||
assert.equal(workspace.devices[0].modelProfileRef, "arusnavi.b2.internal.v1");
|
||||
assert.equal(workspace.devices[0].identifier.masked, "***********1088");
|
||||
assert.equal(workspace.devices[0].identifier.value, "863151070211088");
|
||||
assert.equal(workspace.devices[0].integrationDeviceId, "8028");
|
||||
assert.equal(workspace.sessions[0].lifecycleState, "online");
|
||||
assert.equal(workspace.policies.commandTransport, "typed-service-ping-v1");
|
||||
assert.equal(workspace.policies.identifierProjection, "authorized-full");
|
||||
assert.equal(JSON.stringify(workspace).includes("123456789012345"), false);
|
||||
|
||||
assert.throws(
|
||||
() => createLocalPreviewDeviceCore({ fixture: "unknown" }),
|
||||
/device_manager_preview_fixture_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
function jsonResponse(status, body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const DEFAULT_VALIDATION_TTL_MS = 20_000;
|
||||
const DEFAULT_VALIDATION_GRACE_MS = 30_000;
|
||||
|
||||
export function createDeviceManagerAuth({
|
||||
env = process.env,
|
||||
fetchImpl = fetch,
|
||||
now = Date.now,
|
||||
internalToken: providedInternalToken,
|
||||
} = {}) {
|
||||
const authRequired = booleanValue(
|
||||
env.NODEDC_DEVICE_MANAGER_AUTH_REQUIRED,
|
||||
env.NODE_ENV === "production",
|
||||
);
|
||||
const serviceSlug = textValue(env.NODEDC_DEVICE_MANAGER_SERVICE_SLUG, "device-core");
|
||||
const launcherBaseUrl = baseUrl(env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173");
|
||||
const launcherInternalUrl = baseUrl(env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl);
|
||||
const internalToken = textValue(
|
||||
providedInternalToken
|
||||
|| env.NODEDC_INTERNAL_ACCESS_TOKEN
|
||||
|| env.NODEDC_PLATFORM_SERVICE_TOKEN,
|
||||
"",
|
||||
);
|
||||
const sessionCookie = textValue(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_COOKIE,
|
||||
"nodedc_device_manager_session",
|
||||
);
|
||||
const sessionTtlMs = boundedInteger(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_TTL_MS,
|
||||
DEFAULT_SESSION_TTL_MS,
|
||||
60_000,
|
||||
24 * 60 * 60 * 1000,
|
||||
);
|
||||
const validationTtlMs = boundedInteger(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_TTL_MS,
|
||||
DEFAULT_VALIDATION_TTL_MS,
|
||||
15_000,
|
||||
30_000,
|
||||
);
|
||||
const validationGraceMs = boundedInteger(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_GRACE_MS,
|
||||
DEFAULT_VALIDATION_GRACE_MS,
|
||||
0,
|
||||
60_000,
|
||||
);
|
||||
const secureCookie = booleanValue(
|
||||
env.NODEDC_DEVICE_MANAGER_COOKIE_SECURE,
|
||||
authRequired,
|
||||
);
|
||||
const sessions = new Map();
|
||||
|
||||
function buildCookie(value, maxAgeSeconds) {
|
||||
return [
|
||||
`${sessionCookie}=${encodeURIComponent(value)}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
||||
...(secureCookie ? ["Secure"] : []),
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function createSession(response, handoff) {
|
||||
pruneSessions();
|
||||
const id = randomBytes(32).toString("base64url");
|
||||
const createdAt = now();
|
||||
sessions.set(id, {
|
||||
id,
|
||||
user: handoff.user,
|
||||
access: handoff.access,
|
||||
launcherSessionId: handoff.launcherSessionId,
|
||||
expiresAt: createdAt + sessionTtlMs,
|
||||
validatedAt: createdAt,
|
||||
validationInFlight: null,
|
||||
});
|
||||
appendCookie(response, buildCookie(id, sessionTtlMs / 1000));
|
||||
}
|
||||
|
||||
function currentSession(request) {
|
||||
const id = parseCookies(request.headers.cookie)[sessionCookie];
|
||||
const session = id ? sessions.get(id) : null;
|
||||
if (!session || session.expiresAt <= now()) {
|
||||
if (id) sessions.delete(id);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async function launcherRequest(pathname, payload) {
|
||||
if (!internalToken) throw serviceError("device_manager_auth_not_configured", 503);
|
||||
const response = await fetchImpl(new URL(pathname, launcherInternalUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${internalToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function handleHandoff(request, response, url) {
|
||||
const nextPath = safeReturnTo(
|
||||
url.searchParams.get("next_path") || url.searchParams.get("returnTo"),
|
||||
);
|
||||
if (!authRequired) return redirect(response, nextPath);
|
||||
const token = String(url.searchParams.get("token") || "");
|
||||
if (!token) return sendText(response, 400, "Missing Launcher handoff token.");
|
||||
try {
|
||||
const result = await launcherRequest("/api/internal/handoff/consume", {
|
||||
token,
|
||||
serviceSlug,
|
||||
});
|
||||
if (!result.response.ok || result.body?.ok !== true || !result.body?.user) {
|
||||
return sendText(response, 401, "Launcher handoff rejected.");
|
||||
}
|
||||
createSession(response, {
|
||||
user: result.body.user,
|
||||
access: result.body.access,
|
||||
launcherSessionId: result.body.launcherSessionId ?? null,
|
||||
});
|
||||
return redirect(response, nextPath);
|
||||
} catch {
|
||||
return sendText(response, 401, "Launcher handoff failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function validatedSession(request, response) {
|
||||
if (!authRequired) {
|
||||
return attachSession(request, {
|
||||
user: {
|
||||
id: "local-device-admin",
|
||||
email: "local-device-admin@nodedc.local",
|
||||
name: "Local Device Admin",
|
||||
avatarUrl: null,
|
||||
groups: ["nodedc:superadmin"],
|
||||
},
|
||||
});
|
||||
}
|
||||
const session = currentSession(request);
|
||||
if (!session) {
|
||||
clearCookie(response);
|
||||
return null;
|
||||
}
|
||||
if (now() - session.validatedAt <= validationTtlMs) {
|
||||
return attachSession(request, session);
|
||||
}
|
||||
if (!session.validationInFlight) {
|
||||
session.validationInFlight = launcherRequest("/api/internal/session/validate", {
|
||||
serviceSlug,
|
||||
launcherSessionId: session.launcherSessionId,
|
||||
}).finally(() => {
|
||||
session.validationInFlight = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { response: upstream, body } = await session.validationInFlight;
|
||||
if (upstream.ok && body?.ok === true && body.active === true) {
|
||||
session.user = body.user || session.user;
|
||||
session.access = body.access;
|
||||
session.validatedAt = now();
|
||||
return attachSession(request, session);
|
||||
}
|
||||
if (upstream.ok && body?.ok === true && body.active === false) {
|
||||
sessions.delete(session.id);
|
||||
clearCookie(response);
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Read-only grace is resolved below; mutations always fail closed.
|
||||
}
|
||||
const readOnly = request.method === "GET" || request.method === "HEAD";
|
||||
if (readOnly && now() - session.validatedAt <= validationTtlMs + validationGraceMs) {
|
||||
return attachSession(request, session);
|
||||
}
|
||||
request.nodedcDeviceManagerAuthUnavailable = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function authorize(request, response, url) {
|
||||
if (
|
||||
url.pathname === "/healthz"
|
||||
|| url.pathname === "/auth/nodedc/handoff"
|
||||
|| url.pathname === "/auth/logout"
|
||||
) return false;
|
||||
const session = await validatedSession(request, response);
|
||||
if (!session) {
|
||||
if (request.nodedcDeviceManagerAuthUnavailable) {
|
||||
sendJson(response, 503, { ok: false, error: "device_manager_auth_unavailable" });
|
||||
return true;
|
||||
}
|
||||
const loginUrl = new URL("/auth/login", launcherBaseUrl);
|
||||
const launch = new URL(`/api/services/${encodeURIComponent(serviceSlug)}/launch`, launcherBaseUrl);
|
||||
launch.searchParams.set("returnTo", safeReturnTo(`${url.pathname}${url.search}`));
|
||||
loginUrl.searchParams.set("returnTo", `${launch.pathname}${launch.search}`);
|
||||
if (isHtmlRequest(request, url)) {
|
||||
redirect(response, loginUrl.toString());
|
||||
return true;
|
||||
}
|
||||
sendJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_manager_auth_required",
|
||||
loginUrl: loginUrl.toString(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const access = resolveAccess(session.user, session.access, { allowLegacy: !authRequired });
|
||||
if (!access.allowed) {
|
||||
sendJson(response, 403, {
|
||||
ok: false,
|
||||
error: access.blocked
|
||||
? "device_manager_access_blocked"
|
||||
: "device_manager_access_denied",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
request.nodedcDeviceManagerAccess = access;
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleLogout(request, response) {
|
||||
const id = parseCookies(request.headers.cookie)[sessionCookie];
|
||||
if (id) sessions.delete(id);
|
||||
clearCookie(response);
|
||||
redirect(response, "/");
|
||||
}
|
||||
|
||||
function currentContext(request) {
|
||||
const user = request.nodedcDeviceManagerSession?.user;
|
||||
const trustedAccess = request.nodedcDeviceManagerSession?.access;
|
||||
const access = request.nodedcDeviceManagerAccess
|
||||
?? resolveAccess(user, trustedAccess, { allowLegacy: !authRequired });
|
||||
if (!user || !access.allowed) return null;
|
||||
const id = cleanOpaque(user.id || user.subject || user.sub);
|
||||
if (!id) return null;
|
||||
const email = String(user.email || "").trim().slice(0, 240);
|
||||
const displayName = String(user.name || user.displayName || email || "NODE.DC")
|
||||
.trim()
|
||||
.slice(0, 240);
|
||||
const avatar = String(user.avatarUrl || user.avatar_url || user.picture || "").trim();
|
||||
const userRef = `user:${id}`;
|
||||
return {
|
||||
user: {
|
||||
id,
|
||||
email,
|
||||
displayName,
|
||||
avatarUrl: /^https:\/\//i.test(avatar) || avatar.startsWith("/") ? avatar : null,
|
||||
initials: initials(displayName),
|
||||
},
|
||||
actor: {
|
||||
userRef,
|
||||
hubRole: access.hubRole,
|
||||
groupRefs: access.groups.map((group) => `group:${group}`),
|
||||
ownerScopes: access.ownerScopes,
|
||||
},
|
||||
profileUrl: new URL("/profile", launcherBaseUrl).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
function clearCookie(response) {
|
||||
appendCookie(response, buildCookie("", 0));
|
||||
}
|
||||
|
||||
function pruneSessions() {
|
||||
const current = now();
|
||||
for (const [id, session] of sessions) {
|
||||
if (session.expiresAt <= current) sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authRequired,
|
||||
internalAccessConfigured: Boolean(internalToken),
|
||||
serviceSlug,
|
||||
authorize,
|
||||
currentContext,
|
||||
handleHandoff,
|
||||
handleLogout,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAccess(user, trustedAccess, { allowLegacy = false } = {}) {
|
||||
if (!user || typeof user !== "object") {
|
||||
return deniedAccess();
|
||||
}
|
||||
const groups = normalizedGroups(user);
|
||||
if (groups.includes("nodedc:device-core:blocked")) {
|
||||
return { ...deniedAccess(groups), blocked: true };
|
||||
}
|
||||
const id = cleanOpaque(user.id || user.subject || user.sub);
|
||||
if (!id) return deniedAccess(groups);
|
||||
const claims = normalizeTrustedAccess(trustedAccess, id);
|
||||
if (claims) return { ...claims, blocked: false, groups };
|
||||
if (!allowLegacy) return deniedAccess(groups);
|
||||
const hubRole = id === "user_root" || groups.includes("nodedc:superadmin")
|
||||
? "owner"
|
||||
: groups.includes("nodedc:device-core:admin") || groups.includes("nodedc:launcher:admin")
|
||||
? "admin"
|
||||
: groups.includes("nodedc:device-core:viewer")
|
||||
? "viewer"
|
||||
: "member";
|
||||
const ownerScopes = ["admin", "owner"].includes(hubRole)
|
||||
? [{
|
||||
scopeKind: "personal",
|
||||
ownerRef: `user:${id}`,
|
||||
displayName: String(user.name || user.displayName || user.email || id).trim().slice(0, 240),
|
||||
}]
|
||||
: [];
|
||||
return { allowed: true, blocked: false, hubRole, groups, ownerScopes };
|
||||
}
|
||||
|
||||
function normalizeTrustedAccess(input, userId) {
|
||||
if (!input || typeof input !== "object" || input.allowed !== true) return null;
|
||||
const hubRole = ["viewer", "member", "admin", "owner"].includes(input.hubRole)
|
||||
? input.hubRole
|
||||
: null;
|
||||
if (!hubRole || !Array.isArray(input.ownerScopes)) return null;
|
||||
const ownerScopes = [];
|
||||
for (const item of input.ownerScopes) {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const scopeKind = item.scopeKind === "company" || item.scopeKind === "personal"
|
||||
? item.scopeKind
|
||||
: null;
|
||||
const ownerRef = cleanOpaque(item.ownerRef);
|
||||
const validOwner = scopeKind === "personal"
|
||||
? ownerRef === `user:${userId}`
|
||||
: ownerRef?.startsWith("client:") && ownerRef.length > "client:".length;
|
||||
if (!scopeKind || !validOwner) return null;
|
||||
ownerScopes.push({
|
||||
scopeKind,
|
||||
ownerRef,
|
||||
displayName: String(item.displayName || ownerRef).trim().slice(0, 240),
|
||||
});
|
||||
}
|
||||
return {
|
||||
allowed: true,
|
||||
hubRole,
|
||||
ownerScopes: [...new Map(ownerScopes.map((scope) => [
|
||||
`${scope.scopeKind}\0${scope.ownerRef}`,
|
||||
scope,
|
||||
])).values()],
|
||||
};
|
||||
}
|
||||
|
||||
function deniedAccess(groups = []) {
|
||||
return {
|
||||
allowed: false,
|
||||
blocked: false,
|
||||
hubRole: "viewer",
|
||||
groups,
|
||||
ownerScopes: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedGroups(user) {
|
||||
const values = [user.groups, user.roles, user.roleKeys, user.permissions];
|
||||
const groups = [];
|
||||
for (const value of values) {
|
||||
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
||||
for (const item of items) {
|
||||
const raw = typeof item === "string" ? item : item?.name || item?.key || item?.slug;
|
||||
const normalized = String(raw || "").trim().toLowerCase();
|
||||
if (/^[a-z0-9][a-z0-9._:-]{1,127}$/.test(normalized)) groups.push(normalized);
|
||||
}
|
||||
}
|
||||
return [...new Set(groups)].sort();
|
||||
}
|
||||
|
||||
function attachSession(request, session) {
|
||||
request.nodedcDeviceManagerSession = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
function cleanOpaque(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/.test(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseCookies(header = "") {
|
||||
const values = {};
|
||||
for (const part of String(header).split(";")) {
|
||||
const index = part.indexOf("=");
|
||||
if (index < 1) continue;
|
||||
const key = part.slice(0, index).trim();
|
||||
try {
|
||||
values[key] = decodeURIComponent(part.slice(index + 1).trim());
|
||||
} catch {
|
||||
values[key] = part.slice(index + 1).trim();
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function appendCookie(response, value) {
|
||||
const current = response.getHeader("Set-Cookie");
|
||||
response.setHeader("Set-Cookie", current ? [current, value].flat() : value);
|
||||
}
|
||||
|
||||
function safeReturnTo(value) {
|
||||
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
|
||||
? value
|
||||
: "/";
|
||||
}
|
||||
|
||||
function isHtmlRequest(request, url) {
|
||||
return request.method === "GET"
|
||||
&& !url.pathname.startsWith("/api/")
|
||||
&& (url.pathname === "/" || String(request.headers.accept || "").includes("text/html"));
|
||||
}
|
||||
|
||||
function redirect(response, location) {
|
||||
response.statusCode = 302;
|
||||
response.setHeader("Location", location);
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end();
|
||||
}
|
||||
|
||||
function sendJson(response, status, body) {
|
||||
response.statusCode = status;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function sendText(response, status, body) {
|
||||
response.statusCode = status;
|
||||
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function initials(value) {
|
||||
return value.split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map((part) => part[0]).join("").toUpperCase() || "DC";
|
||||
}
|
||||
|
||||
function booleanValue(value, fallback) {
|
||||
if (value == null || value === "") return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
||||
}
|
||||
|
||||
function boundedInteger(value, fallback, min, max) {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
}
|
||||
|
||||
function textValue(value, fallback) {
|
||||
return String(value || fallback).trim();
|
||||
}
|
||||
|
||||
function baseUrl(value, fallback) {
|
||||
return textValue(value, fallback).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
||||
|
||||
const internalToken = "launcher-internal-token-must-stay-server-side";
|
||||
const launcherSessionId = "launcher-session-id-must-stay-server-side";
|
||||
|
||||
test("Launcher handoff becomes an opaque Device Manager session and trusted actor", async () => {
|
||||
const calls = [];
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: productionEnv(),
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return jsonResponse(200, {
|
||||
ok: true,
|
||||
launcherSessionId,
|
||||
access: {
|
||||
allowed: true,
|
||||
hubRole: "owner",
|
||||
ownerScopes: [
|
||||
{
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:client_dctouch",
|
||||
displayName: "DC Touch",
|
||||
},
|
||||
{
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:user_root",
|
||||
displayName: "DC SUDO",
|
||||
},
|
||||
],
|
||||
},
|
||||
user: {
|
||||
id: "user_root",
|
||||
email: "root@example.test",
|
||||
name: "DC SUDO",
|
||||
groups: ["nodedc:superadmin", "nodedc:device-core:admin"],
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const response = mockResponse();
|
||||
await auth.handleHandoff(
|
||||
{ method: "GET", headers: {} },
|
||||
response,
|
||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret&next_path=%2F"),
|
||||
);
|
||||
|
||||
assert.equal(response.statusCode, 302);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.headers.Authorization, `Bearer ${internalToken}`);
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||
token: "handoff-secret",
|
||||
serviceSlug: "device-core",
|
||||
});
|
||||
const cookie = String(response.getHeader("set-cookie")).split(";", 1)[0];
|
||||
assert.match(cookie, /^nodedc_device_manager_session=[A-Za-z0-9_-]{40,}$/);
|
||||
assert.equal(cookie.includes("user_root"), false);
|
||||
assert.equal(cookie.includes(launcherSessionId), false);
|
||||
|
||||
const request = { method: "GET", headers: { cookie, accept: "application/json" } };
|
||||
const authorized = await auth.authorize(
|
||||
request,
|
||||
mockResponse(),
|
||||
new URL("https://device.example.test/api/device-manager/session"),
|
||||
);
|
||||
assert.equal(authorized, false);
|
||||
const context = auth.currentContext(request);
|
||||
assert.equal(context.actor.userRef, "user:user_root");
|
||||
assert.equal(context.actor.hubRole, "owner");
|
||||
assert.deepEqual(context.actor.ownerScopes, [
|
||||
{
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:client_dctouch",
|
||||
displayName: "DC Touch",
|
||||
},
|
||||
{
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:user_root",
|
||||
displayName: "DC SUDO",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(context.actor.groupRefs, [
|
||||
"group:nodedc:device-core:admin",
|
||||
"group:nodedc:superadmin",
|
||||
]);
|
||||
assert.equal(JSON.stringify(context).includes(launcherSessionId), false);
|
||||
assert.equal(JSON.stringify(context).includes(internalToken), false);
|
||||
});
|
||||
|
||||
test("invalid identity and explicit Device Core block never produce an actor", async () => {
|
||||
for (const user of [
|
||||
{ id: "?", groups: ["nodedc:device-core:admin"] },
|
||||
{ id: "valid-user", groups: ["nodedc:superadmin", "nodedc:device-core:blocked"] },
|
||||
]) {
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: productionEnv(),
|
||||
fetchImpl: async () => jsonResponse(200, {
|
||||
ok: true,
|
||||
launcherSessionId,
|
||||
access: {
|
||||
allowed: true,
|
||||
hubRole: "admin",
|
||||
ownerScopes: [],
|
||||
},
|
||||
user,
|
||||
}),
|
||||
});
|
||||
const handoff = mockResponse();
|
||||
await auth.handleHandoff(
|
||||
{ method: "GET", headers: {} },
|
||||
handoff,
|
||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
|
||||
);
|
||||
const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0];
|
||||
const request = { method: "GET", headers: { cookie, accept: "application/json" } };
|
||||
const response = mockResponse();
|
||||
assert.equal(await auth.authorize(
|
||||
request,
|
||||
response,
|
||||
new URL("https://device.example.test/api/device-manager/session"),
|
||||
), true);
|
||||
assert.equal(response.statusCode, 403);
|
||||
}
|
||||
});
|
||||
|
||||
test("production auth fails closed when Launcher omits trusted Device Core access", async () => {
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: productionEnv(),
|
||||
fetchImpl: async () => jsonResponse(200, {
|
||||
ok: true,
|
||||
launcherSessionId,
|
||||
user: {
|
||||
id: "user_root",
|
||||
email: "root@example.test",
|
||||
name: "DC SUDO",
|
||||
groups: ["nodedc:superadmin"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const handoff = mockResponse();
|
||||
await auth.handleHandoff(
|
||||
{ method: "GET", headers: {} },
|
||||
handoff,
|
||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
|
||||
);
|
||||
const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0];
|
||||
const response = mockResponse();
|
||||
assert.equal(await auth.authorize(
|
||||
{ method: "GET", headers: { cookie, accept: "application/json" } },
|
||||
response,
|
||||
new URL("https://device.example.test/api/device-manager/session"),
|
||||
), true);
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(JSON.parse(response.body).error, "device_manager_access_denied");
|
||||
});
|
||||
|
||||
test("an injected file-backed token takes precedence over broad platform env tokens", async () => {
|
||||
const calls = [];
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: { ...productionEnv(), NODEDC_INTERNAL_ACCESS_TOKEN: "broad-platform-token" },
|
||||
internalToken: "scoped-file-token",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return jsonResponse(200, {
|
||||
ok: true,
|
||||
launcherSessionId,
|
||||
access: { allowed: true, hubRole: "member", ownerScopes: [] },
|
||||
user: {
|
||||
id: "device-member",
|
||||
email: "member@example.test",
|
||||
name: "Device Member",
|
||||
groups: ["nodedc:device-core:access"],
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await auth.handleHandoff(
|
||||
{ method: "GET", headers: {} },
|
||||
mockResponse(),
|
||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
|
||||
);
|
||||
assert.equal(calls[0].init.headers.Authorization, "Bearer scoped-file-token");
|
||||
});
|
||||
|
||||
function productionEnv() {
|
||||
return {
|
||||
NODE_ENV: "production",
|
||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true",
|
||||
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "false",
|
||||
NODEDC_LAUNCHER_BASE_URL: "https://launcher.example.test",
|
||||
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher.internal.test",
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN: internalToken,
|
||||
};
|
||||
}
|
||||
|
||||
function mockResponse() {
|
||||
const headers = new Map();
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: "",
|
||||
setHeader(name, value) { headers.set(String(name).toLowerCase(), value); },
|
||||
getHeader(name) { return headers.get(String(name).toLowerCase()); },
|
||||
end(body = "") { this.body = String(body); },
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(status, body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
||||
|
||||
const DEFAULT_ACCENT = "#b9ff4a";
|
||||
|
||||
export function createDeviceManagerPresentationStore({
|
||||
layoutPath,
|
||||
uploadRoot,
|
||||
} = {}) {
|
||||
const resolvedLayoutPath = resolve(layoutPath || "runtime-data/device-manager-presentation.json");
|
||||
const resolvedUploadRoot = resolve(uploadRoot || "runtime-data/device-manager-media");
|
||||
|
||||
return {
|
||||
mediaRoot: resolvedUploadRoot,
|
||||
async read() {
|
||||
const raw = await readFile(resolvedLayoutPath, "utf8").catch((error) => {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!raw) return defaultPresentation();
|
||||
try {
|
||||
return normalizePresentation(JSON.parse(raw));
|
||||
} catch {
|
||||
throw serviceError("device_manager_presentation_invalid", 500);
|
||||
}
|
||||
},
|
||||
async write(next) {
|
||||
const normalized = normalizePresentation(next);
|
||||
await mkdir(dirname(resolvedLayoutPath), { recursive: true });
|
||||
const temporaryPath = `${resolvedLayoutPath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o640 });
|
||||
await rename(temporaryPath, resolvedLayoutPath);
|
||||
return normalized;
|
||||
},
|
||||
async saveMedia({ bytes, contentType, originalName, kind }) {
|
||||
const extension = allowedExtension(contentType, originalName, kind);
|
||||
await mkdir(resolvedUploadRoot, { recursive: true });
|
||||
const fileName = `${kind}-${randomUUID()}${extension}`;
|
||||
await writeFile(join(resolvedUploadRoot, fileName), bytes, { flag: "wx", mode: 0o640 });
|
||||
return {
|
||||
fileName: String(originalName || fileName).slice(0, 180),
|
||||
fileSrc: `/device-manager-media/${fileName}`,
|
||||
};
|
||||
},
|
||||
resolveMedia(pathname) {
|
||||
const encodedName = pathname.match(/^\/device-manager-media\/([^/]+)$/)?.[1];
|
||||
if (!encodedName) return null;
|
||||
const name = basename(decodeURIComponent(encodedName));
|
||||
if (!/^[a-z]+-[0-9a-f-]+\.(?:png|jpe?g|webp|gif|avif|mp4|webm|mov)$/i.test(name)) return null;
|
||||
const candidate = resolve(resolvedUploadRoot, name);
|
||||
return candidate.startsWith(`${resolvedUploadRoot}${sep}`) ? candidate : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultPresentation() {
|
||||
return {
|
||||
environment: {
|
||||
theme: "dark",
|
||||
accentHex: DEFAULT_ACCENT,
|
||||
overview: defaultOverview(),
|
||||
},
|
||||
projects: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProjectPresentation(value) {
|
||||
return {
|
||||
icon: normalizeMedia(value?.icon),
|
||||
teaser: normalizeMedia(value?.teaser),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEnvironmentPresentation(value) {
|
||||
const legacyTeaser = normalizeMedia(value?.defaultTeaser);
|
||||
return {
|
||||
theme: value?.theme === "light" ? "light" : "dark",
|
||||
accentHex: /^#[0-9a-f]{6}$/i.test(String(value?.accentHex || ""))
|
||||
? String(value.accentHex).toLowerCase()
|
||||
: DEFAULT_ACCENT,
|
||||
overview: normalizeOverview(value?.overview, legacyTeaser),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultOverview() {
|
||||
return {
|
||||
headerLabel: "Device Core",
|
||||
eyebrow: "NODEDC / DEVICE CORE",
|
||||
title: "Device Core",
|
||||
description: "Единый контур подключения, учёта и управления устройствами.",
|
||||
primarySection: "devices",
|
||||
secondarySection: null,
|
||||
background: {
|
||||
enabled: false,
|
||||
imageDurationSeconds: 10,
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOverview(value, legacyTeaser) {
|
||||
const fallback = defaultOverview();
|
||||
const legacySource = mediaSource(legacyTeaser);
|
||||
const legacyItems = legacySource ? [{
|
||||
id: "legacy-overview-media",
|
||||
...legacyTeaser,
|
||||
mediaKind: inferMediaKind(legacySource),
|
||||
}] : [];
|
||||
const sourceItems = Array.isArray(value?.background?.items)
|
||||
? value.background.items.slice(0, 24)
|
||||
: legacyItems;
|
||||
const items = sourceItems
|
||||
.map(normalizeEnvironmentMediaItem)
|
||||
.filter(Boolean);
|
||||
return {
|
||||
headerLabel: normalizeCopy(value?.headerLabel, fallback.headerLabel, 40),
|
||||
eyebrow: normalizeCopy(value?.eyebrow, fallback.eyebrow, 80),
|
||||
title: normalizeCopy(value?.title, fallback.title, 120),
|
||||
description: normalizeCopy(value?.description, fallback.description, 500),
|
||||
primarySection: normalizeSection(value?.primarySection, fallback.primarySection),
|
||||
secondarySection: normalizeSection(value?.secondarySection, fallback.secondarySection),
|
||||
background: {
|
||||
enabled: value?.background
|
||||
? Boolean(value.background.enabled)
|
||||
: legacyItems.length > 0,
|
||||
imageDurationSeconds: clampInteger(value?.background?.imageDurationSeconds, 1, 60, 10),
|
||||
items,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEnvironmentMediaItem(value) {
|
||||
const media = normalizeMedia(value);
|
||||
const source = mediaSource(media);
|
||||
if (!source && !value?.url && !value?.fileSrc) return null;
|
||||
return {
|
||||
id: /^[a-z0-9][a-z0-9._:-]{0,127}$/i.test(String(value?.id || ""))
|
||||
? String(value.id)
|
||||
: randomUUID(),
|
||||
...media,
|
||||
mediaKind: value?.mediaKind === "image" || value?.mediaKind === "video"
|
||||
? value.mediaKind
|
||||
: inferMediaKind(source),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCopy(value, fallback, maxLength) {
|
||||
const normalized = String(value || "").trim();
|
||||
return (normalized || fallback).slice(0, maxLength);
|
||||
}
|
||||
|
||||
function normalizeSection(value, fallback) {
|
||||
const allowed = new Set(["overview", "devices", "infrastructure", "management", "administration"]);
|
||||
if (value === undefined) return fallback;
|
||||
if (value === null || value === "none") return null;
|
||||
return allowed.has(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function clampInteger(value, minimum, maximum, fallback) {
|
||||
const normalized = Number.parseInt(String(value), 10);
|
||||
return Number.isInteger(normalized)
|
||||
? Math.min(maximum, Math.max(minimum, normalized))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizePresentation(value) {
|
||||
const projects = {};
|
||||
if (value?.projects && typeof value.projects === "object" && !Array.isArray(value.projects)) {
|
||||
for (const [projectRef, presentation] of Object.entries(value.projects)) {
|
||||
if (/^project:[0-9a-f-]{36}$/i.test(projectRef)) {
|
||||
projects[projectRef.toLowerCase()] = normalizeProjectPresentation(presentation);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
environment: normalizeEnvironmentPresentation(value?.environment),
|
||||
projects,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMedia(value) {
|
||||
const source = value?.source === "url" ? "url" : "file";
|
||||
const url = source === "url" ? safeExternalUrl(value?.url) : "";
|
||||
const fileSrc = source === "file" && /^\/device-manager-media\/[a-z0-9._-]+$/i.test(String(value?.fileSrc || ""))
|
||||
? String(value.fileSrc)
|
||||
: null;
|
||||
return {
|
||||
source,
|
||||
url,
|
||||
fileName: fileSrc ? String(value?.fileName || basename(fileSrc)).slice(0, 180) : null,
|
||||
fileSrc,
|
||||
};
|
||||
}
|
||||
|
||||
function safeExternalUrl(value) {
|
||||
const candidate = String(value || "").trim();
|
||||
if (!candidate) return "";
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function emptyMedia() {
|
||||
return { source: "file", url: "", fileName: null, fileSrc: null };
|
||||
}
|
||||
|
||||
function mediaSource(value) {
|
||||
if (!value) return null;
|
||||
return value.source === "url" ? value.url || null : value.fileSrc;
|
||||
}
|
||||
|
||||
function inferMediaKind(value) {
|
||||
const pathname = (() => {
|
||||
try { return new URL(String(value || ""), "http://localhost").pathname; }
|
||||
catch { return String(value || ""); }
|
||||
})();
|
||||
return /\.(?:png|jpe?g|webp|gif|avif)$/i.test(pathname) ? "image" : "video";
|
||||
}
|
||||
|
||||
function allowedExtension(contentType, originalName, kind) {
|
||||
const normalized = String(contentType || "").split(";", 1)[0].trim().toLowerCase();
|
||||
const imageTypes = new Map([["image/png", ".png"], ["image/jpeg", ".jpg"], ["image/webp", ".webp"], ["image/gif", ".gif"], ["image/avif", ".avif"]]);
|
||||
const videoTypes = new Map([
|
||||
["video/mp4", ".mp4"],
|
||||
["video/webm", ".webm"],
|
||||
["video/quicktime", ".mov"],
|
||||
["video/x-quicktime", ".mov"],
|
||||
]);
|
||||
const allowed = kind === "icon"
|
||||
? imageTypes
|
||||
: kind === "teaser"
|
||||
? videoTypes
|
||||
: new Map([...imageTypes, ...videoTypes]);
|
||||
const suppliedExtension = extname(String(originalName || "")).toLowerCase();
|
||||
const extensionFallback = new Map([
|
||||
[".png", ".png"], [".jpg", ".jpg"], [".jpeg", ".jpg"], [".webp", ".webp"],
|
||||
[".gif", ".gif"], [".avif", ".avif"], [".mp4", ".mp4"], [".webm", ".webm"], [".mov", ".mov"],
|
||||
]);
|
||||
const extension = allowed.get(normalized)
|
||||
|| (!normalized || normalized === "application/octet-stream"
|
||||
? extensionFallback.get(suppliedExtension)
|
||||
: null);
|
||||
if (!extension) throw serviceError("device_manager_media_type_forbidden", 415);
|
||||
if (suppliedExtension && kind === "icon" && ![".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"].includes(suppliedExtension)) {
|
||||
throw serviceError("device_manager_media_extension_forbidden", 415);
|
||||
}
|
||||
if (suppliedExtension && kind === "teaser" && ![".mp4", ".webm", ".mov"].includes(suppliedExtension)) {
|
||||
throw serviceError("device_manager_media_extension_forbidden", 415);
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createDeviceManagerPresentationStore,
|
||||
defaultPresentation,
|
||||
normalizeEnvironmentPresentation,
|
||||
} from "./device-manager-presentation.mjs";
|
||||
|
||||
test("Device Core environment defaults to the product-level canonical identity", () => {
|
||||
const presentation = defaultPresentation();
|
||||
assert.deepEqual(presentation.environment.overview, {
|
||||
headerLabel: "Device Core",
|
||||
eyebrow: "NODEDC / DEVICE CORE",
|
||||
title: "Device Core",
|
||||
description: "Единый контур подключения, учёта и управления устройствами.",
|
||||
primarySection: "devices",
|
||||
secondarySection: null,
|
||||
background: {
|
||||
enabled: false,
|
||||
imageDurationSeconds: 10,
|
||||
items: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("legacy single teaser migrates into the environment media playlist", () => {
|
||||
const environment = normalizeEnvironmentPresentation({
|
||||
defaultTeaser: {
|
||||
source: "file",
|
||||
fileName: "legacy.mov",
|
||||
fileSrc: "/device-manager-media/background-00000000-0000-4000-8000-000000000000.mov",
|
||||
},
|
||||
});
|
||||
assert.equal(environment.overview.background.enabled, true);
|
||||
assert.equal(environment.overview.background.items.length, 1);
|
||||
assert.equal(environment.overview.background.items[0].mediaKind, "video");
|
||||
});
|
||||
|
||||
test("environment media accepts MOV even when the browser omits or varies its MIME", async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const store = createDeviceManagerPresentationStore({
|
||||
layoutPath: join(root, "presentation.json"),
|
||||
uploadRoot: join(root, "media"),
|
||||
});
|
||||
|
||||
for (const [index, contentType] of ["video/quicktime", "video/x-quicktime", ""].entries()) {
|
||||
const uploaded = await store.saveMedia({
|
||||
bytes: Buffer.from(`mov-${index}`),
|
||||
contentType,
|
||||
originalName: `background-${index}.mov`,
|
||||
kind: "background",
|
||||
});
|
||||
assert.match(uploaded.fileSrc, /^\/device-manager-media\/background-[0-9a-f-]+\.mov$/);
|
||||
assert.equal(await readFile(store.resolveMedia(uploaded.fileSrc), "utf8"), `mov-${index}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("environment presentation persists ordered mixed media and image duration", async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const store = createDeviceManagerPresentationStore({
|
||||
layoutPath: join(root, "presentation.json"),
|
||||
uploadRoot: join(root, "media"),
|
||||
});
|
||||
const next = defaultPresentation();
|
||||
next.environment.overview.background = {
|
||||
enabled: true,
|
||||
imageDurationSeconds: 17,
|
||||
items: [
|
||||
{
|
||||
id: "video-first",
|
||||
source: "url",
|
||||
url: "https://media.example/device.mov",
|
||||
fileName: null,
|
||||
fileSrc: null,
|
||||
mediaKind: "video",
|
||||
},
|
||||
{
|
||||
id: "image-second",
|
||||
source: "url",
|
||||
url: "https://media.example/device.webp",
|
||||
fileName: null,
|
||||
fileSrc: null,
|
||||
mediaKind: "image",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await store.write(next);
|
||||
const restored = await store.read();
|
||||
assert.equal(restored.environment.overview.background.imageDurationSeconds, 17);
|
||||
assert.deepEqual(
|
||||
restored.environment.overview.background.items.map((item) => item.id),
|
||||
["video-first", "image-second"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { dirname, extname, resolve, sep } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
||||
import {
|
||||
createDeviceCoreClient,
|
||||
createLocalPreviewDeviceCore,
|
||||
} from "./device-core-client.mjs";
|
||||
import {
|
||||
createDeviceManagerPresentationStore,
|
||||
normalizeEnvironmentPresentation,
|
||||
normalizeProjectPresentation,
|
||||
} from "./device-manager-presentation.mjs";
|
||||
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const mutationRoutes = new Map([
|
||||
["/api/device-manager/owner-scopes:ensure", "owner-scopes:ensure"],
|
||||
["/api/device-manager/projects:ensure", "projects:ensure"],
|
||||
["/api/device-manager/collections:ensure", "collections:ensure"],
|
||||
["/api/device-manager/project-grants:upsert", "project-grants:upsert"],
|
||||
["/api/device-manager/adapter-packages:ensure", "adapter-packages:ensure"],
|
||||
["/api/device-manager/adapter-versions:register", "adapter-versions:register"],
|
||||
["/api/device-manager/model-profiles:register", "model-profiles:register"],
|
||||
["/api/device-manager/edges:ensure", "edges:ensure"],
|
||||
["/api/device-manager/routes:ensure", "routes:ensure"],
|
||||
["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"],
|
||||
["/api/device-manager/devices:claim", "devices:claim"],
|
||||
["/api/device-manager/devices:update", "devices:update"],
|
||||
["/api/device-manager/device-bindings:ensure", "device-bindings:ensure"],
|
||||
["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"],
|
||||
[
|
||||
"/api/device-manager/device-configuration-revisions:create",
|
||||
"device-configuration-revisions:create",
|
||||
],
|
||||
[
|
||||
"/api/device-manager/device-configurations:set-desired",
|
||||
"device-configurations:set-desired",
|
||||
],
|
||||
["/api/device-manager/commands:service-ping", "commands:service-ping"],
|
||||
]);
|
||||
|
||||
export function createDeviceManagerServer({
|
||||
auth,
|
||||
coreClient,
|
||||
distRoot = resolve(appRoot, "dist"),
|
||||
presentationStore = createDeviceManagerPresentationStore({
|
||||
layoutPath: resolve(appRoot, "runtime-data/device-manager-presentation.json"),
|
||||
uploadRoot: resolve(appRoot, "runtime-data/device-manager-media"),
|
||||
}),
|
||||
} = {}) {
|
||||
if (!auth || typeof auth.authorize !== "function") {
|
||||
throw new TypeError("device_manager_auth_required");
|
||||
}
|
||||
if (!coreClient || typeof coreClient.listProjects !== "function") {
|
||||
throw new TypeError("device_manager_core_client_required");
|
||||
}
|
||||
|
||||
return createServer(async (request, response) => {
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("Referrer-Policy", "same-origin");
|
||||
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
try {
|
||||
const url = new URL(
|
||||
request.url || "/",
|
||||
`http://${request.headers.host || "127.0.0.1"}`,
|
||||
);
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/healthz") {
|
||||
return sendJson(response, 200, {
|
||||
ok: true,
|
||||
service: "nodedc-device-manager",
|
||||
authRequired: auth.authRequired,
|
||||
deviceCoreConfigured: coreClient.configured === true,
|
||||
});
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/auth/nodedc/handoff") {
|
||||
return auth.handleHandoff(request, response, url);
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/auth/logout") {
|
||||
return auth.handleLogout(request, response);
|
||||
}
|
||||
if (await auth.authorize(request, response, url)) return;
|
||||
const context = auth.currentContext(request);
|
||||
if (!context) return sendJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_manager_auth_required",
|
||||
});
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/device-manager/session") {
|
||||
return sendJson(response, 200, { ok: true, session: context });
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/device-manager/projects") {
|
||||
const projects = await coreClient.listProjects(context.actor);
|
||||
return sendJson(response, 200, { ok: true, projects });
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/device-manager/presentation") {
|
||||
const [presentation, projects] = await Promise.all([
|
||||
presentationStore.read(),
|
||||
coreClient.listProjects(context.actor),
|
||||
]);
|
||||
const allowed = new Set(projects.map((project) => project.projectRef));
|
||||
return sendJson(response, 200, {
|
||||
ok: true,
|
||||
presentation: {
|
||||
environment: presentation.environment,
|
||||
projects: Object.fromEntries(
|
||||
Object.entries(presentation.projects).filter(([ref]) => allowed.has(ref)),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/project") {
|
||||
const input = await readJsonBody(request, 128 * 1024);
|
||||
const projectRef = validProjectRef(input.projectRef);
|
||||
await requireProjectManage(coreClient, context.actor, projectRef);
|
||||
const current = await presentationStore.read();
|
||||
current.projects[projectRef] = normalizeProjectPresentation(input.presentation);
|
||||
const presentation = await presentationStore.write(current);
|
||||
return sendJson(response, 200, { ok: true, presentation });
|
||||
}
|
||||
if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/environment") {
|
||||
requireSuperAdmin(context.actor);
|
||||
const input = await readJsonBody(request, 128 * 1024);
|
||||
const current = await presentationStore.read();
|
||||
current.environment = normalizeEnvironmentPresentation(input.environment);
|
||||
const presentation = await presentationStore.write(current);
|
||||
return sendJson(response, 200, { ok: true, presentation });
|
||||
}
|
||||
if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/media") {
|
||||
const scope = url.searchParams.get("scope");
|
||||
const kind = url.searchParams.get("kind");
|
||||
if (kind !== "icon" && kind !== "teaser" && kind !== "background") {
|
||||
throw serviceError("device_manager_media_kind_invalid", 400);
|
||||
}
|
||||
if (scope === "environment") {
|
||||
requireSuperAdmin(context.actor);
|
||||
if (kind !== "background") throw serviceError("device_manager_media_kind_invalid", 400);
|
||||
} else if (scope === "project") {
|
||||
if (kind === "background") throw serviceError("device_manager_media_kind_invalid", 400);
|
||||
await requireProjectManage(coreClient, context.actor, validProjectRef(url.searchParams.get("projectRef")));
|
||||
} else {
|
||||
throw serviceError("device_manager_media_scope_invalid", 400);
|
||||
}
|
||||
const bytes = await readBody(request, kind === "icon" ? 8 * 1024 * 1024 : 256 * 1024 * 1024);
|
||||
const media = await presentationStore.saveMedia({
|
||||
bytes,
|
||||
contentType: request.headers["content-type"],
|
||||
originalName: singleOptionalHeader(request.headers["x-file-name"]),
|
||||
kind,
|
||||
});
|
||||
return sendJson(response, 200, { ok: true, ...media });
|
||||
}
|
||||
if ((request.method === "GET" || request.method === "HEAD") && url.pathname.startsWith("/device-manager-media/")) {
|
||||
const mediaPath = presentationStore.resolveMedia(url.pathname);
|
||||
if (!mediaPath) return sendJson(response, 404, { ok: false, error: "device_manager_media_not_found" });
|
||||
return serveFile(request, response, mediaPath, "private, max-age=300");
|
||||
}
|
||||
const projectRef = workspaceProjectRef(url.pathname);
|
||||
if (request.method === "GET" && projectRef) {
|
||||
const workspace = await coreClient.getWorkspace(context.actor, projectRef);
|
||||
return sendJson(response, 200, { ok: true, workspace });
|
||||
}
|
||||
const command = mutationRoutes.get(url.pathname);
|
||||
if (request.method === "POST" && command) {
|
||||
const idempotencyKey = singleHeader(request.headers["idempotency-key"]);
|
||||
const input = await readJsonBody(request, 64 * 1024);
|
||||
const execution = await coreClient.execute(
|
||||
command,
|
||||
context.actor,
|
||||
input,
|
||||
idempotencyKey,
|
||||
);
|
||||
response.setHeader("Idempotency-Key", idempotencyKey);
|
||||
response.setHeader(
|
||||
"Idempotency-Replayed",
|
||||
execution.replayed ? "true" : "false",
|
||||
);
|
||||
return sendJson(response, 200, { ok: true, ...execution });
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return sendJson(response, 404, { ok: false, error: "device_manager_route_not_found" });
|
||||
}
|
||||
return serveStatic(request, response, url, distRoot);
|
||||
} catch (error) {
|
||||
if (response.headersSent) {
|
||||
response.destroy();
|
||||
return;
|
||||
}
|
||||
const statusCode = normalizeStatus(error?.statusCode);
|
||||
return sendJson(response, statusCode, {
|
||||
ok: false,
|
||||
error: safeError(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createConfiguredDeviceManagerServer({ env = process.env } = {}) {
|
||||
const localPreview = booleanValue(env.NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW, false);
|
||||
const launcherTokenFile = String(env.NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE || "").trim();
|
||||
const launcherInternalToken = launcherTokenFile
|
||||
? (await readFile(launcherTokenFile, "utf8")).trim()
|
||||
: undefined;
|
||||
const auth = createDeviceManagerAuth({ env, internalToken: launcherInternalToken });
|
||||
if (auth.authRequired && !auth.internalAccessConfigured) {
|
||||
throw new Error("device_manager_auth_token_file_required");
|
||||
}
|
||||
let coreClient;
|
||||
if (localPreview) {
|
||||
if (String(env.NODE_ENV || "").toLowerCase() === "production") {
|
||||
throw new Error("device_manager_local_preview_forbidden");
|
||||
}
|
||||
coreClient = createLocalPreviewDeviceCore({
|
||||
fixture: String(env.NODEDC_DEVICE_MANAGER_PREVIEW_FIXTURE || "").trim() || null,
|
||||
});
|
||||
} else {
|
||||
const tokenFile = String(env.NODEDC_DEVICE_CORE_TOKEN_FILE || "").trim();
|
||||
if (!tokenFile) throw new Error("device_core_token_file_required");
|
||||
const token = (await readFile(tokenFile, "utf8")).trim();
|
||||
coreClient = createDeviceCoreClient({
|
||||
baseUrl: env.NODEDC_DEVICE_CORE_INTERNAL_URL,
|
||||
token,
|
||||
});
|
||||
}
|
||||
const presentationStore = createDeviceManagerPresentationStore({
|
||||
layoutPath: String(env.NODEDC_DEVICE_MANAGER_PRESENTATION_PATH || resolve(appRoot, "runtime-data/device-manager-presentation.json")),
|
||||
uploadRoot: String(env.NODEDC_DEVICE_MANAGER_MEDIA_ROOT || resolve(appRoot, "runtime-data/device-manager-media")),
|
||||
});
|
||||
return createDeviceManagerServer({ auth, coreClient, presentationStore });
|
||||
}
|
||||
|
||||
async function serveStatic(request, response, url, root) {
|
||||
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||
const candidate = resolve(root, `.${decodeURIComponent(requestedPath)}`);
|
||||
const normalizedRoot = resolve(root);
|
||||
if (candidate !== normalizedRoot && !candidate.startsWith(`${normalizedRoot}${sep}`)) {
|
||||
return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" });
|
||||
}
|
||||
let filePath = candidate;
|
||||
let info = await stat(filePath).catch(() => null);
|
||||
if ((!info || !info.isFile()) && !extname(requestedPath)) {
|
||||
filePath = resolve(root, "index.html");
|
||||
info = await stat(filePath).catch(() => null);
|
||||
}
|
||||
if (!info?.isFile()) {
|
||||
return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" });
|
||||
}
|
||||
return serveFile(request, response, filePath, filePath.endsWith("index.html") ? "no-store" : "private, max-age=300", info);
|
||||
}
|
||||
|
||||
async function serveFile(request, response, filePath, cacheControl, existingInfo = null) {
|
||||
const info = existingInfo || await stat(filePath).catch(() => null);
|
||||
if (!info?.isFile()) return sendJson(response, 404, { ok: false, error: "device_manager_media_not_found" });
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", contentType(filePath));
|
||||
response.setHeader("Cache-Control", cacheControl);
|
||||
response.setHeader("Content-Length", info.size);
|
||||
if (request.method === "HEAD") return response.end();
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
function workspaceProjectRef(pathname) {
|
||||
const match = pathname.match(/^\/api\/device-manager\/projects\/([^/]+)\/workspace$/);
|
||||
if (!match) return null;
|
||||
const projectRef = decodeURIComponent(match[1]);
|
||||
return /^project:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(projectRef)
|
||||
? projectRef.toLowerCase()
|
||||
: null;
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes) {
|
||||
const bytes = await readBody(request, maxBytes);
|
||||
try {
|
||||
const body = JSON.parse(bytes.toString("utf8") || "{}");
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error();
|
||||
return body;
|
||||
} catch {
|
||||
throw serviceError("device_manager_json_invalid", 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBody(request, maxBytes) {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) throw serviceError("device_manager_request_too_large", 413);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function validProjectRef(value) {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (!/^project:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
|
||||
throw serviceError("device_project_ref_invalid", 400);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function requireProjectManage(coreClient, actor, projectRef) {
|
||||
const workspace = await coreClient.getWorkspace(actor, projectRef);
|
||||
if (!workspace.project.access.capabilities.includes("project.manage")) {
|
||||
throw serviceError("device_project_capability_denied", 403);
|
||||
}
|
||||
}
|
||||
|
||||
function requireSuperAdmin(actor) {
|
||||
const groups = new Set(actor.groupRefs || []);
|
||||
if (!groups.has("group:nodedc:superadmin") && !groups.has("nodedc:superadmin")) {
|
||||
throw serviceError("device_environment_settings_denied", 403);
|
||||
}
|
||||
}
|
||||
|
||||
function singleHeader(value) {
|
||||
if (Array.isArray(value) || typeof value !== "string") {
|
||||
throw serviceError("device_idempotency_key_invalid", 400);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!/^[\x21-\x7e]{8,256}$/.test(normalized)) {
|
||||
throw serviceError("device_idempotency_key_invalid", 400);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function singleOptionalHeader(value) {
|
||||
if (value == null) return "";
|
||||
if (Array.isArray(value) || typeof value !== "string") throw serviceError("device_manager_header_invalid", 400);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, body) {
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function contentType(pathname) {
|
||||
return ({
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".avif": "image/avif",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".ico": "image/x-icon",
|
||||
})[extname(pathname).toLowerCase()] || "application/octet-stream";
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
const status = Number(value || 500);
|
||||
return Number.isInteger(status) && status >= 400 && status < 600 ? status : 500;
|
||||
}
|
||||
|
||||
function safeError(error) {
|
||||
const value = String(error?.message || "");
|
||||
return /^(?:device|nodedc)_[a-z0-9._:-]{2,160}$/.test(value)
|
||||
? value
|
||||
: "device_manager_internal_error";
|
||||
}
|
||||
|
||||
function booleanValue(value, fallback) {
|
||||
if (value == null || value === "") return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const server = await createConfiguredDeviceManagerServer();
|
||||
const port = Number.parseInt(process.env.PORT || "3335", 10);
|
||||
const host = String(process.env.HOST || "127.0.0.1");
|
||||
server.listen(port, host, () => {
|
||||
console.log(JSON.stringify({
|
||||
event: "device_manager_started",
|
||||
host,
|
||||
port,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { createLocalPreviewDeviceCore } from "./device-core-client.mjs";
|
||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
||||
import {
|
||||
createConfiguredDeviceManagerServer,
|
||||
createDeviceManagerServer,
|
||||
} from "./device-manager-server.mjs";
|
||||
|
||||
test("production configuration starts with runner-owned file tokens", async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-device-manager-production-"));
|
||||
const launcherTokenFile = join(root, "launcher-token");
|
||||
const coreTokenFile = join(root, "core-token");
|
||||
await writeFile(launcherTokenFile, `${"a".repeat(48)}\n`, { mode: 0o640 });
|
||||
await writeFile(coreTokenFile, `${"b".repeat(48)}\n`, { mode: 0o640 });
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
|
||||
const server = await createConfiguredDeviceManagerServer({
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true",
|
||||
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "true",
|
||||
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW: "false",
|
||||
NODEDC_DEVICE_MANAGER_SERVICE_SLUG: "device-core",
|
||||
NODEDC_LAUNCHER_BASE_URL: "https://hub.nodedc.ru",
|
||||
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher:5173",
|
||||
NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: launcherTokenFile,
|
||||
NODEDC_DEVICE_CORE_INTERNAL_URL: "http://device-control-core:18120",
|
||||
NODEDC_DEVICE_CORE_TOKEN_FILE: coreTokenFile,
|
||||
},
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
const address = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/healthz`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
ok: true,
|
||||
service: "nodedc-device-manager",
|
||||
authRequired: true,
|
||||
deviceCoreConfigured: true,
|
||||
});
|
||||
|
||||
const rootResponse = await fetch(`http://127.0.0.1:${address.port}/`, {
|
||||
redirect: "manual",
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
assert.equal(rootResponse.status, 302);
|
||||
assert.equal(
|
||||
rootResponse.headers.get("location"),
|
||||
"https://hub.nodedc.ru/auth/login?returnTo=%2Fapi%2Fservices%2Fdevice-core%2Flaunch%3FreturnTo%3D%252F",
|
||||
);
|
||||
|
||||
const healthAfterRedirect = await fetch(`http://127.0.0.1:${address.port}/healthz`);
|
||||
assert.equal(healthAfterRedirect.status, 200);
|
||||
assert.equal((await healthAfterRedirect.json()).ok, true);
|
||||
});
|
||||
|
||||
test("Device Manager BFF exposes an empty, mutation-driven project workspace", async (t) => {
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: { NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "false" },
|
||||
});
|
||||
const coreClient = createLocalPreviewDeviceCore();
|
||||
const server = createDeviceManagerServer({ auth, coreClient });
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const address = server.address();
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const session = await getJson(`${baseUrl}/api/device-manager/session`);
|
||||
assert.equal(session.session.actor.hubRole, "owner");
|
||||
assert.equal(session.session.actor.userRef, "user:local-device-admin");
|
||||
assert.deepEqual((await getJson(`${baseUrl}/api/device-manager/projects`)).projects, []);
|
||||
|
||||
await postJson(`${baseUrl}/api/device-manager/owner-scopes:ensure`, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:local-device-admin",
|
||||
displayName: "Local Device Admin",
|
||||
}, {
|
||||
"X-NODEDC-Hub-Role": "viewer",
|
||||
"X-NODEDC-User-Ref": "user:spoofed-browser",
|
||||
});
|
||||
const created = await postJson(`${baseUrl}/api/device-manager/projects:ensure`, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:local-device-admin",
|
||||
projectKey: "device-sandbox",
|
||||
name: "Device sandbox",
|
||||
description: "Created only through the canonical command path",
|
||||
});
|
||||
const projectRef = created.result.project.projectRef;
|
||||
|
||||
const projects = (await getJson(`${baseUrl}/api/device-manager/projects`)).projects;
|
||||
assert.equal(projects.length, 1);
|
||||
assert.equal(projects[0].projectKey, "device-sandbox");
|
||||
assert.equal(projects[0].ownerScope.ownerRef, "user:local-device-admin");
|
||||
|
||||
await postJson(`${baseUrl}/api/device-manager/collections:ensure`, {
|
||||
projectRef,
|
||||
collectionKey: "pilot-devices",
|
||||
name: "Pilot devices",
|
||||
description: null,
|
||||
});
|
||||
await postJson(`${baseUrl}/api/device-manager/adapter-packages:ensure`, {
|
||||
packageKey: "generic-sensor",
|
||||
displayName: "Generic sensor",
|
||||
publisherRef: "publisher:nodedc",
|
||||
lifecycleState: "active",
|
||||
});
|
||||
await postJson(`${baseUrl}/api/device-manager/edges:ensure`, {
|
||||
edgeKey: "preview-edge",
|
||||
displayName: "Preview Edge",
|
||||
deploymentRef: "deployment:preview-edge",
|
||||
lifecycleState: "provisioning",
|
||||
});
|
||||
await postJson(`${baseUrl}/api/device-manager/project-grants:upsert`, {
|
||||
projectRef,
|
||||
principalKind: "group",
|
||||
principalRef: "group:preview-viewers",
|
||||
projectRole: "viewer",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
const workspace = await getJson(
|
||||
`${baseUrl}/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
||||
);
|
||||
assert.equal(workspace.workspace.project.projectRef, projectRef);
|
||||
assert.equal(workspace.workspace.collections[0].collectionKey, "pilot-devices");
|
||||
assert.equal(workspace.workspace.adapterPackages[0].packageKey, "generic-sensor");
|
||||
assert.equal(workspace.workspace.edges[0].edgeKey, "preview-edge");
|
||||
assert.equal(workspace.workspace.grants.length, 2);
|
||||
assert.equal(workspace.workspace.policies.commandTransport, "disabled");
|
||||
assert.deepEqual(workspace.workspace.devices, []);
|
||||
|
||||
const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.equal(missingKey.status, 400);
|
||||
assert.equal((await missingKey.json()).error, "device_idempotency_key_invalid");
|
||||
});
|
||||
|
||||
async function getJson(url) {
|
||||
const response = await fetch(url, { headers: { accept: "application/json" } });
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, 200, JSON.stringify(body));
|
||||
assert.equal(body.ok, true);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function postJson(url, body, headers = {}) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": `device-manager-test-${crypto.randomUUID()}`,
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200, JSON.stringify(payload));
|
||||
assert.equal(payload.ok, true);
|
||||
return payload;
|
||||
}
|
||||
Reference in New Issue
Block a user