import { createReadStream } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import { createServer } from "node:http"; import { dirname, extname, resolve, sep } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createDeviceManagerAuth } from "./device-manager-auth.mjs"; import { createDeviceCoreClient, createLocalPreviewDeviceCore, } from "./device-core-client.mjs"; import { createDeviceManagerPresentationStore, normalizeEnvironmentPresentation, normalizeProjectPresentation, } from "./device-manager-presentation.mjs"; const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const mutationRoutes = new Map([ ["/api/device-manager/owner-scopes:ensure", "owner-scopes:ensure"], ["/api/device-manager/projects:ensure", "projects:ensure"], ["/api/device-manager/collections:ensure", "collections:ensure"], ["/api/device-manager/project-grants:upsert", "project-grants:upsert"], ["/api/device-manager/adapter-packages:ensure", "adapter-packages:ensure"], ["/api/device-manager/adapter-versions:register", "adapter-versions:register"], ["/api/device-manager/model-profiles:register", "model-profiles:register"], ["/api/device-manager/edges:ensure", "edges:ensure"], ["/api/device-manager/routes:ensure", "routes:ensure"], ["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"], ["/api/device-manager/devices:claim", "devices:claim"], ["/api/device-manager/devices:update", "devices:update"], ["/api/device-manager/assets:ensure", "assets:ensure"], ["/api/device-manager/asset-bindings:ensure", "asset-bindings:ensure"], ["/api/device-manager/asset-bindings:close", "asset-bindings:close"], ["/api/device-manager/infrastructure-hosts:ensure", "infrastructure-hosts:ensure"], ["/api/device-manager/infrastructure-endpoints:ensure", "infrastructure-endpoints:ensure"], ["/api/device-manager/infrastructure-deployments:ensure", "infrastructure-deployments:ensure"], ["/api/device-manager/infrastructure-service-instances:ensure", "infrastructure-service-instances:ensure"], ["/api/device-manager/health-observations:record", "health-observations:record"], ["/api/device-manager/device-bindings:ensure", "device-bindings:ensure"], ["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"], [ "/api/device-manager/device-configuration-revisions:create", "device-configuration-revisions:create", ], [ "/api/device-manager/device-configurations:set-desired", "device-configurations:set-desired", ], ["/api/device-manager/commands:service-ping", "commands:service-ping"], ]); export function createDeviceManagerServer({ auth, coreClient, distRoot = resolve(appRoot, "dist"), presentationStore = createDeviceManagerPresentationStore({ layoutPath: resolve(appRoot, "runtime-data/device-manager-presentation.json"), uploadRoot: resolve(appRoot, "runtime-data/device-manager-media"), }), } = {}) { if (!auth || typeof auth.authorize !== "function") { throw new TypeError("device_manager_auth_required"); } if (!coreClient || typeof coreClient.listProjects !== "function") { throw new TypeError("device_manager_core_client_required"); } return createServer(async (request, response) => { response.setHeader("X-Content-Type-Options", "nosniff"); response.setHeader("Referrer-Policy", "same-origin"); response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); try { const url = new URL( request.url || "/", `http://${request.headers.host || "127.0.0.1"}`, ); if (request.method === "GET" && url.pathname === "/healthz") { return sendJson(response, 200, { ok: true, service: "nodedc-device-manager", authRequired: auth.authRequired, deviceCoreConfigured: coreClient.configured === true, }); } if (request.method === "GET" && url.pathname === "/auth/nodedc/handoff") { return auth.handleHandoff(request, response, url); } if (request.method === "GET" && url.pathname === "/auth/logout") { return auth.handleLogout(request, response); } if (await auth.authorize(request, response, url)) return; const context = auth.currentContext(request); if (!context) return sendJson(response, 401, { ok: false, error: "device_manager_auth_required", }); if (request.method === "GET" && url.pathname === "/api/device-manager/session") { return sendJson(response, 200, { ok: true, session: context }); } if (request.method === "GET" && url.pathname === "/api/device-manager/projects") { const projects = await coreClient.listProjects(context.actor); return sendJson(response, 200, { ok: true, projects }); } if (request.method === "GET" && url.pathname === "/api/device-manager/presentation") { const [presentation, projects] = await Promise.all([ presentationStore.read(), coreClient.listProjects(context.actor), ]); const allowed = new Set(projects.map((project) => project.projectRef)); return sendJson(response, 200, { ok: true, presentation: { environment: presentation.environment, projects: Object.fromEntries( Object.entries(presentation.projects).filter(([ref]) => allowed.has(ref)), ), }, }); } if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/project") { const input = await readJsonBody(request, 128 * 1024); const projectRef = validProjectRef(input.projectRef); await requireProjectManage(coreClient, context.actor, projectRef); const current = await presentationStore.read(); current.projects[projectRef] = normalizeProjectPresentation(input.presentation); const presentation = await presentationStore.write(current); return sendJson(response, 200, { ok: true, presentation }); } if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/environment") { requireSuperAdmin(context.actor); const input = await readJsonBody(request, 128 * 1024); const current = await presentationStore.read(); current.environment = normalizeEnvironmentPresentation(input.environment); const presentation = await presentationStore.write(current); return sendJson(response, 200, { ok: true, presentation }); } if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/media") { const scope = url.searchParams.get("scope"); const kind = url.searchParams.get("kind"); if (kind !== "icon" && kind !== "teaser" && kind !== "background") { throw serviceError("device_manager_media_kind_invalid", 400); } if (scope === "environment") { requireSuperAdmin(context.actor); if (kind !== "background") throw serviceError("device_manager_media_kind_invalid", 400); } else if (scope === "project") { if (kind === "background") throw serviceError("device_manager_media_kind_invalid", 400); await requireProjectManage(coreClient, context.actor, validProjectRef(url.searchParams.get("projectRef"))); } else { throw serviceError("device_manager_media_scope_invalid", 400); } const bytes = await readBody(request, kind === "icon" ? 8 * 1024 * 1024 : 256 * 1024 * 1024); const media = await presentationStore.saveMedia({ bytes, contentType: request.headers["content-type"], originalName: singleOptionalHeader(request.headers["x-file-name"]), kind, }); return sendJson(response, 200, { ok: true, ...media }); } if ((request.method === "GET" || request.method === "HEAD") && url.pathname.startsWith("/device-manager-media/")) { const mediaPath = presentationStore.resolveMedia(url.pathname); if (!mediaPath) return sendJson(response, 404, { ok: false, error: "device_manager_media_not_found" }); return serveFile(request, response, mediaPath, "private, max-age=300"); } const projectRef = workspaceProjectRef(url.pathname); if (request.method === "GET" && projectRef) { const workspace = await coreClient.getWorkspace(context.actor, projectRef); return sendJson(response, 200, { ok: true, workspace }); } const command = mutationRoutes.get(url.pathname); if (request.method === "POST" && command) { const idempotencyKey = singleHeader(request.headers["idempotency-key"]); const input = await readJsonBody(request, 64 * 1024); const execution = await coreClient.execute( command, context.actor, input, idempotencyKey, ); response.setHeader("Idempotency-Key", idempotencyKey); response.setHeader( "Idempotency-Replayed", execution.replayed ? "true" : "false", ); return sendJson(response, 200, { ok: true, ...execution }); } if (url.pathname.startsWith("/api/")) { return sendJson(response, 404, { ok: false, error: "device_manager_route_not_found" }); } return serveStatic(request, response, url, distRoot); } catch (error) { if (response.headersSent) { response.destroy(); return; } const statusCode = normalizeStatus(error?.statusCode); return sendJson(response, statusCode, { ok: false, error: safeError(error), }); } }); } export async function createConfiguredDeviceManagerServer({ env = process.env } = {}) { const localPreview = booleanValue(env.NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW, false); const launcherTokenFile = String(env.NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE || "").trim(); const launcherInternalToken = launcherTokenFile ? (await readFile(launcherTokenFile, "utf8")).trim() : undefined; const auth = createDeviceManagerAuth({ env, internalToken: launcherInternalToken }); if (auth.authRequired && !auth.internalAccessConfigured) { throw new Error("device_manager_auth_token_file_required"); } let coreClient; if (localPreview) { if (String(env.NODE_ENV || "").toLowerCase() === "production") { throw new Error("device_manager_local_preview_forbidden"); } coreClient = createLocalPreviewDeviceCore({ fixture: String(env.NODEDC_DEVICE_MANAGER_PREVIEW_FIXTURE || "").trim() || null, }); } else { const tokenFile = String(env.NODEDC_DEVICE_CORE_TOKEN_FILE || "").trim(); if (!tokenFile) throw new Error("device_core_token_file_required"); const token = (await readFile(tokenFile, "utf8")).trim(); coreClient = createDeviceCoreClient({ baseUrl: env.NODEDC_DEVICE_CORE_INTERNAL_URL, token, }); } const presentationStore = createDeviceManagerPresentationStore({ layoutPath: String(env.NODEDC_DEVICE_MANAGER_PRESENTATION_PATH || resolve(appRoot, "runtime-data/device-manager-presentation.json")), uploadRoot: String(env.NODEDC_DEVICE_MANAGER_MEDIA_ROOT || resolve(appRoot, "runtime-data/device-manager-media")), }); return createDeviceManagerServer({ auth, coreClient, presentationStore }); } async function serveStatic(request, response, url, root) { const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname; const candidate = resolve(root, `.${decodeURIComponent(requestedPath)}`); const normalizedRoot = resolve(root); if (candidate !== normalizedRoot && !candidate.startsWith(`${normalizedRoot}${sep}`)) { return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" }); } let filePath = candidate; let info = await stat(filePath).catch(() => null); if ((!info || !info.isFile()) && !extname(requestedPath)) { filePath = resolve(root, "index.html"); info = await stat(filePath).catch(() => null); } if (!info?.isFile()) { return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" }); } return serveFile(request, response, filePath, filePath.endsWith("index.html") ? "no-store" : "private, max-age=300", info); } async function serveFile(request, response, filePath, cacheControl, existingInfo = null) { const info = existingInfo || await stat(filePath).catch(() => null); if (!info?.isFile()) return sendJson(response, 404, { ok: false, error: "device_manager_media_not_found" }); response.statusCode = 200; response.setHeader("Content-Type", contentType(filePath)); response.setHeader("Cache-Control", cacheControl); response.setHeader("Content-Length", info.size); if (request.method === "HEAD") return response.end(); createReadStream(filePath).pipe(response); } function workspaceProjectRef(pathname) { const match = pathname.match(/^\/api\/device-manager\/projects\/([^/]+)\/workspace$/); if (!match) return null; const projectRef = decodeURIComponent(match[1]); return /^project:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(projectRef) ? projectRef.toLowerCase() : null; } async function readJsonBody(request, maxBytes) { const bytes = await readBody(request, maxBytes); try { const body = JSON.parse(bytes.toString("utf8") || "{}"); if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error(); return body; } catch { throw serviceError("device_manager_json_invalid", 400); } } async function readBody(request, maxBytes) { let size = 0; const chunks = []; for await (const chunk of request) { size += chunk.length; if (size > maxBytes) throw serviceError("device_manager_request_too_large", 413); chunks.push(chunk); } return Buffer.concat(chunks); } function validProjectRef(value) { const normalized = String(value || "").trim().toLowerCase(); if (!/^project:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) { throw serviceError("device_project_ref_invalid", 400); } return normalized; } async function requireProjectManage(coreClient, actor, projectRef) { const workspace = await coreClient.getWorkspace(actor, projectRef); if (!workspace.project.access.capabilities.includes("project.manage")) { throw serviceError("device_project_capability_denied", 403); } } function requireSuperAdmin(actor) { const groups = new Set(actor.groupRefs || []); if (!groups.has("group:nodedc:superadmin") && !groups.has("nodedc:superadmin")) { throw serviceError("device_environment_settings_denied", 403); } } function singleHeader(value) { if (Array.isArray(value) || typeof value !== "string") { throw serviceError("device_idempotency_key_invalid", 400); } const normalized = value.trim(); if (!/^[\x21-\x7e]{8,256}$/.test(normalized)) { throw serviceError("device_idempotency_key_invalid", 400); } return normalized; } function singleOptionalHeader(value) { if (value == null) return ""; if (Array.isArray(value) || typeof value !== "string") throw serviceError("device_manager_header_invalid", 400); return value.trim(); } function sendJson(response, statusCode, body) { response.statusCode = statusCode; response.setHeader("Content-Type", "application/json; charset=utf-8"); response.setHeader("Cache-Control", "no-store"); response.end(JSON.stringify(body)); } function contentType(pathname) { return ({ ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif", ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".ico": "image/x-icon", })[extname(pathname).toLowerCase()] || "application/octet-stream"; } function normalizeStatus(value) { const status = Number(value || 500); return Number.isInteger(status) && status >= 400 && status < 600 ? status : 500; } function safeError(error) { const value = String(error?.message || ""); return /^(?:device|nodedc)_[a-z0-9._:-]{2,160}$/.test(value) ? value : "device_manager_internal_error"; } function booleanValue(value, fallback) { if (value == null || value === "") return fallback; return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); } function serviceError(code, statusCode) { const error = new Error(code); error.statusCode = statusCode; return error; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const server = await createConfiguredDeviceManagerServer(); const port = Number.parseInt(process.env.PORT || "3335", 10); const host = String(process.env.HOST || "127.0.0.1"); server.listen(port, host, () => { console.log(JSON.stringify({ event: "device_manager_started", host, port, })); }); }