233 lines
8.0 KiB
JavaScript
233 lines
8.0 KiB
JavaScript
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;
|
|
}
|