feat(device-core): add idempotent project management

This commit is contained in:
Codex
2026-08-10 17:27:07 +03:00
parent 336602c7ca
commit 70bafdd028
10 changed files with 2260 additions and 2 deletions
@@ -1,4 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import { createHash, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import {
@@ -7,12 +7,25 @@ import {
normalizeDiscoverySignal,
toSafeDiscoveryView,
} from "../../../packages/device-protocol-contract/src/index.mjs";
import {
normalizeManagementActor,
normalizeManagementCommand,
} from "./project-management.mjs";
const managementRoutes = new Map([
["/internal/v1/management/owner-scopes:ensure", "owner_scope.ensure"],
["/internal/v1/management/projects:ensure", "project.ensure"],
["/internal/v1/management/collections:ensure", "collection.ensure"],
["/internal/v1/management/project-grants:upsert", "project_grant.upsert"],
]);
export function createControlCoreApp({
repository,
gatewayToken = "",
identifierPepper = "",
discoveryIngestEnabled = false,
managementApiEnabled = false,
managementToken = "",
} = {}) {
if (!repository || typeof repository.health !== "function") {
throw new TypeError("device_repository_required");
@@ -28,6 +41,14 @@ export function createControlCoreApp({
throw new TypeError("device_identifier_pepper_invalid");
}
}
if (managementApiEnabled) {
if (typeof repository.executeManagementCommand !== "function") {
throw new TypeError("device_management_repository_required");
}
if (typeof managementToken !== "string" || managementToken.length < 32) {
throw new TypeError("device_management_token_invalid");
}
}
const server = createServer(async (request, response) => {
response.setHeader("Content-Type", "application/json; charset=utf-8");
@@ -47,10 +68,56 @@ export function createControlCoreApp({
service: "nodedc-device-control-core",
database,
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
managementApi: managementApiEnabled ? "enabled" : "disabled",
commandTransport: "disabled",
});
}
const managementCommandKind = managementRoutes.get(requestUrl.pathname);
if (request.method === "POST" && managementCommandKind) {
if (!managementApiEnabled) {
return writeJson(response, 404, {
ok: false,
error: "device_management_api_disabled",
});
}
if (!matchesBearer(request.headers.authorization, managementToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_management_auth_required",
});
}
const idempotencyKey = normalizeIdempotencyKey(
request.headers["idempotency-key"],
);
const actor = managementActorFromHeaders(request.headers);
const input = await readJsonBody(request, 64 * 1024);
const command = normalizeManagementCommand(managementCommandKind, input);
const requestDigest = managementRequestDigest({
actor,
commandKind: managementCommandKind,
command,
});
const execution = await repository.executeManagementCommand({
idempotencyKey,
commandKind: managementCommandKind,
requestDigest,
actor,
command,
});
response.setHeader("Idempotency-Key", idempotencyKey);
response.setHeader(
"Idempotency-Replayed",
execution.replayed ? "true" : "false",
);
return writeJson(response, 200, {
ok: true,
replayed: execution.replayed,
result: execution.result,
});
}
if (
request.method === "POST"
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
@@ -108,6 +175,62 @@ export function createControlCoreApp({
return server;
}
function managementActorFromHeaders(headers) {
return normalizeManagementActor({
userRef: singleHeader(headers["x-nodedc-user-ref"]),
hubRole: singleHeader(headers["x-nodedc-hub-role"]),
groupRefs: commaSeparatedHeader(headers["x-nodedc-group-refs"]),
ownerScopes: ownerScopeHeader(headers["x-nodedc-owner-scopes"]),
});
}
function ownerScopeHeader(value) {
return commaSeparatedHeader(value).map((claim) => {
const separatorIndex = claim.indexOf("=");
if (separatorIndex < 1 || separatorIndex === claim.length - 1) {
throw new TypeError("device_actor_owner_scopes_invalid");
}
return {
scopeKind: claim.slice(0, separatorIndex),
ownerRef: claim.slice(separatorIndex + 1),
};
});
}
function commaSeparatedHeader(value) {
const header = singleHeader(value, true);
if (!header) return [];
return header.split(",").map((item) => item.trim()).filter(Boolean);
}
function singleHeader(value, optional = false) {
if (Array.isArray(value)) throw new TypeError("device_management_header_invalid");
if (value == null || value === "") {
if (optional) return "";
throw new TypeError("device_management_header_required");
}
if (typeof value !== "string" || value.length > 4096) {
throw new TypeError("device_management_header_invalid");
}
return value.trim();
}
function normalizeIdempotencyKey(value) {
const key = singleHeader(value);
if (!/^[\x21-\x7e]{8,256}$/.test(key)) {
const error = new Error("device_idempotency_key_invalid");
error.statusCode = 400;
throw error;
}
return key;
}
function managementRequestDigest(value) {
return `sha256:${createHash("sha256")
.update(JSON.stringify(value), "utf8")
.digest("hex")}`;
}
function matchesBearer(header, expected) {
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
const actual = Buffer.from(header.slice("Bearer ".length), "utf8");
@@ -6,16 +6,34 @@ import { fileURLToPath } from "node:url";
import pg from "pg";
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
import {
assertActorCanManageOwnerScope,
assertGrantMutationAllowed,
assertProjectCapability,
toProjectRef,
} from "./project-management.mjs";
const { Pool } = pg;
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const migrationFiles = [
"001_device_plane_foundation.sql",
"002_device_project_access.sql",
"003_device_management_commands.sql",
];
export class PostgresDeviceRepository {
constructor({ databaseUrl, poolSize = 10 } = {}) {
constructor({ databaseUrl, poolSize = 10, pool = null } = {}) {
if (pool) {
if (
typeof pool.query !== "function" ||
typeof pool.connect !== "function" ||
typeof pool.end !== "function"
) {
throw new TypeError("device_database_pool_invalid");
}
this.pool = pool;
return;
}
if (typeof databaseUrl !== "string" || databaseUrl.trim() === "") {
throw new TypeError("device_database_url_required");
}
@@ -120,11 +138,650 @@ export class PostgresDeviceRepository {
};
}
async executeManagementCommand({
idempotencyKey,
commandKind,
requestDigest,
actor,
command,
}) {
const client = await this.pool.connect();
try {
await client.query("begin");
const receipt = await claimManagementReceipt(client, {
idempotencyKey,
commandKind,
requestDigest,
actorRef: actor.userRef,
});
if (receipt.replayed) {
await authorizeManagementReplay(client, {
commandKind,
actor,
command,
});
await client.query("commit");
return {
replayed: true,
result: receipt.responseBody,
};
}
const result = await applyManagementCommand(client, {
commandKind,
actor,
command,
});
await completeManagementReceipt(client, receipt.id, result);
await client.query("commit");
return { replayed: false, result };
} catch (error) {
await client.query("rollback").catch(() => undefined);
throw mapPostgresError(error);
} finally {
client.release();
}
}
async close() {
await this.pool.end();
}
}
async function claimManagementReceipt(client, {
idempotencyKey,
commandKind,
requestDigest,
actorRef,
}) {
const id = randomUUID();
const inserted = await client.query(
`insert into device_management_command_receipts (
id,
actor_ref,
command_kind,
idempotency_key,
request_digest
) values ($1, $2, $3, $4, $5)
on conflict (actor_ref, command_kind, idempotency_key) do nothing
returning id`,
[id, actorRef, commandKind, idempotencyKey, requestDigest],
);
if (inserted.rows.length === 1) {
return { id, replayed: false, responseBody: null };
}
const existing = await client.query(
`select id, request_digest, lifecycle_state, response_body
from device_management_command_receipts
where actor_ref = $1
and command_kind = $2
and idempotency_key = $3
for update`,
[actorRef, commandKind, idempotencyKey],
);
const row = existing.rows[0];
if (!row) throw domainError("device_idempotency_receipt_missing", 409);
if (row.request_digest !== requestDigest) {
throw domainError("device_idempotency_key_conflict", 409);
}
if (row.lifecycle_state !== "completed" || !row.response_body) {
throw domainError("device_idempotency_command_in_progress", 409);
}
return {
id: row.id,
replayed: true,
responseBody: row.response_body,
};
}
async function completeManagementReceipt(client, receiptId, result) {
await client.query(
`update device_management_command_receipts
set lifecycle_state = 'completed',
response_status = 200,
response_body = $2::jsonb,
completed_at = now(),
updated_at = now()
where id = $1`,
[receiptId, JSON.stringify(result)],
);
}
async function applyManagementCommand(client, { commandKind, actor, command }) {
if (commandKind === "owner_scope.ensure") {
return ensureOwnerScope(client, actor, command);
}
if (commandKind === "project.ensure") {
return ensureProject(client, actor, command);
}
if (commandKind === "collection.ensure") {
return ensureCollection(client, actor, command);
}
if (commandKind === "project_grant.upsert") {
return upsertProjectGrant(client, actor, command);
}
throw new TypeError("device_management_command_kind_invalid");
}
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
if (commandKind === "owner_scope.ensure") {
assertActorCanManageOwnerScope(actor, command);
const ownerScope = await findOwnerScope(
client,
command.scopeKind,
command.ownerRef,
);
assertOwnerScopeActive(ownerScope);
return;
}
if (commandKind === "project.ensure") {
const ownerScope = await findOwnerScope(
client,
command.scopeKind,
command.ownerRef,
);
assertOwnerScopeActive(ownerScope);
const project = await findProjectByOwnerAndKey(
client,
ownerScope.id,
command.projectKey,
false,
);
assertProjectActive(project);
const grants = await listProjectGrants(client, project.id);
assertProjectCapability(actor, grants, "project.manage");
return;
}
const lockForGrantMutation = commandKind === "project_grant.upsert";
const { project } = await findProjectContext(
client,
command.projectId,
lockForGrantMutation,
);
assertProjectActive(project);
const grants = await listProjectGrants(client, project.id);
if (commandKind === "collection.ensure") {
assertProjectCapability(actor, grants, "collection.manage");
return;
}
if (commandKind === "project_grant.upsert") {
const existing = grants.find(
(grant) =>
grant.principalKind === command.principalKind &&
grant.principalRef === command.principalRef,
) ?? null;
assertGrantMutationAllowed(actor, grants, command, existing);
return;
}
throw new TypeError("device_management_command_kind_invalid");
}
async function ensureOwnerScope(client, actor, command) {
assertActorCanManageOwnerScope(actor, command);
const result = await client.query(
`insert into device_owner_scopes (
id,
scope_kind,
owner_ref,
display_name,
created_by_ref
) values ($1, $2, $3, $4, $5)
on conflict (scope_kind, owner_ref) do update set
display_name = excluded.display_name,
updated_at = now()
returning id, scope_kind, owner_ref, display_name, lifecycle_state,
created_at, updated_at, (xmax = 0) as created`,
[randomUUID(), command.scopeKind, command.ownerRef, command.displayName, actor.userRef],
);
const row = result.rows[0];
assertOwnerScopeActive(row);
await addManagementAudit(client, {
eventType: row.created ? "owner_scope.created" : "owner_scope.updated",
actorRef: actor.userRef,
payload: {
scopeKind: row.scope_kind,
ownerRef: row.owner_ref,
lifecycleState: row.lifecycle_state,
},
});
return {
created: row.created === true,
ownerScope: projectOwnerScopeView(row),
};
}
async function ensureProject(client, actor, command) {
const ownerScope = await findOwnerScope(client, command.scopeKind, command.ownerRef);
assertOwnerScopeActive(ownerScope);
const inserted = await client.query(
`insert into device_projects (
id,
owner_scope_id,
project_key,
name,
description,
created_by_ref
) values ($1, $2, $3, $4, $5, $6)
on conflict (owner_scope_id, project_key) do nothing
returning id, owner_scope_id, project_key, name, description,
lifecycle_state, created_at, updated_at`,
[
randomUUID(),
ownerScope.id,
command.projectKey,
command.name,
command.description,
actor.userRef,
],
);
let row = inserted.rows[0];
const created = Boolean(row);
if (created) {
assertActorCanManageOwnerScope(actor, command);
await client.query(
`insert into device_project_grants (
id,
project_id,
principal_kind,
principal_ref,
project_role,
capability_allow,
capability_deny,
lifecycle_state,
created_by_ref
) values ($1, $2, 'user', $3, 'owner', '{}', '{}', 'active', $3)`,
[randomUUID(), row.id, actor.userRef],
);
} else {
row = await findProjectByOwnerAndKey(
client,
ownerScope.id,
command.projectKey,
true,
);
assertProjectActive(row);
const grants = await listProjectGrants(client, row.id);
assertProjectCapability(actor, grants, "project.manage");
const updated = await client.query(
`update device_projects
set name = $2,
description = $3,
updated_at = now()
where id = $1
returning id, owner_scope_id, project_key, name, description,
lifecycle_state, created_at, updated_at`,
[row.id, command.name, command.description],
);
row = updated.rows[0];
}
await addManagementAudit(client, {
eventType: created ? "project.created" : "project.updated",
actorRef: actor.userRef,
projectId: row.id,
payload: {
projectRef: toProjectRef(row.id),
projectKey: row.project_key,
ownerScope: {
scopeKind: ownerScope.scope_kind,
ownerRef: ownerScope.owner_ref,
},
},
});
return {
created,
project: projectView(row, ownerScope),
...(created
? {
initialGrant: {
principalKind: "user",
principalRef: actor.userRef,
projectRole: "owner",
lifecycleState: "active",
},
}
: {}),
};
}
async function ensureCollection(client, actor, command) {
const { project, ownerScope } = await findProjectContext(client, command.projectId);
assertProjectActive(project);
const grants = await listProjectGrants(client, project.id);
assertProjectCapability(actor, grants, "collection.manage");
const result = await client.query(
`insert into device_collections (
id,
project_id,
collection_key,
name,
description,
created_by_ref
) values ($1, $2, $3, $4, $5, $6)
on conflict (project_id, collection_key) do update set
name = excluded.name,
description = excluded.description,
updated_at = now()
returning id, project_id, collection_key, name, description,
lifecycle_state, created_at, updated_at, (xmax = 0) as created`,
[
randomUUID(),
project.id,
command.collectionKey,
command.name,
command.description,
actor.userRef,
],
);
const row = result.rows[0];
assertCollectionActive(row);
await addManagementAudit(client, {
eventType: row.created ? "collection.created" : "collection.updated",
actorRef: actor.userRef,
projectId: project.id,
payload: {
projectRef: toProjectRef(project.id),
collectionRef: `collection:${row.id}`,
collectionKey: row.collection_key,
},
});
return {
created: row.created === true,
project: projectView(project, ownerScope),
collection: collectionView(row),
};
}
async function upsertProjectGrant(client, actor, command) {
const { project, ownerScope } = await findProjectContext(
client,
command.projectId,
true,
);
assertProjectActive(project);
const grants = await listProjectGrants(client, project.id);
const existing = grants.find(
(grant) =>
grant.principalKind === command.principalKind &&
grant.principalRef === command.principalRef,
) ?? null;
assertGrantMutationAllowed(actor, grants, command, existing);
const removesActiveOwner =
existing?.projectRole === "owner" &&
existing.lifecycleState === "active" &&
(command.projectRole !== "owner" || command.lifecycleState !== "active");
if (removesActiveOwner) {
const remaining = grants.filter(
(grant) =>
grant.grantRef !== existing.grantRef &&
grant.projectRole === "owner" &&
grant.lifecycleState === "active" &&
grant.principalKind === "user",
);
if (remaining.length === 0) {
throw domainError("device_project_last_owner_required", 409);
}
}
const result = await client.query(
`insert into device_project_grants (
id,
project_id,
principal_kind,
principal_ref,
project_role,
capability_allow,
capability_deny,
lifecycle_state,
created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
on conflict (project_id, principal_kind, principal_ref) do update set
project_role = excluded.project_role,
capability_allow = excluded.capability_allow,
capability_deny = excluded.capability_deny,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
returning id, principal_kind, principal_ref, project_role,
capability_allow, capability_deny, lifecycle_state, created_at,
updated_at, (xmax = 0) as created`,
[
randomUUID(),
project.id,
command.principalKind,
command.principalRef,
command.projectRole,
command.capabilityAllow,
command.capabilityDeny,
command.lifecycleState,
actor.userRef,
],
);
const row = result.rows[0];
await addManagementAudit(client, {
eventType: row.created ? "project_grant.created" : "project_grant.updated",
actorRef: actor.userRef,
projectId: project.id,
payload: {
projectRef: toProjectRef(project.id),
grantRef: `grant:${row.id}`,
principalKind: row.principal_kind,
principalRef: row.principal_ref,
projectRole: row.project_role,
lifecycleState: row.lifecycle_state,
},
});
return {
created: row.created === true,
project: projectView(project, ownerScope),
grant: grantView(row),
};
}
async function findOwnerScope(client, scopeKind, ownerRef) {
const result = await client.query(
`select id, scope_kind, owner_ref, display_name, lifecycle_state,
created_at, updated_at
from device_owner_scopes
where scope_kind = $1 and owner_ref = $2
for share`,
[scopeKind, ownerRef],
);
if (!result.rows[0]) throw domainError("device_owner_scope_not_found", 404);
return result.rows[0];
}
async function findProjectByOwnerAndKey(
client,
ownerScopeId,
projectKey,
forUpdate = false,
) {
const lockClause = forUpdate ? "for update" : "for share";
const result = await client.query(
`select id, owner_scope_id, project_key, name, description,
lifecycle_state, created_at, updated_at
from device_projects
where owner_scope_id = $1 and project_key = $2
${lockClause}`,
[ownerScopeId, projectKey],
);
if (!result.rows[0]) throw domainError("device_project_not_found", 404);
return result.rows[0];
}
async function findProjectContext(client, projectId, forUpdate = false) {
const lockClause = forUpdate ? "for update of p" : "for share of p, os";
const result = await client.query(
`select
p.id,
p.owner_scope_id,
p.project_key,
p.name,
p.description,
p.lifecycle_state,
p.created_at,
p.updated_at,
os.scope_kind,
os.owner_ref,
os.display_name as owner_display_name,
os.lifecycle_state as owner_lifecycle_state,
os.created_at as owner_created_at,
os.updated_at as owner_updated_at
from device_projects p
join device_owner_scopes os on os.id = p.owner_scope_id
where p.id = $1
${lockClause}`,
[projectId],
);
const row = result.rows[0];
if (!row) throw domainError("device_project_not_found", 404);
assertOwnerScopeActive({ lifecycle_state: row.owner_lifecycle_state });
return {
project: row,
ownerScope: {
id: row.owner_scope_id,
scope_kind: row.scope_kind,
owner_ref: row.owner_ref,
display_name: row.owner_display_name,
lifecycle_state: row.owner_lifecycle_state,
created_at: row.owner_created_at,
updated_at: row.owner_updated_at,
},
};
}
async function listProjectGrants(client, projectId) {
const result = await client.query(
`select id, principal_kind, principal_ref, project_role,
capability_allow, capability_deny, lifecycle_state,
created_at, updated_at
from device_project_grants
where project_id = $1
order by created_at, id
for share`,
[projectId],
);
return result.rows.map(grantView);
}
async function addManagementAudit(client, {
eventType,
actorRef,
projectId = null,
payload,
}) {
await client.query(
`insert into device_audit_events (
id,
event_type,
actor_ref,
project_id,
payload
) values ($1, $2, $3, $4, $5::jsonb)`,
[randomUUID(), eventType, actorRef, projectId, JSON.stringify(payload)],
);
}
function projectOwnerScopeView(row) {
return {
ownerScopeRef: `owner-scope:${row.id}`,
scopeKind: row.scope_kind,
ownerRef: row.owner_ref,
displayName: row.display_name,
lifecycleState: row.lifecycle_state,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function projectView(row, ownerScope) {
return {
projectRef: toProjectRef(row.id),
ownerScope: projectOwnerScopeView(ownerScope),
projectKey: row.project_key,
name: row.name,
description: row.description ?? null,
lifecycleState: row.lifecycle_state,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function collectionView(row) {
return {
collectionRef: `collection:${row.id}`,
projectRef: toProjectRef(row.project_id),
collectionKey: row.collection_key,
name: row.name,
description: row.description ?? null,
lifecycleState: row.lifecycle_state,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function grantView(row) {
return {
grantRef: `grant:${row.id}`,
principalKind: row.principal_kind,
principalRef: row.principal_ref,
projectRole: row.project_role,
capabilityAllow: [...(row.capability_allow ?? [])].sort(),
capabilityDeny: [...(row.capability_deny ?? [])].sort(),
lifecycleState: row.lifecycle_state,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function assertOwnerScopeActive(ownerScope) {
if (ownerScope.lifecycle_state !== "active") {
throw domainError("device_owner_scope_inactive", 409);
}
}
function assertProjectActive(project) {
if (project.lifecycle_state !== "active") {
throw domainError("device_project_inactive", 409);
}
}
function assertCollectionActive(collection) {
if (collection.lifecycle_state !== "active") {
throw domainError("device_collection_inactive", 409);
}
}
function toIso(value) {
return new Date(value).toISOString();
}
function mapPostgresError(error) {
if (error?.statusCode) return error;
if (error?.code === "23503") {
return domainError("device_management_reference_invalid", 409);
}
if (error?.code === "23505") {
return domainError("device_management_identity_conflict", 409);
}
if (error?.code === "23514") {
return domainError("device_management_constraint_failed", 400);
}
return error;
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
function normalizePoolSize(value) {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 50) {
@@ -0,0 +1,535 @@
export const DEVICE_PROJECT_CAPABILITIES = 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",
"command.plan",
"command.confirm",
"command.dispatch",
"credential.manage",
"audit.read",
]);
export const DEVICE_PROJECT_ROLES = Object.freeze([
"viewer",
"operator",
"engineer",
"admin",
"owner",
]);
export const DEVICE_HUB_ROLES = Object.freeze([
"viewer",
"member",
"admin",
"owner",
]);
export const DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
"owner_scope.ensure",
"project.ensure",
"collection.ensure",
"project_grant.upsert",
]);
const capabilitySet = new Set(DEVICE_PROJECT_CAPABILITIES);
const projectRoleSet = new Set(DEVICE_PROJECT_ROLES);
const hubRoleSet = new Set(DEVICE_HUB_ROLES);
const commandKindSet = new Set(DEVICE_MANAGEMENT_COMMAND_KINDS);
const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/;
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
const projectRefPattern = /^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;
const roleCapabilities = Object.freeze({
viewer: Object.freeze([
"project.read",
"inventory.read",
"telemetry.observe",
"configuration.read",
"audit.read",
]),
operator: Object.freeze([
"project.read",
"inventory.read",
"telemetry.observe",
"configuration.read",
"command.plan",
"command.confirm",
"command.dispatch",
"audit.read",
]),
engineer: Object.freeze([
"project.read",
"inventory.read",
"device.enroll",
"device.claim",
"collection.manage",
"route.manage",
"binding.manage",
"telemetry.observe",
"configuration.read",
"command.plan",
"audit.read",
]),
admin: Object.freeze([
"project.read",
"project.manage",
"access.manage",
"inventory.read",
"device.enroll",
"device.claim",
"collection.manage",
"route.manage",
"binding.manage",
"telemetry.observe",
"configuration.read",
"command.plan",
"command.confirm",
"command.dispatch",
"credential.manage",
"audit.read",
]),
owner: DEVICE_PROJECT_CAPABILITIES,
});
const hubRoleCeilings = Object.freeze({
viewer: roleCapabilities.viewer,
member: Object.freeze([
...new Set([
...roleCapabilities.viewer,
...roleCapabilities.operator,
...roleCapabilities.engineer,
]),
]),
admin: roleCapabilities.admin,
owner: DEVICE_PROJECT_CAPABILITIES,
});
const projectRoleWeight = Object.freeze({
viewer: 10,
operator: 20,
engineer: 30,
admin: 40,
owner: 50,
});
export function normalizeManagementActor(input) {
assertPlainObject(input, "device_management_actor_invalid");
assertAllowedKeys(
input,
["userRef", "hubRole", "groupRefs", "ownerScopes"],
"device_management_actor_field_unexpected",
);
const userRef = normalizeOpaqueRef(input.userRef, "device_actor_user_ref_invalid");
const hubRole = normalizeEnum(input.hubRole, hubRoleSet, "device_actor_hub_role_invalid");
const groupRefs = normalizeOpaqueRefArray(
input.groupRefs ?? [],
"device_actor_group_refs_invalid",
);
const ownerScopes = normalizeOwnerScopeClaims(input.ownerScopes ?? []);
return Object.freeze({
userRef,
hubRole,
groupRefs: Object.freeze(groupRefs),
ownerScopes: Object.freeze(ownerScopes),
});
}
export function normalizeManagementCommand(kind, input) {
const normalizedKind = normalizeEnum(
kind,
commandKindSet,
"device_management_command_kind_invalid",
);
assertPlainObject(input, "device_management_command_invalid");
if (normalizedKind === "owner_scope.ensure") {
assertAllowedKeys(
input,
["scopeKind", "ownerRef", "displayName"],
"device_management_command_field_unexpected",
);
return Object.freeze({
scopeKind: normalizeScopeKind(input.scopeKind),
ownerRef: normalizeOpaqueRef(input.ownerRef, "device_owner_ref_invalid"),
displayName: normalizeDisplayText(input.displayName, 160, "device_owner_name_invalid"),
});
}
if (normalizedKind === "project.ensure") {
assertAllowedKeys(
input,
["scopeKind", "ownerRef", "projectKey", "name", "description"],
"device_management_command_field_unexpected",
);
return Object.freeze({
scopeKind: normalizeScopeKind(input.scopeKind),
ownerRef: normalizeOpaqueRef(input.ownerRef, "device_owner_ref_invalid"),
projectKey: normalizeKey(input.projectKey, "device_project_key_invalid"),
name: normalizeDisplayText(input.name, 160, "device_project_name_invalid"),
description: normalizeOptionalText(
input.description,
2000,
"device_project_description_invalid",
),
});
}
if (normalizedKind === "collection.ensure") {
assertAllowedKeys(
input,
["projectRef", "collectionKey", "name", "description"],
"device_management_command_field_unexpected",
);
return Object.freeze({
projectId: normalizeProjectRef(input.projectRef),
collectionKey: normalizeKey(
input.collectionKey,
"device_collection_key_invalid",
),
name: normalizeDisplayText(input.name, 160, "device_collection_name_invalid"),
description: normalizeOptionalText(
input.description,
2000,
"device_collection_description_invalid",
),
});
}
assertAllowedKeys(
input,
[
"projectRef",
"principalKind",
"principalRef",
"projectRole",
"capabilityAllow",
"capabilityDeny",
"lifecycleState",
],
"device_management_command_field_unexpected",
);
const principalKind = normalizeEnum(
input.principalKind,
new Set(["user", "group"]),
"device_project_principal_kind_invalid",
);
const projectRole = normalizeEnum(
input.projectRole,
projectRoleSet,
"device_project_role_invalid",
);
if (projectRole === "owner" && principalKind !== "user") {
throw domainError("device_project_owner_must_be_user", 400);
}
const capabilityAllow = normalizeCapabilities(input.capabilityAllow ?? []);
const capabilityDeny = normalizeCapabilities(input.capabilityDeny ?? []);
if (capabilityAllow.some((capability) => capabilityDeny.includes(capability))) {
throw domainError("device_project_capability_overlap", 400);
}
return Object.freeze({
projectId: normalizeProjectRef(input.projectRef),
principalKind,
principalRef: normalizeOpaqueRef(
input.principalRef,
"device_project_principal_ref_invalid",
),
projectRole,
capabilityAllow: Object.freeze(capabilityAllow),
capabilityDeny: Object.freeze(capabilityDeny),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "active",
new Set(["active", "revoked"]),
"device_project_grant_state_invalid",
),
});
}
export function assertActorCanManageOwnerScope(actorInput, scopeInput) {
const actor = normalizeManagementActor(actorInput);
const scope = {
scopeKind: normalizeScopeKind(scopeInput?.scopeKind),
ownerRef: normalizeOpaqueRef(scopeInput?.ownerRef, "device_owner_ref_invalid"),
};
if (scope.scopeKind === "personal") {
if (
actor.userRef !== scope.ownerRef ||
!["admin", "owner"].includes(actor.hubRole)
) {
throw domainError("device_owner_scope_access_denied", 403);
}
return actor;
}
const hasClaim = actor.ownerScopes.some(
(claim) => claim.scopeKind === "company" && claim.ownerRef === scope.ownerRef,
);
if (!hasClaim || !["admin", "owner"].includes(actor.hubRole)) {
throw domainError("device_owner_scope_access_denied", 403);
}
return actor;
}
export function resolveProjectAccess({ actor: actorInput, grants = [] }) {
const actor = normalizeManagementActor(actorInput);
if (!Array.isArray(grants)) {
throw new TypeError("device_project_grants_invalid");
}
const active = grants
.map(normalizeStoredGrant)
.filter((grant) => grant.lifecycleState === "active");
const direct = active.find(
(grant) => grant.principalKind === "user" && grant.principalRef === actor.userRef,
);
const matching = direct
? [direct]
: active
.filter(
(grant) =>
grant.principalKind === "group" &&
actor.groupRefs.includes(grant.principalRef),
)
.sort(compareGrantPriority);
if (matching.length === 0) {
return Object.freeze({
allowed: false,
projectRole: null,
capabilities: Object.freeze([]),
sourceRefs: Object.freeze([]),
});
}
const primary = matching[0];
const allowed = new Set();
const denied = new Set();
for (const grant of matching) {
for (const capability of roleCapabilities[grant.projectRole]) {
allowed.add(capability);
}
for (const capability of grant.capabilityAllow) allowed.add(capability);
for (const capability of grant.capabilityDeny) denied.add(capability);
}
for (const capability of denied) allowed.delete(capability);
const hubCeiling = new Set(hubRoleCeilings[actor.hubRole]);
const capabilities = [...allowed]
.filter((capability) => hubCeiling.has(capability))
.sort();
if (!capabilities.includes("project.read")) {
return Object.freeze({
allowed: false,
projectRole: primary.projectRole,
capabilities: Object.freeze([]),
sourceRefs: Object.freeze(matching.map((grant) => grant.grantRef)),
});
}
return Object.freeze({
allowed: true,
projectRole: primary.projectRole,
capabilities: Object.freeze(capabilities),
sourceRefs: Object.freeze(matching.map((grant) => grant.grantRef)),
});
}
export function assertProjectCapability(actor, grants, capability) {
if (!capabilitySet.has(capability)) {
throw new TypeError("device_project_capability_invalid");
}
const access = resolveProjectAccess({ actor, grants });
if (!access.capabilities.includes(capability)) {
throw domainError("device_project_capability_denied", 403);
}
return access;
}
export function assertGrantMutationAllowed(actor, grants, command, existingGrant = null) {
const access = assertProjectCapability(actor, grants, "access.manage");
if (
command.projectRole === "owner" ||
existingGrant?.projectRole === "owner"
) {
if (!access.capabilities.includes("device.transfer")) {
throw domainError("device_project_owner_transfer_denied", 403);
}
}
return access;
}
export function toProjectRef(projectId) {
if (typeof projectId !== "string" || !projectRefPattern.test(`project:${projectId}`)) {
throw new TypeError("device_project_id_invalid");
}
return `project:${projectId.toLowerCase()}`;
}
function normalizeStoredGrant(input) {
assertPlainObject(input, "device_project_grant_invalid");
const grant = {
grantRef: normalizeOpaqueRef(input.grantRef, "device_project_grant_ref_invalid"),
principalKind: normalizeEnum(
input.principalKind,
new Set(["user", "group"]),
"device_project_principal_kind_invalid",
),
principalRef: normalizeOpaqueRef(
input.principalRef,
"device_project_principal_ref_invalid",
),
projectRole: normalizeEnum(
input.projectRole,
projectRoleSet,
"device_project_role_invalid",
),
capabilityAllow: normalizeCapabilities(input.capabilityAllow ?? []),
capabilityDeny: normalizeCapabilities(input.capabilityDeny ?? []),
lifecycleState: normalizeEnum(
input.lifecycleState,
new Set(["active", "revoked"]),
"device_project_grant_state_invalid",
),
};
if (grant.projectRole === "owner" && grant.principalKind !== "user") {
throw new TypeError("device_project_owner_must_be_user");
}
return grant;
}
function compareGrantPriority(left, right) {
return (
projectRoleWeight[right.projectRole] - projectRoleWeight[left.projectRole] ||
left.principalRef.localeCompare(right.principalRef)
);
}
function normalizeOwnerScopeClaims(input) {
if (!Array.isArray(input) || input.length > 128) {
throw new TypeError("device_actor_owner_scopes_invalid");
}
const claims = input.map((claim) => {
assertPlainObject(claim, "device_actor_owner_scope_invalid");
assertAllowedKeys(
claim,
["scopeKind", "ownerRef"],
"device_actor_owner_scope_field_unexpected",
);
return {
scopeKind: normalizeScopeKind(claim.scopeKind),
ownerRef: normalizeOpaqueRef(claim.ownerRef, "device_owner_ref_invalid"),
};
});
const byKey = new Map(
claims.map((claim) => [`${claim.scopeKind}\0${claim.ownerRef}`, claim]),
);
return [...byKey.values()].sort((left, right) =>
`${left.scopeKind}:${left.ownerRef}`.localeCompare(
`${right.scopeKind}:${right.ownerRef}`,
),
);
}
function normalizeCapabilities(input) {
if (!Array.isArray(input) || input.length > DEVICE_PROJECT_CAPABILITIES.length) {
throw new TypeError("device_project_capabilities_invalid");
}
const normalized = input.map((capability) =>
normalizeEnum(
capability,
capabilitySet,
"device_project_capability_invalid",
),
);
return [...new Set(normalized)].sort();
}
function normalizeOpaqueRefArray(input, code) {
if (!Array.isArray(input) || input.length > 128) throw new TypeError(code);
return [...new Set(input.map((value) => normalizeOpaqueRef(value, code)))].sort();
}
function normalizeProjectRef(value) {
if (typeof value !== "string") throw new TypeError("device_project_ref_invalid");
const match = value.match(projectRefPattern);
if (!match) throw new TypeError("device_project_ref_invalid");
return match[1].toLowerCase();
}
function normalizeScopeKind(value) {
return normalizeEnum(
value,
new Set(["company", "personal"]),
"device_owner_scope_kind_invalid",
);
}
function normalizeKey(value, code) {
if (typeof value !== "string" || !keyPattern.test(value)) {
throw new TypeError(code);
}
return value;
}
function normalizeOpaqueRef(value, code) {
if (typeof value !== "string" || !opaqueRefPattern.test(value)) {
throw new TypeError(code);
}
return value;
}
function normalizeDisplayText(value, maxLength, code) {
if (typeof value !== "string") throw new TypeError(code);
const normalized = value.trim();
if (normalized.length < 1 || normalized.length > maxLength) {
throw new TypeError(code);
}
if (/\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)) {
throw new TypeError(code);
}
return normalized;
}
function normalizeOptionalText(value, maxLength, code) {
if (value == null || value === "") return null;
return normalizeDisplayText(value, maxLength, code);
}
function normalizeEnum(value, allowed, code) {
if (typeof value !== "string" || !allowed.has(value)) {
throw new TypeError(code);
}
return value;
}
function assertAllowedKeys(input, allowed, code) {
const allowedSet = new Set(allowed);
for (const key of Object.keys(input)) {
if (!allowedSet.has(key)) throw new TypeError(`${code}:${key}`);
}
}
function assertPlainObject(value, code) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(code);
}
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -17,6 +17,8 @@ const server = createControlCoreApp({
gatewayToken: config.gatewayToken,
identifierPepper: config.identifierPepper,
discoveryIngestEnabled: config.discoveryIngestEnabled,
managementApiEnabled: config.managementApiEnabled,
managementToken: config.managementToken,
});
server.listen(config.port, config.host, () => {
@@ -25,6 +27,7 @@ server.listen(config.port, config.host, () => {
host: config.host,
port: config.port,
discoveryIngest: config.discoveryIngestEnabled,
managementApi: config.managementApiEnabled,
commandTransport: "disabled",
}));
});
@@ -44,6 +47,10 @@ async function readConfig() {
process.env.DEVICE_DISCOVERY_INGEST_ENABLED,
false,
);
const managementApiEnabled = parseBoolean(
process.env.DEVICE_MANAGEMENT_API_ENABLED,
false,
);
return {
host: String(process.env.HOST || "127.0.0.1").trim(),
port: parsePort(process.env.PORT, 18120),
@@ -53,6 +60,7 @@ async function readConfig() {
10,
),
discoveryIngestEnabled,
managementApiEnabled,
gatewayToken: discoveryIngestEnabled
? await readRequiredSecretFile(
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
@@ -65,6 +73,12 @@ async function readConfig() {
"device_identifier_pepper_file_required",
)
: "",
managementToken: managementApiEnabled
? await readRequiredSecretFile(
process.env.DEVICE_MANAGEMENT_CORE_TOKEN_FILE,
"device_management_core_token_file_required",
)
: "",
};
}