feat(device-manager): add standalone project workspace
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user