diff --git a/apps/device-manager/README.md b/apps/device-manager/README.md
new file mode 100644
index 0000000..8f81539
--- /dev/null
+++ b/apps/device-manager/README.md
@@ -0,0 +1,50 @@
+# 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 only. Digests and credential references stay
+ inside Device Control Core.
+
+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
new file mode 100644
index 0000000..30163c1
--- /dev/null
+++ b/apps/device-manager/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ NODE.DC Device Core
+
+
+
+
+
+
diff --git a/apps/device-manager/package.json b/apps/device-manager/package.json
new file mode 100644
index 0000000..84885dd
--- /dev/null
+++ b/apps/device-manager/package.json
@@ -0,0 +1,26 @@
+{
+ "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/server/device-core-client.mjs b/apps/device-manager/server/device-core-client.mjs
new file mode 100644
index 0000000..cc94cb6
--- /dev/null
+++ b/apps/device-manager/server/device-core-client.mjs
@@ -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;
+}
diff --git a/apps/device-manager/server/device-core-client.test.mjs b/apps/device-manager/server/device-core-client.test.mjs
new file mode 100644
index 0000000..d71c0c7
--- /dev/null
+++ b/apps/device-manager/server/device-core-client.test.mjs
@@ -0,0 +1,103 @@
+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" }],
+});
+
+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 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.deepEqual(workspace.devices, []);
+});
+
+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
new file mode 100644
index 0000000..3b46aa2
--- /dev/null
+++ b/apps/device-manager/server/device-manager-auth.mjs
@@ -0,0 +1,403 @@
+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,
+} = {}) {
+ 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(
+ 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,
+ 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,
+ 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.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)) return redirect(response, loginUrl.toString());
+ sendJson(response, 401, {
+ ok: false,
+ error: "device_manager_auth_required",
+ loginUrl: loginUrl.toString(),
+ });
+ return true;
+ }
+ const access = resolveAccess(session.user);
+ 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 access = request.nodedcDeviceManagerAccess ?? resolveAccess(user);
+ 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}`;
+ const ownerScopes = ["admin", "owner"].includes(access.hubRole)
+ ? [{ scopeKind: "personal", ownerRef: userRef, displayName }]
+ : [];
+ 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,
+ },
+ 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,
+ serviceSlug,
+ authorize,
+ currentContext,
+ handleHandoff,
+ handleLogout,
+ };
+}
+
+function resolveAccess(user) {
+ if (!user || typeof user !== "object") {
+ return { allowed: false, blocked: false, hubRole: "viewer", groups: [] };
+ }
+ const groups = normalizedGroups(user);
+ if (groups.includes("nodedc:device-core:blocked")) {
+ return { allowed: false, blocked: true, hubRole: "viewer", groups };
+ }
+ const id = cleanOpaque(user.id || user.subject || user.sub);
+ if (!id) return { allowed: false, blocked: false, hubRole: "viewer", 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";
+ return { allowed: true, blocked: false, hubRole, groups };
+}
+
+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
new file mode 100644
index 0000000..f22434c
--- /dev/null
+++ b/apps/device-manager/server/device-manager-auth.test.mjs
@@ -0,0 +1,127 @@
+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,
+ 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: "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,
+ 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);
+ }
+});
+
+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
new file mode 100644
index 0000000..0a5f8c7
--- /dev/null
+++ b/apps/device-manager/server/device-manager-server.mjs
@@ -0,0 +1,244 @@
+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/devices:claim", "devices:claim"],
+]);
+
+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) {
+ 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 auth = createDeviceManagerAuth({ env });
+ let coreClient;
+ if (localPreview) {
+ if (String(env.NODE_ENV || "").toLowerCase() === "production") {
+ throw new Error("device_manager_local_preview_forbidden");
+ }
+ coreClient = createLocalPreviewDeviceCore();
+ } 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
new file mode 100644
index 0000000..8cee497
--- /dev/null
+++ b/apps/device-manager/server/device-manager-server.test.mjs
@@ -0,0 +1,94 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { createLocalPreviewDeviceCore } from "./device-core-client.mjs";
+import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
+import { createDeviceManagerServer } from "./device-manager-server.mjs";
+
+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,
+ });
+ 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.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/DeviceManagerApp.tsx b/apps/device-manager/src/DeviceManagerApp.tsx
new file mode 100644
index 0000000..d6babc8
--- /dev/null
+++ b/apps/device-manager/src/DeviceManagerApp.tsx
@@ -0,0 +1,630 @@
+import { useEffect, useMemo, useState, type FormEvent } from "react";
+import {
+ AdminNavigationPanel,
+ AppHeader,
+ ApplicationPanel,
+ ApplicationShell,
+ Button,
+ GlassSurface,
+ HeaderNavigation,
+ HeaderProfile,
+ HeaderWorkspace,
+ Icon,
+ Select,
+ SettingsCard,
+ StatusBadge,
+ TextAreaField,
+ TextField,
+ UserProfileMenu,
+ Window,
+ WindowFooterActions,
+ useApplicationWorkspace,
+} from "@nodedc/ui-react";
+import {
+ claimDevice,
+ ensureCollection,
+ ensureOwnerScope,
+ ensureProject,
+ loadProjects,
+ loadSession,
+ loadWorkspace,
+} from "./api";
+import type {
+ DeviceManagerSession,
+ EnrollmentView,
+ OwnerScopeClaim,
+ ProjectSummary,
+ ProjectWorkspace,
+} from "./types";
+
+type ViewId = "overview" | "inventory" | "discovery" | "collections";
+
+const navigationItems = [
+ { id: "overview", label: "Обзор", icon: "grid" },
+ { id: "inventory", label: "Устройства", icon: "apps" },
+ { id: "discovery", label: "Подключение", icon: "network" },
+ { id: "collections", label: "Коллекции", icon: "folder" },
+] as const;
+
+export function DeviceManagerApp() {
+ const shell = useApplicationWorkspace({ navigationOpen: true });
+ const [session, setSession] = useState(null);
+ const [projects, setProjects] = useState([]);
+ const [workspace, setWorkspace] = useState(null);
+ const [activeOwnerRef, setActiveOwnerRef] = useState("");
+ const [activeProjectRef, setActiveProjectRef] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [projectDialogOpen, setProjectDialogOpen] = useState(false);
+ const [collectionDialogOpen, setCollectionDialogOpen] = useState(false);
+ const [claimEnrollment, setClaimEnrollment] = useState(null);
+
+ const refreshProjects = async () => {
+ const next = await loadProjects();
+ setProjects(next);
+ setActiveProjectRef((current) =>
+ next.some((project) => project.projectRef === current)
+ ? current
+ : next[0]?.projectRef ?? ""
+ );
+ return next;
+ };
+
+ useEffect(() => {
+ let active = true;
+ Promise.all([loadSession(), loadProjects()])
+ .then(([nextSession, nextProjects]) => {
+ if (!active) return;
+ setSession(nextSession);
+ setProjects(nextProjects);
+ const ownerRef = nextProjects[0]?.ownerScope.ownerRef
+ || nextSession.actor.ownerScopes[0]?.ownerRef
+ || "";
+ setActiveOwnerRef(ownerRef);
+ setActiveProjectRef(nextProjects[0]?.projectRef ?? "");
+ })
+ .catch((reason) => active && setError(errorText(reason)))
+ .finally(() => active && setLoading(false));
+ return () => { active = false; };
+ }, []);
+
+ useEffect(() => {
+ if (!activeProjectRef) {
+ setWorkspace(null);
+ return;
+ }
+ let active = true;
+ loadWorkspace(activeProjectRef)
+ .then((next) => active && setWorkspace(next))
+ .catch((reason) => active && setError(errorText(reason)));
+ return () => { active = false; };
+ }, [activeProjectRef]);
+
+ const ownerScopes = useMemo(
+ () => mergeOwnerScopes(session?.actor.ownerScopes ?? [], projects),
+ [projects, session],
+ );
+ const visibleProjects = useMemo(
+ () => projects.filter((project) => !activeOwnerRef || project.ownerScope.ownerRef === activeOwnerRef),
+ [activeOwnerRef, projects],
+ );
+ const activeProject = projects.find((project) => project.projectRef === activeProjectRef) ?? null;
+ const capabilities = new Set(activeProject?.access.capabilities ?? []);
+ const canCreateProject = Boolean(
+ session?.actor.ownerScopes.some((scope) => scope.ownerRef === activeOwnerRef),
+ );
+ const canManageCollections = capabilities.has("collection.manage");
+ const canClaim = capabilities.has("device.claim");
+
+ useEffect(() => {
+ if (!activeOwnerRef && ownerScopes[0]) setActiveOwnerRef(ownerScopes[0].ownerRef);
+ }, [activeOwnerRef, ownerScopes]);
+
+ useEffect(() => {
+ if (
+ activeProjectRef
+ && !visibleProjects.some((project) => project.projectRef === activeProjectRef)
+ ) {
+ setActiveProjectRef(visibleProjects[0]?.projectRef ?? "");
+ setWorkspace(null);
+ }
+ }, [activeOwnerRef, activeProjectRef, visibleProjects]);
+
+ const openProject = (projectRef: string, view: ViewId = "overview") => {
+ setActiveProjectRef(projectRef);
+ shell.openView(view);
+ };
+
+ const refreshWorkspace = async () => {
+ if (!activeProjectRef) return;
+ const next = await loadWorkspace(activeProjectRef);
+ setWorkspace(next);
+ await refreshProjects();
+ };
+
+ if (loading || !session) {
+ return Подключаем Device Core…
;
+ }
+
+ const activeView = shell.activeView ?? "overview";
+ return (
+ <>
+ NODE.DC}
+ brandHref="/"
+ left={
+
+
+
+ }
+ center={
+ shell.openNavigation()}
+ />
+ }
+ right={
+
+
+
+ }
+ />
+ }
+ stage={
+ setProjectDialogOpen(true)}
+ onOpenProject={openProject}
+ error={error}
+ onDismissError={() => setError(null)}
+ />
+ }
+ navigationOpen={shell.navigationOpen}
+ navigation={
+ ,
+ }] : []}
+ contextSlot={
+ ownerScopes.length ? (
+ ({
+ value: scope.ownerRef,
+ label: scope.displayName,
+ description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
+ }))}
+ onChange={setActiveOwnerRef}
+ searchable={ownerScopes.length > 6}
+ />
+ ) : null
+ }
+ items={activeProject ? navigationItems.map((item) => ({
+ id: item.id,
+ label: item.label,
+ icon: ,
+ })) : []}
+ activeId={shell.activeView ?? undefined}
+ footer={{session.actor.hubRole} · {projects.length} проектов }
+ onClose={shell.closeNavigation}
+ onItemChange={(id) => shell.openView(id as ViewId)}
+ />
+ }
+ contentOpen={shell.contentOpen}
+ contentExpanded={shell.contentExpanded}
+ content={
+ activeProject ? (
+ refreshWorkspace().catch((reason) => setError(errorText(reason))),
+ }]}
+ >
+ setCollectionDialogOpen(true)}
+ onClaim={setClaimEnrollment}
+ />
+
+ ) : null
+ }
+ />
+
+ scope.ownerRef === activeOwnerRef) ?? null}
+ onClose={() => setProjectDialogOpen(false)}
+ onCreated={async () => {
+ setProjectDialogOpen(false);
+ await refreshProjects();
+ }}
+ onError={(reason) => setError(errorText(reason))}
+ />
+ setCollectionDialogOpen(false)}
+ onCreated={async () => {
+ setCollectionDialogOpen(false);
+ await refreshWorkspace();
+ }}
+ onError={(reason) => setError(errorText(reason))}
+ />
+ setClaimEnrollment(null)}
+ onClaimed={async () => {
+ setClaimEnrollment(null);
+ await refreshWorkspace();
+ }}
+ onError={(reason) => setError(errorText(reason))}
+ />
+ >
+ );
+}
+
+function DeviceStage({
+ ownerScopes,
+ activeOwnerRef,
+ onOwnerChange,
+ projects,
+ canCreateProject,
+ onCreateProject,
+ onOpenProject,
+ error,
+ onDismissError,
+}: {
+ ownerScopes: OwnerScopeClaim[];
+ activeOwnerRef: string;
+ onOwnerChange: (value: string) => void;
+ projects: ProjectSummary[];
+ canCreateProject: boolean;
+ onCreateProject: () => void;
+ onOpenProject: (projectRef: string, view?: ViewId) => void;
+ error: string | null;
+ onDismissError: () => void;
+}) {
+ const totals = projects.reduce(
+ (sum, project) => ({
+ devices: sum.devices + project.counts.devices,
+ collections: sum.collections + project.counts.collections,
+ discoveries: sum.discoveries + project.counts.discoveries,
+ }),
+ { devices: 0, collections: 0, discoveries: 0 },
+ );
+ return (
+
+
+
+
UNIVERSAL DEVICE PLANE
+
Все устройства — в одном управляемом контуре.
+
Проекты, безопасное подключение, инвентарь и коллекции без привязки интерфейса к конкретному производителю.
+
+
+ {ownerScopes.length ? (
+ ({
+ value: scope.ownerRef,
+ label: scope.displayName,
+ description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
+ }))}
+ onChange={onOwnerChange}
+ />
+ ) : null}
+ } disabled={!canCreateProject} onClick={onCreateProject}>
+ Новый проект
+
+
+
+
+ {error ? (
+
+
+ {error}
+ Закрыть
+
+ ) : null}
+
+
+
+
+
+
DEVICE PROJECTS
Рабочие проекты
+
Доступ определяется Hub и project grants
+
+ {projects.length ? (
+
+ {projects.map((project) => (
+ onOpenProject(project.projectRef)}>
+
+
+ {project.ownerScope.displayName}
+ {project.name}
+ {project.description || "Универсальный проект устройств"}
+
+
+ {project.counts.devices} устройств
+ {project.counts.collections} коллекций
+
+
+ {project.access.projectRole || "read"}
+
+
+ ))}
+
+ ) : (
+
+
+ В этом контуре пока нет проектов
+ Создание доступно только в owner scope, подтверждённом Hub.
+
+ )}
+
+
+ );
+}
+
+function Metric({ label, value, detail, tone = "neutral" }: { label: string; value: number; detail: string; tone?: "neutral" | "success" | "warning" }) {
+ return (
+
+ {label}
+ {value}
+ {detail}
+
+ );
+}
+
+function ProjectView({ view, workspace, canManageCollections, canClaim, onCreateCollection, onClaim }: {
+ view: ViewId;
+ workspace: ProjectWorkspace | null;
+ canManageCollections: boolean;
+ canClaim: boolean;
+ onCreateCollection: () => void;
+ onClaim: (enrollment: EnrollmentView) => void;
+}) {
+ if (!workspace) return Загружаем проект…
;
+ if (view === "inventory") return (
+ ({
+ id: device.deviceRef,
+ title: device.displayName,
+ subtitle: `${device.modelProfileRef} · ${device.identifier?.masked ?? "идентификатор не назначен"}`,
+ status: device.session?.state || device.lifecycleState,
+ tone: device.session?.state === "online" ? "success" : "neutral",
+ }))}
+ />
+ );
+ if (view === "discovery") return (
+
+ {workspace.enrollments.map((enrollment) => (
+
onClaim(enrollment)}>
+ Принять устройство
+
+ ) : {enrollment.lifecycleState} }
+ >
+ Enrollment и quarantine evidence остаются связанными; raw identifier в интерфейс не передаётся.
+
+ ))}
+ {!workspace.enrollments.length ?
Нет ожидающих подключений.
: null}
+
+ );
+ if (view === "collections") return (
+
+
+
Коллекции группируют устройства для последующих bindings и задач.
+
} disabled={!canManageCollections} onClick={onCreateCollection}>Новая коллекция
+
+
({
+ id: collection.collectionRef,
+ title: collection.name,
+ subtitle: collection.description || collection.collectionKey,
+ status: `${collection.memberCount} устройств`,
+ tone: "neutral",
+ }))}
+ />
+
+ );
+ return (
+
+
+
item.lifecycleState === "quarantine").length} detail="safe projection" />
+
+
+
+ {workspace.project.access.capabilities.map((capability) => {capability} )}
+
+
+
+ );
+}
+
+function EntityList({ items, empty }: { items: Array<{ id: string; title: string; subtitle: string; status: string; tone: "neutral" | "success" | "warning" }>; empty: string }) {
+ if (!items.length) return {empty}
;
+ return {items.map((item) => (
+
+
+ {item.title} {item.subtitle}
+ {item.status}
+
+ ))}
;
+}
+
+function ProjectDialog({ open, ownerScope, onClose, onCreated, onError }: {
+ open: boolean;
+ ownerScope: OwnerScopeClaim | 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 (!ownerScope) return;
+ setPending(true);
+ try {
+ await ensureOwnerScope(ownerScope);
+ await ensureProject({
+ scopeKind: ownerScope.scopeKind,
+ ownerRef: ownerScope.ownerRef,
+ projectKey: key,
+ name,
+ description: description.trim() || null,
+ });
+ setName(""); setKey(""); setDescription("");
+ await onCreated();
+ } catch (reason) { onError(reason); } finally { setPending(false); }
+ };
+ return Отмена {pending ? "Создаём…" : "Создать"}
+ }>
+
+ ;
+}
+
+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 Отмена {pending ? "Сохраняем…" : "Создать"}
+ }>
+
+ ;
+}
+
+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 Отмена {pending ? "Проверяем…" : "Принять"}
+ }>
+
+ ;
+}
+
+function mergeOwnerScopes(claims: OwnerScopeClaim[], projects: ProjectSummary[]) {
+ const scopes = new Map(claims.map((scope) => [scope.ownerRef, scope]));
+ for (const project of projects) {
+ if (!scopes.has(project.ownerScope.ownerRef)) {
+ scopes.set(project.ownerScope.ownerRef, {
+ scopeKind: project.ownerScope.scopeKind,
+ ownerRef: project.ownerScope.ownerRef,
+ displayName: project.ownerScope.displayName,
+ });
+ }
+ }
+ return [...scopes.values()];
+}
+
+function viewTitle(view: ViewId) {
+ return ({ overview: "Обзор проекта", inventory: "Устройства", discovery: "Подключение", collections: "Коллекции" })[view];
+}
+
+function errorText(reason: unknown) {
+ const value = reason instanceof Error ? reason.message : String(reason || "device_manager_error");
+ const labels: Record = {
+ device_project_capability_denied: "Недостаточно прав в выбранном проекте.",
+ device_owner_scope_access_denied: "Hub не подтвердил право управлять этим контуром.",
+ device_manager_auth_unavailable: "Проверка Hub-сессии временно недоступна.",
+ };
+ return labels[value] || value;
+}
diff --git a/apps/device-manager/src/api.ts b/apps/device-manager/src/api.ts
new file mode 100644
index 0000000..f9ded11
--- /dev/null
+++ b/apps/device-manager/src/api.ts
@@ -0,0 +1,87 @@
+import type {
+ DeviceManagerSession,
+ ProjectSummary,
+ ProjectWorkspace,
+ ScopeKind,
+} from "./types";
+
+export async function loadSession(): Promise {
+ return requestJson<{ ok: true; session: DeviceManagerSession }>(
+ "/api/device-manager/session",
+ ).then((value) => value.session);
+}
+
+export async function loadProjects(): Promise {
+ return requestJson<{ ok: true; projects: ProjectSummary[] }>(
+ "/api/device-manager/projects",
+ ).then((value) => value.projects);
+}
+
+export async function loadWorkspace(projectRef: string): Promise {
+ return requestJson<{ ok: true; workspace: ProjectWorkspace }>(
+ `/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
+ ).then((value) => value.workspace);
+}
+
+export async function ensureOwnerScope(input: {
+ scopeKind: ScopeKind;
+ ownerRef: string;
+ displayName: string;
+}) {
+ return mutate("/api/device-manager/owner-scopes:ensure", input);
+}
+
+export async function ensureProject(input: {
+ scopeKind: ScopeKind;
+ ownerRef: string;
+ projectKey: string;
+ name: string;
+ description: string | null;
+}) {
+ return mutate("/api/device-manager/projects:ensure", input);
+}
+
+export async function ensureCollection(input: {
+ projectRef: string;
+ collectionKey: string;
+ name: string;
+ description: string | null;
+}) {
+ return mutate("/api/device-manager/collections:ensure", input);
+}
+
+export async function claimDevice(input: {
+ projectRef: string;
+ enrollmentIntentRef: string;
+ discoveryRef: string;
+ deviceKey: string;
+ displayName: string;
+}) {
+ return mutate("/api/device-manager/devices:claim", input);
+}
+
+async function mutate(path: string, input: unknown) {
+ return requestJson<{ ok: true; replayed: boolean; result: unknown }>(path, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": `device-manager-${crypto.randomUUID()}`,
+ },
+ body: JSON.stringify(input),
+ });
+}
+
+async function requestJson(path: string, init?: RequestInit): Promise {
+ const response = await fetch(path, {
+ credentials: "same-origin",
+ headers: { Accept: "application/json", ...(init?.headers ?? {}) },
+ ...init,
+ });
+ const body = await response.json().catch(() => null);
+ if (!response.ok || !body?.ok) {
+ const error = new Error(body?.error || `device_manager_request_failed:${response.status}`);
+ Object.assign(error, { status: response.status });
+ throw error;
+ }
+ return body as T;
+}
diff --git a/apps/device-manager/src/main.tsx b/apps/device-manager/src/main.tsx
new file mode 100644
index 0000000..c7e1457
--- /dev/null
+++ b/apps/device-manager/src/main.tsx
@@ -0,0 +1,17 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { applyNodedcTheme } from "@nodedc/ui-core";
+import "@nodedc/ui-core/styles.css";
+import "./styles.css";
+import { DeviceManagerApp } from "./DeviceManagerApp";
+
+applyNodedcTheme(document.documentElement, {
+ theme: "dark",
+ accent: [185, 255, 74],
+});
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/apps/device-manager/src/styles.css b/apps/device-manager/src/styles.css
new file mode 100644
index 0000000..45975a9
--- /dev/null
+++ b/apps/device-manager/src/styles.css
@@ -0,0 +1,418 @@
+:root {
+ color-scheme: dark;
+ background: #0b0d0f;
+}
+
+html,
+body,
+#root {
+ width: 100%;
+ min-width: 320px;
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 76% 18%, rgb(185 255 74 / 0.09), transparent 34%),
+ radial-gradient(circle at 12% 84%, rgb(79 127 255 / 0.08), transparent 31%),
+ #0b0d0f;
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+}
+
+.device-manager-boot {
+ display: grid;
+ height: 100%;
+ place-items: center;
+ color: var(--nodedc-text-secondary);
+ font: 500 0.9rem/1.4 var(--nodedc-font-family);
+}
+
+.device-manager-brand {
+ color: var(--nodedc-text-primary);
+ font-size: 0.86rem;
+ font-weight: 800;
+ letter-spacing: 0.15em;
+}
+
+.device-manager-workspace-trigger {
+ display: inline-grid;
+ border: 0;
+ background: transparent;
+ padding: 0;
+ cursor: pointer;
+}
+
+.device-manager-stage {
+ height: 100%;
+ overflow: auto;
+ padding: clamp(2.25rem, 5vw, 5.5rem);
+ scrollbar-width: thin;
+}
+
+.device-manager-hero {
+ display: flex;
+ max-width: 1420px;
+ align-items: end;
+ justify-content: space-between;
+ gap: 2rem;
+ margin: 0 auto 2.5rem;
+}
+
+.device-manager-hero > div:first-child {
+ max-width: 780px;
+}
+
+.device-manager-hero span,
+.device-manager-section-title span {
+ color: rgb(var(--nodedc-accent-rgb));
+ font-size: 0.68rem;
+ font-weight: 760;
+ letter-spacing: 0.16em;
+}
+
+.device-manager-hero h1 {
+ max-width: 760px;
+ margin: 0.7rem 0 0.9rem;
+ color: var(--nodedc-text-primary);
+ font-size: clamp(2.25rem, 5vw, 5.4rem);
+ font-weight: 520;
+ letter-spacing: -0.065em;
+ line-height: 0.98;
+}
+
+.device-manager-hero p {
+ max-width: 680px;
+ margin: 0;
+ color: var(--nodedc-text-secondary);
+ font-size: clamp(0.96rem, 1.4vw, 1.14rem);
+ line-height: 1.55;
+}
+
+.device-manager-hero__actions {
+ display: flex;
+ min-width: min(100%, 390px);
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+}
+
+.device-manager-hero__actions .nodedc-select-anchor {
+ min-width: 210px;
+}
+
+.device-manager-alert {
+ display: flex;
+ max-width: 1420px;
+ align-items: center;
+ gap: 0.75rem;
+ margin: 0 auto 1.25rem;
+ border-radius: 18px;
+ background: rgb(var(--nodedc-danger-rgb) / 0.11);
+ color: var(--nodedc-text-primary);
+ padding: 0.7rem 0.8rem 0.7rem 1rem;
+}
+
+.device-manager-alert span {
+ flex: 1;
+}
+
+.device-manager-metrics {
+ display: grid;
+ max-width: 1420px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 1rem;
+ margin: 0 auto 3.5rem;
+}
+
+.device-manager-metric {
+ display: grid;
+ min-height: 142px;
+ align-content: space-between;
+ gap: 0.4rem;
+}
+
+.device-manager-metric > span:first-child {
+ color: var(--nodedc-text-secondary);
+ font-size: 0.78rem;
+}
+
+.device-manager-metric > strong {
+ color: var(--nodedc-text-primary);
+ font-size: clamp(2rem, 3vw, 3.25rem);
+ font-weight: 520;
+ letter-spacing: -0.05em;
+}
+
+.device-manager-metric .nodedc-status {
+ width: max-content;
+}
+
+.device-manager-projects {
+ max-width: 1420px;
+ margin: 0 auto;
+}
+
+.device-manager-section-title {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1.15rem;
+}
+
+.device-manager-section-title h2 {
+ margin: 0.35rem 0 0;
+ font-size: 1.45rem;
+ font-weight: 560;
+}
+
+.device-manager-section-title small {
+ color: var(--nodedc-text-muted);
+}
+
+.device-manager-project-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
+ gap: 1rem;
+}
+
+.device-project-card {
+ display: grid;
+ min-height: 210px;
+ grid-template-columns: auto 1fr auto;
+ grid-template-rows: 1fr auto;
+ gap: 1rem;
+ border: 0;
+ border-radius: var(--nodedc-radius-card);
+ background: var(--nodedc-glass-panel-bg-soft);
+ color: var(--nodedc-text-primary);
+ padding: 1.25rem;
+ text-align: left;
+ box-shadow: var(--nodedc-glass-panel-shadow);
+ cursor: pointer;
+ transition: background 160ms ease, transform 160ms ease;
+}
+
+.device-project-card:hover {
+ background: var(--nodedc-glass-control-hover);
+ transform: translateY(-2px);
+}
+
+.device-project-card__icon,
+.device-manager-entity__icon {
+ display: grid;
+ width: 44px;
+ height: 44px;
+ place-items: center;
+ border-radius: 50%;
+ background: rgb(var(--nodedc-accent-rgb) / 0.12);
+ color: rgb(var(--nodedc-accent-rgb));
+}
+
+.device-project-card__body {
+ display: grid;
+ align-content: start;
+ gap: 0.35rem;
+}
+
+.device-project-card__body small,
+.device-project-card__body span,
+.device-project-card__stats {
+ color: var(--nodedc-text-secondary);
+ font-size: 0.78rem;
+ line-height: 1.45;
+}
+
+.device-project-card__body strong {
+ font-size: 1.05rem;
+}
+
+.device-project-card__stats {
+ display: flex;
+ grid-column: 1 / 3;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.device-project-card__stats b {
+ color: var(--nodedc-text-primary);
+ font-size: 0.95rem;
+}
+
+.device-project-card > .nodedc-status {
+ grid-column: 3;
+ align-self: end;
+}
+
+.device-manager-empty {
+ display: grid;
+ min-height: 220px;
+ place-items: center;
+ align-content: center;
+ gap: 0.65rem;
+ text-align: center;
+}
+
+.device-manager-empty h3,
+.device-manager-empty p {
+ margin: 0;
+}
+
+.device-manager-empty p,
+.device-manager-card-copy {
+ color: var(--nodedc-text-secondary);
+ font-size: 0.82rem;
+ line-height: 1.55;
+}
+
+.device-manager-panel-empty {
+ display: grid;
+ min-height: 220px;
+ place-items: center;
+ color: var(--nodedc-text-secondary);
+ text-align: center;
+}
+
+.device-manager-stack,
+.device-manager-entity-list {
+ display: grid;
+ gap: 0.85rem;
+}
+
+.device-manager-panel-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 0.4rem;
+}
+
+.device-manager-panel-toolbar p {
+ margin: 0;
+ color: var(--nodedc-text-secondary);
+ font-size: 0.82rem;
+}
+
+.device-manager-entity {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+}
+
+.device-manager-entity__icon {
+ width: 34px;
+ height: 34px;
+}
+
+.device-manager-entity__body {
+ display: grid;
+ flex: 1;
+ gap: 0.18rem;
+}
+
+.device-manager-entity__body small {
+ overflow: hidden;
+ color: var(--nodedc-text-secondary);
+ font-size: 0.74rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.device-manager-overview-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.9rem;
+}
+
+.device-manager-overview-grid .nodedc-settings-card {
+ grid-column: 1 / -1;
+}
+
+.device-manager-capabilities {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+
+.device-manager-capabilities span {
+ border-radius: 999px;
+ background: var(--nodedc-glass-control-bg);
+ color: var(--nodedc-text-secondary);
+ padding: 0.45rem 0.7rem;
+ font-size: 0.72rem;
+}
+
+.device-manager-form {
+ display: grid;
+ gap: 1rem;
+}
+
+.device-manager-form textarea {
+ min-height: 110px;
+ resize: vertical;
+}
+
+@media (max-width: 1100px) {
+ .device-manager-hero {
+ align-items: start;
+ flex-direction: column;
+ }
+
+ .device-manager-hero__actions {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .device-manager-metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 760px) {
+ body { overflow: auto; }
+
+ .device-manager-stage {
+ padding: 2rem 1rem 4rem;
+ }
+
+ .device-manager-hero h1 {
+ font-size: clamp(2.25rem, 14vw, 3.5rem);
+ }
+
+ .device-manager-hero__actions,
+ .device-manager-panel-toolbar,
+ .device-manager-section-title {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .device-manager-hero__actions > *,
+ .device-manager-hero__actions .nodedc-select-anchor {
+ width: 100%;
+ }
+
+ .device-manager-metrics,
+ .device-manager-overview-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .device-manager-overview-grid .nodedc-settings-card {
+ grid-column: auto;
+ }
+
+ .device-project-card {
+ grid-template-columns: auto 1fr;
+ }
+
+ .device-project-card > .nodedc-status {
+ grid-column: 2;
+ justify-self: end;
+ }
+}
diff --git a/apps/device-manager/src/types.ts b/apps/device-manager/src/types.ts
new file mode 100644
index 0000000..b86ed62
--- /dev/null
+++ b/apps/device-manager/src/types.ts
@@ -0,0 +1,103 @@
+export type HubRole = "viewer" | "member" | "admin" | "owner";
+export type ScopeKind = "company" | "personal";
+
+export interface OwnerScopeClaim {
+ scopeKind: ScopeKind;
+ ownerRef: string;
+ displayName: string;
+}
+
+export interface DeviceManagerSession {
+ user: {
+ id: string;
+ email: string;
+ displayName: string;
+ avatarUrl: string | null;
+ initials: string;
+ };
+ actor: {
+ userRef: string;
+ hubRole: HubRole;
+ groupRefs: string[];
+ ownerScopes: OwnerScopeClaim[];
+ };
+ profileUrl: string;
+}
+
+export interface ProjectSummary {
+ projectRef: string;
+ projectKey: string;
+ name: string;
+ description: string | null;
+ lifecycleState: string;
+ ownerScope: {
+ ownerScopeRef: string;
+ scopeKind: ScopeKind;
+ ownerRef: string;
+ displayName: string;
+ };
+ access: {
+ projectRole: string | null;
+ capabilities: string[];
+ };
+ counts: { devices: number; collections: number; discoveries: number };
+ createdAt: string | null;
+ updatedAt: string | null;
+}
+
+export interface DeviceView {
+ deviceRef: string;
+ deviceKey: string | null;
+ displayName: string;
+ modelProfileRef: string;
+ lifecycleState: string;
+ identifier: { kind: string; masked: string } | null;
+ session: { state: string; lastSeenAt: string | null } | null;
+ createdAt: string | null;
+ updatedAt: string | null;
+}
+
+export interface CollectionView {
+ collectionRef: string;
+ collectionKey: string;
+ name: string;
+ description: string | null;
+ lifecycleState: string;
+ memberCount: number;
+ createdAt: string | null;
+ updatedAt: string | null;
+}
+
+export interface DiscoveryView {
+ discoveryRef: string;
+ identifier: { kind: string; masked: string };
+ modelProfileRef: string;
+ protocol: string;
+ lifecycleState: string;
+ enrollmentIntentRef: string | null;
+ claimedDeviceRef: string | null;
+ firstObservedAt: string | null;
+ lastObservedAt: string | null;
+}
+
+export interface EnrollmentView {
+ enrollmentIntentRef: string;
+ enrollmentKey: string;
+ displayName: string;
+ modelProfileRef: string;
+ expectedIdentifier: { kind: string; masked: string };
+ lifecycleState: string;
+ observedDiscoveryRef: string | null;
+ claimedDeviceRef: string | null;
+ expiresAt: string | null;
+ createdAt: string | null;
+ updatedAt: string | null;
+}
+
+export interface ProjectWorkspace {
+ project: ProjectSummary;
+ devices: DeviceView[];
+ collections: CollectionView[];
+ discoveries: DiscoveryView[];
+ enrollments: EnrollmentView[];
+}
diff --git a/apps/device-manager/tsconfig.json b/apps/device-manager/tsconfig.json
new file mode 100644
index 0000000..fd471c7
--- /dev/null
+++ b/apps/device-manager/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/apps/device-manager/vite.config.ts b/apps/device-manager/vite.config.ts
new file mode 100644
index 0000000..b2f1a56
--- /dev/null
+++ b/apps/device-manager/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ build: { sourcemap: true },
+});
diff --git a/package-lock.json b/package-lock.json
index e58a60b..2c6f06d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -43,6 +43,23 @@
"vite-plugin-static-copy": "^4.1.1"
}
},
+ "apps/device-manager": {
+ "name": "@nodedc/device-manager",
+ "version": "0.1.0",
+ "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"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -921,6 +938,10 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@nodedc/device-manager": {
+ "resolved": "apps/device-manager",
+ "link": true
+ },
"node_modules/@nodedc/map-cesium-react": {
"resolved": "packages/map-cesium-react",
"link": true