From 51cb426c6b986d2f66570e2d03d93026346f1055 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 12:23:21 +0300 Subject: [PATCH] refactor: move Device Manager app to Device Core repository --- apps/device-manager/README.md | 60 -- apps/device-manager/index.html | 13 - apps/device-manager/package.json | 26 - apps/device-manager/public/nodedc-logo.svg | 1 - apps/device-manager/public/nodedc-mark.svg | 1 - .../server/device-core-client.mjs | 889 ---------------- .../server/device-core-client.test.mjs | 246 ----- .../server/device-manager-auth.mjs | 465 --------- .../server/device-manager-auth.test.mjs | 214 ---- .../server/device-manager-server.mjs | 275 ----- .../server/device-manager-server.test.mjs | 178 ---- .../device-manager/src/DeviceControlViews.tsx | 955 ----------------- .../src/DeviceInventoryView.tsx | 329 ------ apps/device-manager/src/DeviceManagerApp.tsx | 771 -------------- apps/device-manager/src/api.ts | 252 ----- .../src/deviceProfileCatalog.ts | 465 --------- apps/device-manager/src/main.tsx | 17 - apps/device-manager/src/styles.css | 962 ------------------ apps/device-manager/src/types.ts | 287 ------ apps/device-manager/tsconfig.json | 9 - apps/device-manager/vite.config.ts | 7 - package-lock.json | 21 - 22 files changed, 6443 deletions(-) delete mode 100644 apps/device-manager/README.md delete mode 100644 apps/device-manager/index.html delete mode 100644 apps/device-manager/package.json delete mode 100644 apps/device-manager/public/nodedc-logo.svg delete mode 100644 apps/device-manager/public/nodedc-mark.svg delete mode 100644 apps/device-manager/server/device-core-client.mjs delete mode 100644 apps/device-manager/server/device-core-client.test.mjs delete mode 100644 apps/device-manager/server/device-manager-auth.mjs delete mode 100644 apps/device-manager/server/device-manager-auth.test.mjs delete mode 100644 apps/device-manager/server/device-manager-server.mjs delete mode 100644 apps/device-manager/server/device-manager-server.test.mjs delete mode 100644 apps/device-manager/src/DeviceControlViews.tsx delete mode 100644 apps/device-manager/src/DeviceInventoryView.tsx delete mode 100644 apps/device-manager/src/DeviceManagerApp.tsx delete mode 100644 apps/device-manager/src/api.ts delete mode 100644 apps/device-manager/src/deviceProfileCatalog.ts delete mode 100644 apps/device-manager/src/main.tsx delete mode 100644 apps/device-manager/src/styles.css delete mode 100644 apps/device-manager/src/types.ts delete mode 100644 apps/device-manager/tsconfig.json delete mode 100644 apps/device-manager/vite.config.ts diff --git a/apps/device-manager/README.md b/apps/device-manager/README.md deleted file mode 100644 index c57f78a..0000000 --- a/apps/device-manager/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# 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 deleted file mode 100644 index 30163c1..0000000 --- a/apps/device-manager/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - NODE.DC Device Core - - -
- - - diff --git a/apps/device-manager/package.json b/apps/device-manager/package.json deleted file mode 100644 index 84885dd..0000000 --- a/apps/device-manager/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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/ui-core": "0.7.0", - "@nodedc/ui-react": "0.7.0", - "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 deleted file mode 100644 index 92b19d8..0000000 --- a/apps/device-manager/public/nodedc-logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/device-manager/public/nodedc-mark.svg b/apps/device-manager/public/nodedc-mark.svg deleted file mode 100644 index 866cfe4..0000000 --- a/apps/device-manager/public/nodedc-mark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/device-manager/server/device-core-client.mjs b/apps/device-manager/server/device-core-client.mjs deleted file mode 100644 index c097f16..0000000 --- a/apps/device-manager/server/device-core-client.mjs +++ /dev/null @@ -1,889 +0,0 @@ -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"], - ["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: "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); - } - 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: "Локальная визуальная фикстура без реальных идентификаторов и команд", - 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", - modelProfileRef: "arusnavi.b2.internal.v1", - lifecycleState: "active", - identifier: { kind: "imei", masked: "***********0001" }, - session: { state: "online", lastSeenAt: timestamp }, - reported: { - observedAt: timestamp, - identity: { 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 deleted file mode 100644 index eed6595..0000000 --- a/apps/device-manager/server/device-core-client.test.mjs +++ /dev/null @@ -1,246 +0,0 @@ -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, "***********0001"); - assert.equal(workspace.sessions[0].lifecycleState, "online"); - assert.equal(workspace.policies.commandTransport, "typed-service-ping-v1"); - 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 deleted file mode 100644 index 542e6ed..0000000 --- a/apps/device-manager/server/device-manager-auth.mjs +++ /dev/null @@ -1,465 +0,0 @@ -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 deleted file mode 100644 index 4ddd205..0000000 --- a/apps/device-manager/server/device-manager-auth.test.mjs +++ /dev/null @@ -1,214 +0,0 @@ -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-server.mjs b/apps/device-manager/server/device-manager-server.mjs deleted file mode 100644 index 3fd5858..0000000 --- a/apps/device-manager/server/device-manager-server.mjs +++ /dev/null @@ -1,275 +0,0 @@ -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"; - -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/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"), -} = {}) { - 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 }); - } - 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, - }); - } - return createDeviceManagerServer({ auth, coreClient }); -} - -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" }); - } - response.statusCode = 200; - response.setHeader("Content-Type", contentType(filePath)); - response.setHeader( - "Cache-Control", - filePath.endsWith("index.html") ? "no-store" : "private, max-age=300", - ); - 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) { - 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); - } - try { - const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); - if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error(); - return body; - } catch { - throw serviceError("device_manager_json_invalid", 400); - } -} - -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 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", - ".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 deleted file mode 100644 index 0753362..0000000 --- a/apps/device-manager/server/device-manager-server.test.mjs +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index a7eb2be..0000000 --- a/apps/device-manager/src/DeviceControlViews.tsx +++ /dev/null @@ -1,955 +0,0 @@ -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: scope.ownerRef, - label: scope.displayName, - description: scope.scopeKind === "company" ? "Компания" : "Личный контур", - }))} - onChange={setActiveOwnerRef} - searchable={ownerScopes.length > 6} - /> - {visibleProjects.length ? ( - ({ - value: scope.ownerRef, - label: scope.displayName, - description: scope.scopeKind === "company" ? "Коммерческий контур" : "Личный контур", - }))} - onChange={setOwnerRef} - /> - setName(event.target.value)} required maxLength={160} /> - setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" description="Стабильный ключ: латиница, цифры и дефис." /> - setDescription(event.target.value)} maxLength={2000} /> - - ; -} - -function CollectionDialog({ open, project, onClose, onCreated, onError }: { - open: boolean; project: ProjectSummary | null; onClose: () => void; onCreated: () => Promise; onError: (reason: unknown) => void; -}) { - const [name, setName] = useState(""); - const [key, setKey] = useState(""); - const [description, setDescription] = useState(""); - const [pending, setPending] = useState(false); - const submit = async (event: FormEvent) => { - event.preventDefault(); if (!project) return; setPending(true); - try { - await ensureCollection({ projectRef: project.projectRef, collectionKey: key, name, description: description.trim() || null }); - setName(""); setKey(""); setDescription(""); await onCreated(); - } catch (reason) { onError(reason); } finally { setPending(false); } - }; - return - }> -
- setName(event.target.value)} required maxLength={160} /> - setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" /> - setDescription(event.target.value)} maxLength={2000} /> - -
; -} - -function ClaimDialog({ enrollment, project, onClose, onClaimed, onError }: { - enrollment: EnrollmentView | null; project: ProjectSummary | null; onClose: () => void; onClaimed: () => Promise; onError: (reason: unknown) => void; -}) { - const [name, setName] = useState(""); - const [key, setKey] = useState(""); - const [pending, setPending] = useState(false); - useEffect(() => { if (enrollment) setName(enrollment.displayName); }, [enrollment]); - const submit = async (event: FormEvent) => { - event.preventDefault(); - if (!project || !enrollment?.observedDiscoveryRef) return; - setPending(true); - try { - await claimDevice({ - projectRef: project.projectRef, - enrollmentIntentRef: enrollment.enrollmentIntentRef, - discoveryRef: enrollment.observedDiscoveryRef, - deviceKey: key, - displayName: name, - }); - setName(""); setKey(""); await onClaimed(); - } catch (reason) { onError(reason); } finally { setPending(false); } - }; - return - }> -
- setName(event.target.value)} required maxLength={160} /> - setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" /> -

Claim использует только ссылки на enrollment и discovery. Исходный идентификатор не запрашивается повторно.

- -
; -} - -function EnrollmentDialog({ open, workspace, onClose, onCreated, onError }: { - open: boolean; - workspace: ProjectWorkspace | null; - onClose: () => void; - onCreated: () => Promise; - onError: (reason: unknown) => void; -}) { - const activeRoutes = useMemo( - () => workspace?.routes.filter((route) => route.lifecycleState === "active") ?? [], - [workspace], - ); - const [routeRef, setRouteRef] = useState(""); - const [name, setName] = useState(""); - const [key, setKey] = useState(""); - const [imei, setImei] = useState(""); - const [expiresAt, setExpiresAt] = useState(""); - const [pending, setPending] = useState(false); - useEffect(() => { - if (!activeRoutes.some((route) => route.routeRef === routeRef)) { - setRouteRef(activeRoutes[0]?.routeRef ?? ""); - } - }, [activeRoutes, routeRef]); - useEffect(() => { - if (!open) setImei(""); - }, [open]); - const submit = async (event: FormEvent) => { - event.preventDefault(); - const route = activeRoutes.find((item) => item.routeRef === routeRef); - if (!workspace || !route) return; - setPending(true); - try { - await ensureEnrollmentIntent({ - projectRef: workspace.project.projectRef, - enrollmentKey: key, - routeRef: route.routeRef, - modelProfileRef: route.modelProfileRef, - displayName: name, - identifier: { kind: "imei", value: imei }, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - }); - setImei(""); - setName(""); - setKey(""); - setExpiresAt(""); - await onCreated(); - } catch (reason) { - onError(reason); - } finally { - setPending(false); - } - }; - return - - - - }> -
-