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"], ["devices:claim", "/internal/v1/management/devices:claim"], ]); 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() { const ownerScopes = new Map(); const projects = new Map(); const collections = new Map(); function projectSummary(project) { const projectCollections = [...collections.values()] .filter((collection) => collection.projectRef === project.projectRef); return { ...project, counts: { devices: 0, collections: projectCollections.length, discoveries: 0 }, }; } return { configured: true, async listProjects() { return [...projects.values()].map(projectSummary); }, async getWorkspace(_actor, projectRef) { const project = projects.get(projectRef); if (!project) throw serviceError("device_project_not_found", 404); return { project: projectSummary(project), devices: [], discoveries: [], enrollments: [], collections: [...collections.values()] .filter((collection) => collection.projectRef === projectRef) .map(({ projectRef: _projectRef, ...collection }) => collection), }; }, async execute(command, actor, input) { 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); 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); return { replayed: false, result: { created: !existing, collection } }; } if (command === "devices:claim") { throw serviceError("device_discovery_not_found", 404); } throw serviceError("device_manager_command_invalid", 404); }, snapshot() { return { ownerScopes, projects, collections }; }, }; } 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; }