commit e0bac205d0f49294d4f0d74c6e478ce30d9fd73f Author: DCCONSTRUCTIONS Date: Fri Aug 21 11:51:21 2026 +0300 feat: establish standalone Device Core repository diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e478899 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.DS_Store +.env +.env.* +docs +node_modules +**/test +**/*.log +**/*.prev-* +**/*.next-* +runtime +secrets diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ac9804 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +*.tsbuildinfo +__pycache__/ +*.pyc +runtime-data/ +deploy-artifacts/ +infra/deploy-artifacts/ +.DS_Store +.env +.env.* +secrets/ +enrollment/ +runtime/ +vendor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1701b8c --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# NODE.DC Device Core + +Device Core is the product-owned source repository for the universal NODE.DC +device control plane. It owns the Device Manager UI/BFF, provider-neutral +device runtime, adapters, edge channel, deployment descriptors and canonical +artifact builders for the `device-plane` and `device-edge-vps` components. + +The repository boundary does **not** change the production boundary: + +- Synology live root remains `/volume1/docker/nodedc-device-plane`; +- Compose project remains `nodedc-device-plane`; +- deploy artifacts keep `component=device-plane` or `component=device-edge-vps`; +- runtime databases, volumes, secrets, mTLS identity and edge registrations are + preserved and are never stored in Git; +- the root-owned `nodedc-deploy` runner and component registry remain owned by + `NODEDC_PLATFORM`; +- Hub/Authentik authorization, Launcher service grants and the platform public + reverse-proxy route remain owned by `NODEDC_PLATFORM`; +- the shared UI canon remains owned by `NODEDC_DESIGN_GUIDELINE`. + +## Source layout + +- `apps/device-manager` — Device Manager browser app and server-owned BFF; +- `packages/*` — protocol, adapter and edge-channel contracts; +- `services/*` — control core, gateway and edge runtimes; +- `deployment/*` — immutable release/bootstrap descriptor templates; +- `vps/*` — reviewed VPS process, firewall and systemd definitions; +- `infra/deploy-runner/*` — Device Core artifact builders and builder tests; +- `docker-compose.*.yml` — reviewed runtime topologies. + +## Canonical checkout topology + +The Device Manager consumes the canonical UI packages directly from the +sibling Design Guideline repository; their source is intentionally not copied +here: + +```text +NODEDC/ +├── NODEDC_DEVICE_CORE/ +├── NODEDC_DESIGN_GUIDELINE/ +└── platform/ +``` + +Install and verify from this repository root: + +```bash +npm install +npm run build +npm run typecheck +npm test +``` + +`npm run test:deploy` validates the product-owned artifact builders. The +platform runner registry is validated separately in `NODEDC_PLATFORM`. + +## Deployment ownership + +Artifact builders in `infra/deploy-runner` emit data-only tarballs. They never +orchestrate Docker or mutate a live host. Promotion remains the established +two-step workflow: + +```bash +sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/.tgz +sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/.tgz +``` + +See [Repository boundary](docs/REPOSITORY_BOUNDARY.md) and +[Implementation baseline](docs/IMPLEMENTATION_BASELINE.md) for the security, +runtime and rollout constraints. diff --git a/apps/device-manager/README.md b/apps/device-manager/README.md new file mode 100644 index 0000000..c57f78a --- /dev/null +++ b/apps/device-manager/README.md @@ -0,0 +1,60 @@ +# NODE.DC Device Manager + +Standalone Device Core application shell for Hub-authenticated device administration. +It is intentionally vendor-neutral: adapters and model profiles describe protocol-specific +behavior; projects, inventory, collections and access remain shared Device Core concepts. + +## Runtime boundary + +- The browser talks only to the Device Manager BFF under `/api/device-manager/*`. +- Launcher consumes the one-time handoff and periodically revalidates the process-local, + opaque Device Manager cookie. +- The BFF derives the Core actor from that trusted Hub identity. Browser-supplied role, + group or owner headers are ignored. +- The BFF reads the Core bearer token from `NODEDC_DEVICE_CORE_TOKEN_FILE`; the token is + never embedded into client assets or accepted as a raw environment value. +- Device Control Core owns authorization, lifecycle validation, idempotency and persistence. +- Query responses contain masked identifiers and bounded metadata only. Identifier and + credential digests, external approval proofs, command parameters/transport refs, raw + configuration documents and audit payloads stay inside Device Control Core. + +The project workspace covers inventory, discovery, collections, adapter/profile metadata, +Edges, routes, sessions, bindings, configuration state, the honest command ledger, immutable +audit metadata and project grants. Navigation and actions are derived from effective project +capabilities. Global adapter/profile/Edge mutation is additionally restricted to a Hub owner. + +Command planning and transport intentionally have no Device Manager mutation route yet. +The UI never presents `sent` as success: `acknowledged` and `verified` remain different +ledger states, and the disabled transport policy is visible in the Commands section. + +Hub currently supplies identity and groups but no signed company-membership/owner-scope +claim. Therefore an admin may create projects in their personal scope. Existing company +projects remain visible through explicit project grants, but company project creation stays +closed until Hub extends the handoff contract. + +## Local source preview + +The preview store starts empty and exists only to exercise the shell without a deployed Core. +All visible resources must still be created through the same command-shaped BFF endpoints. +It is forbidden when `NODE_ENV=production`. + +```sh +NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW=1 \ +NODEDC_DEVICE_MANAGER_AUTH_REQUIRED=0 \ +npm run build --workspace @nodedc/device-manager + +NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW=1 \ +NODEDC_DEVICE_MANAGER_AUTH_REQUIRED=0 \ +npm run serve --workspace @nodedc/device-manager +``` + +Production additionally requires: + +- `NODEDC_LAUNCHER_BASE_URL` +- `NODEDC_LAUNCHER_INTERNAL_URL` +- `NODEDC_INTERNAL_ACCESS_TOKEN` or `NODEDC_PLATFORM_SERVICE_TOKEN` +- `NODEDC_DEVICE_CORE_INTERNAL_URL` +- `NODEDC_DEVICE_CORE_TOKEN_FILE` + +The application source does not create a Hub service entry, DNS record, reverse proxy, +database or deployment artifact. Those remain explicit infrastructure phases. diff --git a/apps/device-manager/index.html b/apps/device-manager/index.html new file mode 100644 index 0000000..30163c1 --- /dev/null +++ b/apps/device-manager/index.html @@ -0,0 +1,13 @@ + + + + + + + NODE.DC Device Core + + +
+ + + diff --git a/apps/device-manager/package.json b/apps/device-manager/package.json new file mode 100644 index 0000000..8d234ee --- /dev/null +++ b/apps/device-manager/package.json @@ -0,0 +1,27 @@ +{ + "name": "@nodedc/device-manager", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "test": "node --test server/*.test.mjs", + "serve": "node server/device-manager-server.mjs" + }, + "dependencies": { + "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", + "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", + "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.6.0", + "typescript": "^5.8.3", + "vite": "^7.0.0" + } +} diff --git a/apps/device-manager/public/nodedc-logo.svg b/apps/device-manager/public/nodedc-logo.svg new file mode 100644 index 0000000..92b19d8 --- /dev/null +++ b/apps/device-manager/public/nodedc-logo.svg @@ -0,0 +1 @@ + diff --git a/apps/device-manager/public/nodedc-mark.svg b/apps/device-manager/public/nodedc-mark.svg new file mode 100644 index 0000000..866cfe4 --- /dev/null +++ b/apps/device-manager/public/nodedc-mark.svg @@ -0,0 +1 @@ + diff --git a/apps/device-manager/server/device-core-client.mjs b/apps/device-manager/server/device-core-client.mjs new file mode 100644 index 0000000..5a917eb --- /dev/null +++ b/apps/device-manager/server/device-core-client.mjs @@ -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; +} diff --git a/apps/device-manager/server/device-core-client.test.mjs b/apps/device-manager/server/device-core-client.test.mjs new file mode 100644 index 0000000..5762d7c --- /dev/null +++ b/apps/device-manager/server/device-core-client.test.mjs @@ -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" }, + }); +} diff --git a/apps/device-manager/server/device-manager-auth.mjs b/apps/device-manager/server/device-manager-auth.mjs new file mode 100644 index 0000000..542e6ed --- /dev/null +++ b/apps/device-manager/server/device-manager-auth.mjs @@ -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; +} diff --git a/apps/device-manager/server/device-manager-auth.test.mjs b/apps/device-manager/server/device-manager-auth.test.mjs new file mode 100644 index 0000000..4ddd205 --- /dev/null +++ b/apps/device-manager/server/device-manager-auth.test.mjs @@ -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" }, + }); +} diff --git a/apps/device-manager/server/device-manager-presentation.mjs b/apps/device-manager/server/device-manager-presentation.mjs new file mode 100644 index 0000000..313f423 --- /dev/null +++ b/apps/device-manager/server/device-manager-presentation.mjs @@ -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; +} diff --git a/apps/device-manager/server/device-manager-presentation.test.mjs b/apps/device-manager/server/device-manager-presentation.test.mjs new file mode 100644 index 0000000..52dd132 --- /dev/null +++ b/apps/device-manager/server/device-manager-presentation.test.mjs @@ -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"], + ); +}); diff --git a/apps/device-manager/server/device-manager-server.mjs b/apps/device-manager/server/device-manager-server.mjs new file mode 100644 index 0000000..2e4e03f --- /dev/null +++ b/apps/device-manager/server/device-manager-server.mjs @@ -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, + })); + }); +} diff --git a/apps/device-manager/server/device-manager-server.test.mjs b/apps/device-manager/server/device-manager-server.test.mjs new file mode 100644 index 0000000..0753362 --- /dev/null +++ b/apps/device-manager/server/device-manager-server.test.mjs @@ -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; +} diff --git a/apps/device-manager/src/DeviceControlViews.tsx b/apps/device-manager/src/DeviceControlViews.tsx new file mode 100644 index 0000000..58a720c --- /dev/null +++ b/apps/device-manager/src/DeviceControlViews.tsx @@ -0,0 +1,957 @@ +import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react"; +import { + Button, + GlassSurface, + Icon, + Select, + SettingsCard, + StatusBadge, + TextAreaField, + TextField, + Window, + WindowFooterActions, +} from "@nodedc/ui-react"; + +import { + createConfigurationRevision, + ensureAdapterPackage, + ensureDeviceBinding, + ensureEdge, + ensureRoute, + registerAdapterVersion, + registerModelProfile, + revokeDeviceBinding, + sendServicePing, + setDesiredConfiguration, + upsertProjectGrant, +} from "./api"; +import type { + AdapterPackageView, + AdapterVersionView, + BindingView, + DeviceManagerSession, + EdgeView, + ModelProfileView, + ProjectWorkspace, +} from "./types"; + +export type ControlViewId = + | "catalog" + | "infrastructure" + | "sessions" + | "bindings" + | "commands" + | "audit" + | "access" + | "settings"; + +type DialogId = + | "adapter-package" + | "adapter-version" + | "model-profile" + | "edge" + | "route" + | "binding" + | "grant" + | "configuration" + | null; + +export function DeviceControlView({ + view, + workspace, + session, + onRefresh, + onError, +}: { + view: ControlViewId; + workspace: ProjectWorkspace; + session: DeviceManagerSession; + onRefresh: () => Promise; + onError: (reason: unknown) => void; +}) { + const [dialog, setDialog] = useState(null); + const capabilities = new Set(workspace.project.access.capabilities); + const platformOwner = session.actor.hubRole === "owner"; + const close = () => setDialog(null); + const completed = async () => { + close(); + await onRefresh(); + }; + const mutateAndRefresh = async (mutation: () => Promise) => { + try { + await mutation(); + await onRefresh(); + } catch (reason) { + onError(reason); + } + }; + + return ( + <> + {view === "catalog" ? ( + setDialog("adapter-package")} + onCreateVersion={() => setDialog("adapter-version")} + onCreateProfile={() => setDialog("model-profile")} + onActivateVersion={(version) => mutateAndRefresh(() => registerAdapterVersion({ + adapterPackageRef: version.adapterPackageRef, + version: version.version, + runtimePackageRef: version.runtimePackageRef, + contentDigest: version.contentDigest, + contractVersion: version.contractVersion, + capabilities: version.capabilities, + lifecycleState: "active", + }))} + onActivateProfile={(profile) => mutateAndRefresh(() => registerModelProfile({ + adapterVersionRef: profile.adapterVersionRef || "", + profileRef: profile.modelProfileRef, + schemaVersion: profile.schemaVersion, + vendor: profile.vendor, + model: profile.model, + deviceType: profile.deviceType, + protocol: profile.protocol, + schemaArtifactRef: profile.schemaArtifactRef || "", + profileDigest: profile.profileDigest || "", + capabilities: profile.capabilities, + lifecycleState: "active", + }))} + /> + ) : null} + {view === "infrastructure" ? ( + setDialog("edge")} + onCreateRoute={() => setDialog("route")} + onActivateEdge={(edge) => mutateAndRefresh(() => ensureEdge({ + edgeKey: edge.edgeKey, + displayName: edge.displayName, + deploymentRef: edge.deploymentRef, + lifecycleState: "active", + }))} + onActivateRoute={(route) => mutateAndRefresh(() => ensureRoute({ + projectRef: workspace.project.projectRef, + routeKey: route.routeKey, + displayName: route.displayName, + edgeRef: route.edgeRef, + modelProfileRef: route.modelProfileRef, + listenerRef: route.listenerRef, + protocol: route.protocol, + direction: route.direction, + lifecycleState: "active", + }))} + /> + ) : null} + {view === "sessions" ? : null} + {view === "bindings" ? ( + setDialog("binding")} + onRevoke={(binding) => revokeDeviceBinding({ + projectRef: workspace.project.projectRef, + bindingRef: binding.bindingRef, + resolutionCode: "operator.revoked", + }).then(onRefresh).catch(onError)} + /> + ) : null} + {view === "commands" ? ( + + ) : null} + {view === "audit" ? : null} + {view === "access" ? ( + setDialog("grant")} + /> + ) : null} + {view === "settings" ? ( + setDialog("configuration")} + /> + ) : null} + + + + + + + + + + + ); +} + +function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile, onActivateVersion, onActivateProfile }: { + workspace: ProjectWorkspace; + canManage: boolean; + onCreatePackage: () => void; + onCreateVersion: () => void; + onCreateProfile: () => void; + onActivateVersion: (version: AdapterVersionView) => void; + onActivateProfile: (profile: ModelProfileView) => void; +}) { + return ( + + + + + + : null} + /> + + + {workspace.modelProfiles.map((profile) => ( + onActivateProfile(profile)}>Активировать + ) : null} + /> + ))} + + + + + {workspace.adapterVersions.map((version) => ( + onActivateVersion(version)}>Активировать + ) : null} + /> + ))} + + + + + {workspace.adapterPackages.map((adapterPackage) => ( + version.adapterPackageRef === adapterPackage.adapterPackageRef) + .map((version) => `${version.version} · ${version.lifecycleState}`)} + /> + ))} + + + + ); +} + +function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute, onActivateEdge, onActivateRoute }: { + workspace: ProjectWorkspace; + canManageCatalog: boolean; + canManageRoutes: boolean; + onCreateEdge: () => void; + onCreateRoute: () => void; + onActivateEdge: (edge: EdgeView) => void; + onActivateRoute: (route: ProjectWorkspace["routes"][number]) => void; +}) { + return ( + + + {canManageCatalog ? : null} + {canManageRoutes ? : null} + } + /> + + + {workspace.routes.map((route) => ( + onActivateRoute(route)}>Активировать + ) : null} + /> + ))} + + + + + {workspace.edges.map((edge) => ( + onActivateEdge(edge)}>Активировать + ) : null} + /> + ))} + + + + ); +} + +function SessionsView({ workspace }: { workspace: ProjectWorkspace }) { + return ( + + + + {workspace.sessions.map((session) => ( + + ))} + + + ); +} + +function BindingsView({ workspace, canManage, onCreate, onRevoke }: { + workspace: ProjectWorkspace; + canManage: boolean; + onCreate: () => void; + onRevoke: (binding: BindingView) => void; +}) { + return ( + + Новый binding : null} + /> + + {workspace.bindings.map((binding) => ( + onRevoke(binding)}>Отозвать + ) : binding.capabilities.join(", ")} + /> + ))} + + + ); +} + +function CommandsView({ workspace, canDispatch, onRefresh, onError }: { + workspace: ProjectWorkspace; + canDispatch: boolean; + onRefresh: () => Promise; + onError: (reason: unknown) => void; +}) { + const supportedDevices = workspace.devices.filter( + (device) => device.modelProfileRef === "arusnavi.b2.internal.v1" + && !["suspended", "retired"].includes(device.lifecycleState), + ); + const [deviceRef, setDeviceRef] = useState(supportedDevices[0]?.deviceRef ?? ""); + const [accessCode, setAccessCode] = useState(""); + const [submitting, setSubmitting] = useState(false); + const enabled = workspace.policies.commandTransport === "typed-service-ping-v1"; + useEffect(() => { + if (!supportedDevices.some((device) => device.deviceRef === deviceRef)) { + setDeviceRef(supportedDevices[0]?.deviceRef ?? ""); + } + }, [deviceRef, supportedDevices]); + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!enabled || !canDispatch || !deviceRef || !/^\d{6}$/.test(accessCode)) return; + setSubmitting(true); + try { + await sendServicePing({ + projectRef: workspace.project.projectRef, + deviceRef, + accessCode, + expiresInSeconds: 300, + }); + setAccessCode(""); + await onRefresh(); + } catch (reason) { + onError(reason); + } finally { + setSubmitting(false); + } + }; + return ( + + + +
+ {enabled ? "Типизированный командный канал активен" : "Command transport выключен"} +

{enabled + ? "Доступна только безопасная проверка сервиса. Произвольные команды, прошивка, очистка памяти и перезагрузка отсутствуют. Код устройства существует только в памяти Core до отправки или истечения TTL." + : "Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния."}

+
+ {workspace.policies.commandTransport} +
+ {enabled ? ( + +
+ ({ value: item.adapterPackageRef, label: item.displayName }))} /> + setVersion(event.target.value)} required placeholder="1.0.0" /> + setRuntimeRef(event.target.value)} required /> + setDigest(event.target.value)} required placeholder="sha256:…" /> + setContractVersion(event.target.value)} required /> + setCapabilities(event.target.value)} description="Через запятую" /> + ; +} + +function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions: AdapterVersionView[] }) { + const [versionRef, setVersionRef] = useState(versions[0]?.adapterVersionRef ?? ""); + const [profileRef, setProfileRef] = useState(""); + const [schemaVersion, setSchemaVersion] = useState(""); + const [vendor, setVendor] = useState(""); + const [model, setModel] = useState(""); + const [deviceType, setDeviceType] = useState(""); + const [protocol, setProtocol] = useState(""); + const [schemaRef, setSchemaRef] = useState(""); + const [digest, setDigest] = useState(""); + const [capabilities, setCapabilities] = useState(""); + useEffect(() => { + if (!versions.some((item) => item.adapterVersionRef === versionRef)) { + setVersionRef(versions[0]?.adapterVersionRef ?? ""); + } + }, [versionRef, versions]); + return { + await registerModelProfile({ + adapterVersionRef: versionRef, + profileRef, + schemaVersion, + vendor, + model, + deviceType, + protocol: protocol.toUpperCase(), + schemaArtifactRef: schemaRef, + profileDigest: digest, + capabilities: commaList(capabilities), + lifecycleState: "draft", + }); + }}> + ({ value: item.edgeRef, label: item.displayName, description: item.lifecycleState }))} /> + + + setDisplayName(event.target.value)} required /> + setTargetKind(event.target.value)} required placeholder="foundry.application" /> + setTargetRef(event.target.value)} required /> + setCapabilities(event.target.value)} description="observe, inspect, configure, command" required /> + ; +} + +function GrantDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) { + const [principalKind, setPrincipalKind] = useState<"user" | "group">("user"); + const [principalRef, setPrincipalRef] = useState(""); + const [role, setRole] = useState("viewer"); + const [allow, setAllow] = useState(""); + const [deny, setDeny] = useState(""); + return { + await upsertProjectGrant({ + projectRef, + principalKind, + principalRef, + projectRole: role, + capabilityAllow: commaList(allow), + capabilityDeny: commaList(deny), + lifecycleState: "active", + }); + }}> + ({ value, label: value, disabled: value === "owner" && principalKind === "group" }))} /> + setAllow(event.target.value)} description="Опциональные точечные добавления" /> + setDeny(event.target.value)} description="Deny имеет приоритет" /> + ; +} + +function ConfigurationDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) { + const [deviceRef, setDeviceRef] = useState(workspace.devices[0]?.deviceRef ?? ""); + const [configuration, setConfiguration] = useState("{\n \"reporting_interval_seconds\": 30\n}"); + const [summary, setSummary] = useState(""); + useEffect(() => { + if (!workspace.devices.some((item) => item.deviceRef === deviceRef)) { + setDeviceRef(workspace.devices[0]?.deviceRef ?? ""); + } + }, [deviceRef, workspace.devices]); + return { + const parsed = JSON.parse(configuration) as Record; + const created = await createConfigurationRevision({ + projectRef: workspace.project.projectRef, + deviceRef, + configuration: parsed, + changeSummary: summary || null, + }); + await setDesiredConfiguration({ + projectRef: workspace.project.projectRef, + deviceRef, + configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef, + }); + }}> + ({ + value: section.id, + label: section.label, + }))} + onChange={(sectionId) => onDetailChange({ ...detail, sectionId })} + placement="bottom-end" + minMenuWidth={320} + menuWidth="anchor" + variant="split" + /> + onDetailChange({ + ...detail, + editing: !detail.editing, + })} + > + + + + ); +} + +export function DeviceInventoryView({ + workspace, + canClaim, + canConfigure, + canManageProject, + detail, + onClaim, + onPoll, + onError, + onDetailChange, +}: { + workspace: ProjectWorkspace; + canClaim: boolean; + canConfigure: boolean; + canManageProject: boolean; + detail: DeviceInventoryDetailState | null; + onClaim: (enrollment: ProjectWorkspace["enrollments"][number]) => void; + onPoll: () => Promise; + onError: (reason: unknown) => void; + onDetailChange: (detail: DeviceInventoryDetailState | null) => void; +}) { + const [statusFilter, setStatusFilter] = useState("all"); + const [sortOrder, setSortOrder] = useState("activity"); + const selectedDevice = workspace.devices.find( + (device) => device.deviceRef === detail?.deviceRef, + ) ?? null; + const pendingEnrollments = workspace.enrollments.filter( + (enrollment) => enrollment.lifecycleState !== "claimed", + ); + const deviceRows = useMemo(() => workspace.devices + .map((device) => { + const session = latestSession(workspace, device); + const online = session?.lifecycleState === "online" || device.session?.state === "online"; + const lastSeenAt = session?.lastSeenAt || device.session?.lastSeenAt || device.updatedAt; + return { device, session, online, lastSeenAt }; + }) + .filter((row) => { + if (statusFilter === "active") return row.online; + if (statusFilter === "inactive") return !row.online; + return statusFilter !== "pending"; + }) + .sort((left, right) => { + if (sortOrder === "name") { + return left.device.displayName.localeCompare(right.device.displayName, "ru"); + } + if (sortOrder === "activity" && left.online !== right.online) { + return left.online ? -1 : 1; + } + return String(right.lastSeenAt || "").localeCompare(String(left.lastSeenAt || "")); + }), [sortOrder, statusFilter, workspace]); + + useEffect(() => { + if (detail?.deviceRef && !selectedDevice) onDetailChange(null); + }, [detail?.deviceRef, onDetailChange, selectedDevice]); + + useEffect(() => { + if (!detail?.deviceRef) return undefined; + const poll = () => { + if (document.visibilityState === "visible") onPoll().catch(onError); + }; + const timer = window.setInterval(poll, 5_000); + return () => window.clearInterval(timer); + }, [detail?.deviceRef, onError, onPoll]); + + if (selectedDevice && detail) { + return ( + onDetailChange(null)} + /> + ); + } + + return ( +
+
+
+ Реестр устройств +

{workspace.devices.length} зарегистрировано · {pendingEnrollments.length} ожидают подключения

+
+
+ +
+ +
+ + {statusFilter !== "pending" && !deviceRows.length ? ( + + +

{workspace.devices.length ? "Устройств с таким состоянием нет" : "В проекте пока нет устройств"}

+

Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому процессу подключения.

+
+ ) : statusFilter !== "pending" ? ( + +
+ Устройство + Профиль + IMEI + ID интеграционного устройства + Канал + Последний пакет +
+ {deviceRows.map(({ device, session, online, lastSeenAt }) => { + return ( + + ); + })} +
+ ) : null} + + {(statusFilter === "all" || statusFilter === "pending") ? ( +
+
+
+ Ожидают подключения +

Разрешённые идентификаторы и обнаруженные устройства.

+
+ {pendingEnrollments.length} +
+ {pendingEnrollments.length ? pendingEnrollments.map((enrollment) => ( + onClaim(enrollment)}> + Принять устройство + + ) : {enrollment.lifecycleState}} + > +

После первого пакета устройство можно принять в реестр. Исходный идентификатор в интерфейсе не раскрывается.

+
+ )) : ( + + Нет ожидающих подключений. + + )} +
+ ) : null} +
+ ); +} + +function DeviceDetailView({ + device, + detail, + workspace, + canConfigure, + canManageProject, + onDetailChange, + onSaved, + onError, + onBack, +}: { + device: DeviceView; + detail: DeviceInventoryDetailState; + workspace: ProjectWorkspace; + canConfigure: boolean; + canManageProject: boolean; + onDetailChange: (detail: DeviceInventoryDetailState | null) => void; + onSaved: () => Promise; + onError: (reason: unknown) => void; + onBack: () => void; +}) { + const catalog = getDeviceProfileCatalog(device.modelProfileRef); + const detailRef = useRef(null); + const [draftValues, setDraftValues] = useState>({}); + const [saving, setSaving] = useState(false); + const profile = workspace.modelProfiles.find( + (item) => item.modelProfileRef === device.modelProfileRef, + ) ?? null; + const session = latestSession(workspace, device); + const configurationState = workspace.configurationStates.find( + (item) => item.deviceRef === device.deviceRef, + ) ?? null; + const identifierDisplayValue = deviceIdentifierDisplayValue(device); + const context = useMemo(() => ({ + device: { + ...device, + identifier: device.identifier ? { + ...device.identifier, + displayValue: identifierDisplayValue, + } : null, + }, + profile, + session, + configurationState, + reported: device.reported ?? {}, + policies: { + ...workspace.policies, + firmwareUpdate: "blocked", + }, + }), [configurationState, device, identifierDisplayValue, profile, session, workspace.policies]); + const activeSection = catalog.sections.find( + (section) => section.id === detail.sectionId, + ) ?? catalog.sections[0]; + + useEffect(() => { + if (!catalog.sections.some((section) => section.id === detail.sectionId)) { + onDetailChange({ + ...detail, + sectionId: catalog.sections[0]?.id ?? "passport", + }); + } + }, [catalog.sections, detail, onDetailChange]); + + useEffect(() => { + setDraftValues({}); + }, [detail.editing, device.deviceRef]); + + useEffect(() => { + const panelBody = detailRef.current?.closest(".nodedc-application-panel__body"); + if (panelBody) panelBody.scrollTop = 0; + }, [detail.sectionId, device.deviceRef]); + + if (!activeSection) return null; + + const saveDeviceChanges = async () => { + if (!(canConfigure || canManageProject) || !Object.keys(draftValues).length) return; + setSaving(true); + try { + const displayNameDraft = draftValues["device.displayName"]; + const integrationDeviceIdDraft = draftValues["device.integrationDeviceId"]; + const nextDisplayName = typeof displayNameDraft === "string" + ? displayNameDraft.trim() + : device.displayName; + const nextIntegrationDeviceId = typeof integrationDeviceIdDraft === "string" + ? integrationDeviceIdDraft.trim() || null + : device.integrationDeviceId; + if ( + canManageProject + && nextDisplayName + && ( + nextDisplayName !== device.displayName + || nextIntegrationDeviceId !== device.integrationDeviceId + ) + ) { + await updateDevice({ + projectRef: workspace.project.projectRef, + deviceRef: device.deviceRef, + displayName: nextDisplayName, + integrationDeviceId: nextIntegrationDeviceId, + }); + } + + const configurationDrafts = Object.entries(draftValues).filter(([path]) => + path.startsWith("reported.configuration."), + ); + if (configurationDrafts.length) { + const nextConfiguration = cloneConfiguration(device.reported?.configuration); + for (const [path, value] of configurationDrafts) { + const field = catalog.sections.flatMap((section) => section.fields) + .find((item) => item.path === path); + if (!field || !isDeviceFieldEditable(field, field.access ?? activeSection.access, { + canConfigure, + canManageProject, + })) continue; + writePath( + nextConfiguration, + path.replace(/^reported\.configuration\./, ""), + normalizeDraftValue(value, field), + ); + } + const created = await createConfigurationRevision({ + projectRef: workspace.project.projectRef, + deviceRef: device.deviceRef, + configuration: nextConfiguration, + changeSummary: `Device Manager · ${activeSection.title}`, + }); + await setDesiredConfiguration({ + projectRef: workspace.project.projectRef, + deviceRef: device.deviceRef, + configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef, + }); + } + setDraftValues({}); + onDetailChange({ ...detail, editing: false }); + await onSaved(); + } catch (reason) { + onError(reason); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ + + +
+ {catalog.vendor} · {catalog.model} +

{device.displayName}

+

{identifierDisplayValue || "Идентификатор не назначен"} · {device.modelProfileRef}

+
+ + {session?.lifecycleState === "online" ? "Онлайн" : session?.lifecycleState || device.lifecycleState} + +
+ +
+ + + +
+ + +
Состояние связи{session?.lifecycleState || device.session?.state || "Нет сессии"}
+
Маршрут{session?.routeName || "Не определён"}
+
Протокол{session?.protocol || profile?.protocol || "Нет данных"}
+
Последняя активность{formatDate(session?.lastSeenAt || device.session?.lastSeenAt)}
+
Пакеты{session?.frameCount ?? 0}
+
Подключено{formatDate(session?.connectedAt)}
+
+ +
+
+
+
+ {catalog.title} +

{activeSection.title}

+

{activeSection.description}

+
+ +
+ + + +
+ {activeSection.fields.map((item) => { + const access = item.access ?? activeSection.access; + const editable = detail.editing + && isDeviceFieldEditable(item, access, { + canConfigure, + canManageProject, + }); + const value = Object.prototype.hasOwnProperty.call(draftValues, item.path) + ? draftValues[item.path] + : readPath(context, item.path); + return ( + + {editable && item.valueKind === "boolean" ? ( + setDraftValues((current) => ({ ...current, [item.path]: checked }))} + /> + ) : editable ? ( + setDraftValues((current) => ({ ...current, [item.path]: event.target.value }))} + /> + ) : ( + <> +
+ {item.label} + {access !== activeSection.access ? : null} +
+ {formatFieldValue(value, item)} + {item.description ? {item.description} : null} + + )} +
+ ); + })} +
+ + {detail.editing ? ( +
+ + +
+ ) : null} + +
+ Desired + {configurationState?.desiredConfigurationRevisionRef || "Не задано"} + Applied + {configurationState?.appliedConfigurationRevisionRef || "Не подтверждено"} +
+
+
+
+ ); +} + +function isDeviceFieldEditable( + field: DeviceProfileField, + access: DeviceFieldAccess, + capabilities: { canConfigure: boolean; canManageProject: boolean } = { + canConfigure: true, + canManageProject: true, + }, +) { + return access === "managed" + && ( + (["device.displayName", "device.integrationDeviceId"].includes(field.path) && capabilities.canManageProject) + || (field.path.startsWith("reported.configuration.") && capabilities.canConfigure) + ) + && !field.sensitive; +} + +function deviceIdentifierDisplayValue(device: DeviceView) { + if (!device.identifier) return null; + if (device.identifier.value) return device.identifier.value; + const reportedImei = device.reported?.identity?.imei; + if (typeof reportedImei === "string" && reportedImei.trim()) return reportedImei; + return device.identifier.masked; +} + +function cloneConfiguration(configuration: Record | null | undefined) { + if (!configuration) return {}; + return JSON.parse(JSON.stringify(configuration)) as Record; +} + +function writePath(target: Record, path: string, value: unknown) { + const keys = path.split("."); + let cursor: Record | unknown[] = target; + keys.forEach((key, index) => { + if (index === keys.length - 1) { + if (Array.isArray(cursor)) cursor[Number(key)] = value; + else cursor[key] = value; + return; + } + const nextKey = keys[index + 1]; + const nextValue = Array.isArray(cursor) ? cursor[Number(key)] : cursor[key]; + if (!nextValue || typeof nextValue !== "object") { + const created: Record | unknown[] = /^\d+$/.test(nextKey) ? [] : {}; + if (Array.isArray(cursor)) cursor[Number(key)] = created; + else cursor[key] = created; + cursor = created; + } else { + cursor = nextValue as Record | unknown[]; + } + }); +} + +function normalizeDraftValue(value: string | boolean, field: DeviceProfileField) { + if (field.valueKind === "number") return value === "" ? null : Number(value); + return value; +} + +function AccessBadge({ access, compact = false }: { access: DeviceFieldAccess; compact?: boolean }) { + const tone = access === "managed" ? "accent" : access === "protected" ? "warning" : "neutral"; + return {accessLabel(access)}; +} + +function AccessNotice({ + access, + commandTransport, +}: { + access: DeviceFieldAccess; + commandTransport: ProjectWorkspace["policies"]["commandTransport"]; +}) { + if (access === "read-only") { + return Этот блок отражает фактическое состояние устройства и не редактируется.; + } + if (access === "protected") { + return Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.; + } + return {commandTransport === "typed-service-ping-v1" ? "Изменение создаёт новую desired-ревизию. Статус Applied появится только после подтверждения устройством." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}; +} + +function latestSession(workspace: ProjectWorkspace, device: DeviceView): SessionView | null { + const sessions = workspace.sessions.filter((item) => item.deviceRef === device.deviceRef); + return sessions.sort((left, right) => { + if (left.lifecycleState === "online" && right.lifecycleState !== "online") return -1; + if (right.lifecycleState === "online" && left.lifecycleState !== "online") return 1; + return String(right.lastSeenAt || right.connectedAt || "").localeCompare(String(left.lastSeenAt || left.connectedAt || "")); + })[0] ?? null; +} + +function profileLabel(workspace: ProjectWorkspace, device: DeviceView) { + const profile = workspace.modelProfiles.find( + (item) => item.modelProfileRef === device.modelProfileRef, + ); + return profile ? `${profile.vendor} ${profile.model}` : device.modelProfileRef; +} + +function readPath(input: unknown, path: string): unknown { + return path.split(".").reduce((value, key) => { + if (!value || typeof value !== "object") return undefined; + return (value as Record)[key]; + }, input); +} + +function formatFieldValue(value: unknown, item: DeviceProfileField) { + if (value === undefined || value === null || value === "") return "Нет данных"; + if (item.sensitive) return "Задано · значение скрыто"; + if (item.valueKind === "date") return formatDate(String(value)); + if (item.valueKind === "boolean" || typeof value === "boolean") return value ? "Включено" : "Выключено"; + if (value === "blocked") return "Запрещено"; + if (Array.isArray(value)) return value.length ? value.join(", ") : "Нет данных"; + if (typeof value === "object") return JSON.stringify(value); + return `${String(value)}${item.unit ? ` ${item.unit}` : ""}`; +} + +function formatDate(value: string | null | undefined) { + if (!value) return "Нет данных"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(date); +} + +export const __deviceInventoryTestables = { + formatFieldValue, + readPath, +} as const; diff --git a/apps/device-manager/src/DeviceManagerApp.tsx b/apps/device-manager/src/DeviceManagerApp.tsx new file mode 100644 index 0000000..d755c01 --- /dev/null +++ b/apps/device-manager/src/DeviceManagerApp.tsx @@ -0,0 +1,1478 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { + AdminNavigationPanel, + AppHeader, + ApplicationPanel, + ApplicationShell, + Button, + ColorField, + Dropdown, + FeatureSettingsWindow, + GlassSurface, + HeaderNavigation, + HeaderProfile, + HeaderWorkspace, + Icon, + type IconName, + IconButton, + MediaSourceField, + SegmentedControl, + Select, + SettingsCard, + StatusBadge, + Switch, + TextAreaField, + TextField, + UserProfileMenu, + Window, + WindowFooterActions, + useApplicationWorkspace, +} from "@nodedc/ui-react"; +import { applyNodedcTheme } from "@nodedc/ui-core"; +import { + claimDevice, + ensureCollection, + ensureEnrollmentIntent, + ensureOwnerScope, + ensureProject, + loadPresentation, + loadProjects, + loadSession, + loadWorkspace, + saveEnvironmentPresentation, + saveProjectPresentation, + uploadPresentationMedia, +} from "./api"; +import type { + DeviceManagerMediaValue, + DeviceManagerEnvironmentOverview, + DeviceManagerPresentation, + DeviceManagerProjectPresentation, + DeviceManagerSession, + DeviceManagerTheme, + EnrollmentView, + OwnerScopeClaim, + ProjectSummary, + ProjectWorkspace, +} from "./types"; +import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor"; +import { + DeviceControlView, + type ControlViewId, +} from "./DeviceControlViews"; +import { + DeviceDetailHeaderTools, + DeviceInventoryView, + type DeviceInventoryDetailState, +} from "./DeviceInventoryView"; + +type ViewId = + | "overview" + | "inventory" + | "collections" + | ControlViewId; + +type PrimarySection = "overview" | "devices" | "infrastructure" | "management" | "administration"; + +type NavigationItem = { + id: ViewId; + label: string; + icon: IconName; + capability: string | null; +}; + +const primarySections: Array<{ value: PrimarySection; label: string }> = [ + { value: "overview", label: "Обзор" }, + { value: "devices", label: "Устройства" }, + { value: "infrastructure", label: "Инфраструктура" }, + { value: "management", label: "Управление" }, + { value: "administration", label: "Администрирование" }, +]; + +const sectionNavigation: Record = { + overview: [ + { id: "overview", label: "Обзор проекта", icon: "grid", capability: null }, + ], + devices: [ + { id: "inventory", label: "Устройства", icon: "apps", capability: "inventory.read" }, + { id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" }, + ], + infrastructure: [ + { id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" }, + { id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" }, + ], + management: [ + { id: "bindings", label: "Связи с Foundry", icon: "external", capability: "binding.manage" }, + { id: "commands", label: "Команды", icon: "target", capability: "command.plan" }, + { id: "settings", label: "Конфигурации", icon: "settings", capability: "configuration.read" }, + ], + administration: [ + { id: "audit", label: "Аудит", icon: "clipboard", capability: "audit.read" }, + { id: "access", label: "Доступ", icon: "users", capability: "access.manage" }, + ], +}; + +const sectionLabels: Record = { + overview: "Обзор", + devices: "Устройства", + infrastructure: "Инфраструктура", + management: "Управление", + administration: "Администрирование", +}; + +const emptyMedia = (): DeviceManagerMediaValue => ({ + source: "file", + url: "", + fileName: null, + fileSrc: null, +}); + +const defaultPresentation = (): DeviceManagerPresentation => ({ + environment: { + theme: "dark", + accentHex: "#b9ff4a", + overview: { + headerLabel: "Device Core", + eyebrow: "NODEDC / DEVICE CORE", + title: "Device Core", + description: "Единый контур подключения, учёта и управления устройствами.", + primarySection: "devices", + secondarySection: null, + background: { + enabled: false, + imageDurationSeconds: 10, + items: [], + }, + }, + }, + projects: {}, +}); + +const emptyProjectPresentation = (): DeviceManagerProjectPresentation => ({ + icon: emptyMedia(), + teaser: emptyMedia(), +}); + +function mediaSource(value?: DeviceManagerMediaValue | null) { + if (!value) return null; + return value.source === "url" ? value.url.trim() || null : value.fileSrc; +} + +function cloneOverview(value: DeviceManagerEnvironmentOverview): DeviceManagerEnvironmentOverview { + return { + ...value, + background: { + ...value.background, + items: value.background.items.map((item) => ({ ...item })), + }, + }; +} + +function hexToRgb(value: string): [number, number, number] { + const match = /^#([0-9a-f]{6})$/i.exec(value.trim()); + if (!match) return [185, 255, 74]; + return [ + Number.parseInt(match[1].slice(0, 2), 16), + Number.parseInt(match[1].slice(2, 4), 16), + Number.parseInt(match[1].slice(4, 6), 16), + ]; +} + +export function DeviceManagerApp() { + const shell = useApplicationWorkspace({ navigationOpen: false }); + const [activeSection, setActiveSection] = useState("overview"); + const [session, setSession] = useState(null); + const [projects, setProjects] = useState([]); + const [presentation, setPresentation] = useState(defaultPresentation); + const [workspace, setWorkspace] = useState(null); + const [activeOwnerRef, setActiveOwnerRef] = useState(""); + const [activeProjectRef, setActiveProjectRef] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [projectDialogOpen, setProjectDialogOpen] = useState(false); + const [collectionDialogOpen, setCollectionDialogOpen] = useState(false); + const [enrollmentDialogOpen, setEnrollmentDialogOpen] = useState(false); + const [claimEnrollment, setClaimEnrollment] = useState(null); + const [projectSettingsOpen, setProjectSettingsOpen] = useState(false); + const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false); + const [inventoryDetail, setInventoryDetail] = useState(null); + + const refreshProjects = async () => { + const next = await loadProjects(); + setProjects(next); + setActiveProjectRef((current) => + next.some((project) => project.projectRef === current) + ? current + : next[0]?.projectRef ?? "" + ); + return next; + }; + + useEffect(() => { + let active = true; + Promise.all([loadSession(), loadProjects(), loadPresentation()]) + .then(([nextSession, nextProjects, nextPresentation]) => { + if (!active) return; + setSession(nextSession); + setProjects(nextProjects); + setPresentation(nextPresentation); + const ownerRef = nextProjects[0]?.ownerScope.ownerRef + || nextSession.actor.ownerScopes[0]?.ownerRef + || ""; + setActiveOwnerRef(ownerRef); + setActiveProjectRef(nextProjects[0]?.projectRef ?? ""); + }) + .catch((reason) => active && setError(errorText(reason))) + .finally(() => active && setLoading(false)); + return () => { active = false; }; + }, []); + + useEffect(() => { + applyNodedcTheme(document.documentElement, { + theme: presentation.environment.theme, + accent: hexToRgb(presentation.environment.accentHex), + }); + }, [presentation.environment.accentHex, presentation.environment.theme]); + + useEffect(() => { + if (!activeProjectRef) { + setWorkspace(null); + return; + } + let active = true; + loadWorkspace(activeProjectRef) + .then((next) => active && setWorkspace(next)) + .catch((reason) => active && setError(errorText(reason))); + return () => { active = false; }; + }, [activeProjectRef]); + + const ownerScopes = useMemo( + () => mergeOwnerScopes(session?.actor.ownerScopes ?? [], projects), + [projects, session], + ); + const visibleProjects = useMemo( + () => projects.filter((project) => !activeOwnerRef || project.ownerScope.ownerRef === activeOwnerRef), + [activeOwnerRef, projects], + ); + const activeProject = projects.find((project) => project.projectRef === activeProjectRef) ?? null; + const creatableOwnerScopes = session?.actor.ownerScopes ?? []; + const capabilities = new Set(activeProject?.access.capabilities ?? []); + const canCreateProject = creatableOwnerScopes.length > 0; + const canManageProject = capabilities.has("project.manage"); + const canManageCollections = capabilities.has("collection.manage"); + const canEnroll = capabilities.has("device.enroll"); + const canClaim = capabilities.has("device.claim"); + const canConfigure = capabilities.has("configuration.manage"); + const canManageEnvironment = session?.actor.groupRefs.some( + (groupRef) => groupRef === "nodedc:superadmin" || groupRef === "group:nodedc:superadmin", + ) ?? false; + const visibleNavigationItems = sectionNavigation[activeSection].filter( + (item) => item.capability === null || capabilities.has(item.capability), + ); + + useEffect(() => { + if (!activeOwnerRef && ownerScopes[0]) setActiveOwnerRef(ownerScopes[0].ownerRef); + }, [activeOwnerRef, ownerScopes]); + + useEffect(() => { + if ( + activeProjectRef + && !visibleProjects.some((project) => project.projectRef === activeProjectRef) + ) { + setActiveProjectRef(visibleProjects[0]?.projectRef ?? ""); + setWorkspace(null); + } + }, [activeOwnerRef, activeProjectRef, visibleProjects]); + + const selectProject = (projectRef: string) => { + setInventoryDetail(null); + setActiveProjectRef(projectRef); + shell.closeView(); + }; + + const openSection = (section: PrimarySection) => { + if (section !== "devices") setInventoryDetail(null); + setActiveSection(section); + shell.openNavigation(); + shell.closeView(); + }; + + const openTechnicalView = (view: ViewId) => { + if (view !== "inventory") setInventoryDetail(null); + setActiveSection(sectionForView(view)); + shell.openView(view); + }; + + const selectOwner = (ownerRef: string) => { + setActiveOwnerRef(ownerRef); + setInventoryDetail(null); + const nextProject = projects.find((project) => project.ownerScope.ownerRef === ownerRef); + if (nextProject) { + setActiveProjectRef(nextProject.projectRef); + shell.closeView(); + } + else { + setActiveProjectRef(""); + setWorkspace(null); + shell.closeView(); + } + }; + + const refreshWorkspace = async () => { + if (!activeProjectRef) return; + const next = await loadWorkspace(activeProjectRef); + setWorkspace(next); + await refreshProjects(); + }; + + const pollWorkspace = async () => { + if (!activeProjectRef) return; + const next = await loadWorkspace(activeProjectRef); + setWorkspace(next); + }; + + if (loading || !session) { + return
Подключаем Device Core…
; + } + + const activeView = shell.activeView; + const selectedInventoryDevice = inventoryDetail + ? workspace?.devices.find((device) => device.deviceRef === inventoryDetail.deviceRef) ?? null + : null; + return ( + <> + } + brandHref="/" + center={ + <> + + + + } + right={ + + setEnvironmentSettingsOpen(true), + }] : []), + { id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" }, + ]} + /> + + } + /> + } + stage={ + setError(null)} + onOpenSection={openSection} + /> + } + navigationOpen={shell.navigationOpen} + navigation={ + + {activeSection === "devices" && activeProject ? ( + route.lifecycleState === "active")} + onClick={() => setEnrollmentDialogOpen(true)} + > + + + ) : activeSection === "overview" ? ( + setProjectDialogOpen(true)} + > + + + ) : null} + {activeProject ? ( + setProjectSettingsOpen(true)} + > + + + ) : null} + + } + contextSlot={ + ownerScopes.length ? ( +
+ ({ + value: project.projectRef, + label: project.name, + description: project.access.projectRole || "read", + }))} + onChange={selectProject} + searchable={visibleProjects.length > 6} + /> + ) : null} +
+ ) : null + } + items={activeProject ? visibleNavigationItems.map((item) => ({ + id: item.id, + label: item.label, + icon: , + })) : []} + activeId={activeProject && activeView ? activeView : undefined} + footer={{session.actor.hubRole} · {projects.length} проектов} + onClose={shell.closeNavigation} + onItemChange={(id) => openTechnicalView(id as ViewId)} + /> + } + contentOpen={shell.contentOpen} + contentExpanded={shell.contentExpanded} + content={ + activeProject && activeView ? ( + + ) : undefined} + utilityActions={[{ + label: "Обновить данные", + icon: "refresh", + onClick: () => refreshWorkspace().catch((reason) => setError(errorText(reason))), + }]} + > + setError(errorText(reason))} + onCreateCollection={() => setCollectionDialogOpen(true)} + onClaim={setClaimEnrollment} + onInventoryDetailChange={setInventoryDetail} + /> + + ) : null + } + /> + + setProjectDialogOpen(false)} + onCreated={async () => { + setProjectDialogOpen(false); + await refreshProjects(); + }} + onError={(reason) => setError(errorText(reason))} + /> + setCollectionDialogOpen(false)} + onCreated={async () => { + setCollectionDialogOpen(false); + await refreshWorkspace(); + }} + onError={(reason) => setError(errorText(reason))} + /> + setClaimEnrollment(null)} + onClaimed={async () => { + setClaimEnrollment(null); + await refreshWorkspace(); + }} + onError={(reason) => setError(errorText(reason))} + /> + setEnrollmentDialogOpen(false)} + onCreated={async () => { + setEnrollmentDialogOpen(false); + await refreshWorkspace(); + }} + onError={(reason) => setError(errorText(reason))} + /> + setProjectSettingsOpen(false)} + onSaved={async (nextPresentation) => { + setPresentation(nextPresentation); + setProjectSettingsOpen(false); + await refreshWorkspace(); + }} + onError={(reason) => setError(errorText(reason))} + /> + setEnvironmentSettingsOpen(false)} + onSaved={(nextPresentation) => { + setPresentation(nextPresentation); + setEnvironmentSettingsOpen(false); + }} + onError={(reason) => setError(errorText(reason))} + /> + + ); +} + +function DeviceContextSwitcher({ + ownerScopes, + projects, + presentation, + activeOwnerRef, + activeProjectRef, + onSelectOwner, + onSelectProject, +}: { + ownerScopes: OwnerScopeClaim[]; + projects: ProjectSummary[]; + presentation: DeviceManagerPresentation; + activeOwnerRef: string; + activeProjectRef: string; + onSelectOwner: (ownerRef: string) => void; + onSelectProject: (projectRef: string) => void; +}) { + const activeOwner = ownerScopes.find((scope) => scope.ownerRef === activeOwnerRef) ?? null; + const activeProject = projects.find((project) => project.projectRef === activeProjectRef) ?? null; + const activeIcon = activeProject ? mediaSource(presentation.projects[activeProject.projectRef]?.icon) : null; + return ( + ( + + )} + > + {({ close }) => ( +
+
+ Контур и проект + {activeProject?.name || activeOwner?.displayName || "Не выбрано"} +
+
+ Контуры + {ownerScopes.map((scope) => ( + + ))} +
+
+ Проекты + {projects.filter((project) => project.ownerScope.ownerRef === activeOwnerRef).map((project) => ( + + ))} +
+
+ )} +
+ ); +} + +function DeviceStage({ + presentation, + error, + onDismissError, + onOpenSection, +}: { + presentation: DeviceManagerPresentation; + error: string | null; + onDismissError: () => void; + onOpenSection: (section: PrimarySection) => void; +}) { + const overview = presentation.environment.overview; + const actions = [overview.primarySection, overview.secondarySection] + .filter((value, index, values): value is PrimarySection => ( + Boolean(value) + && primarySections.some((section) => section.value === value) + && values.indexOf(value) === index + )); + return ( +
+ {error ? ( + + + {error} + + + ) : null} + + + + + ); +} + +function EnvironmentBackdrop({ + background, +}: { + background: DeviceManagerEnvironmentOverview["background"]; +}) { + const playableItems = useMemo( + () => background.enabled + ? background.items.filter((item) => Boolean(mediaSource(item)) && Boolean(item.mediaKind)) + : [], + [background.enabled, background.items], + ); + const [activeIndex, setActiveIndex] = useState(0); + const activeItem = playableItems[activeIndex] ?? null; + const source = activeItem ? mediaSource(activeItem) : null; + const playbackKey = playableItems + .map((item) => `${item.id}:${mediaSource(item) || ""}:${item.mediaKind}`) + .join("|"); + + useEffect(() => setActiveIndex(0), [playbackKey]); + useEffect(() => { + if (!activeItem || activeItem.mediaKind !== "image" || playableItems.length < 2) return undefined; + const timer = window.setTimeout( + () => setActiveIndex((current) => (current + 1) % playableItems.length), + background.imageDurationSeconds * 1000, + ); + return () => window.clearTimeout(timer); + }, [activeItem, background.imageDurationSeconds, playableItems.length]); + + if (!activeItem || !source) return null; + if (activeItem.mediaKind === "image") { + return ; + } + return ( +