feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import {
|
||||
hashRestrictedIdentifier,
|
||||
maskRestrictedIdentifier,
|
||||
normalizeRestrictedIdentifier,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import { createDeviceGatewayIngest } from "./gateway-ingest.mjs";
|
||||
import {
|
||||
normalizeManagementActor,
|
||||
} from "./project-management.mjs";
|
||||
import { normalizeDeviceManagementCommand } from "./management-command.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"],
|
||||
["/internal/v1/management/adapter-packages:ensure", "adapter_package.ensure"],
|
||||
["/internal/v1/management/adapter-versions:register", "adapter_version.register"],
|
||||
["/internal/v1/management/model-profiles:register", "model_profile.register"],
|
||||
["/internal/v1/management/edges:ensure", "edge.ensure"],
|
||||
["/internal/v1/management/routes:ensure", "route.ensure"],
|
||||
["/internal/v1/management/enrollment-intents:ensure", "enrollment_intent.ensure"],
|
||||
["/internal/v1/management/devices:claim", "device.claim"],
|
||||
["/internal/v1/management/devices:update", "device.update"],
|
||||
["/internal/v1/management/devices:transfer", "device.transfer"],
|
||||
["/internal/v1/management/discoveries:reject", "discovery.reject"],
|
||||
["/internal/v1/management/discoveries:expire", "discovery.expire"],
|
||||
[
|
||||
"/internal/v1/management/device-credential-bindings:upsert",
|
||||
"device_credential_binding.upsert",
|
||||
],
|
||||
[
|
||||
"/internal/v1/management/device-credential-bindings:revoke",
|
||||
"device_credential_binding.revoke",
|
||||
],
|
||||
["/internal/v1/management/device-bindings:ensure", "device_binding.ensure"],
|
||||
["/internal/v1/management/device-bindings:revoke", "device_binding.revoke"],
|
||||
[
|
||||
"/internal/v1/management/device-configuration-revisions:create",
|
||||
"device_configuration_revision.create",
|
||||
],
|
||||
[
|
||||
"/internal/v1/management/device-configurations:set-desired",
|
||||
"device_configuration_desired.set",
|
||||
],
|
||||
]);
|
||||
|
||||
export function createControlCoreApp({
|
||||
repository,
|
||||
gatewayToken = "",
|
||||
identifierPepper = "",
|
||||
discoveryIngestEnabled = false,
|
||||
managementApiEnabled = false,
|
||||
managementToken = "",
|
||||
gatewayIngest = null,
|
||||
edgeChannelStatusProvider = null,
|
||||
typedCommandRuntime = null,
|
||||
} = {}) {
|
||||
if (!repository || typeof repository.health !== "function") {
|
||||
throw new TypeError("device_repository_required");
|
||||
}
|
||||
if (discoveryIngestEnabled) {
|
||||
if (typeof repository.upsertQuarantineDiscovery !== "function") {
|
||||
throw new TypeError("device_discovery_repository_required");
|
||||
}
|
||||
if (typeof repository.acceptAdapterMessage !== "function") {
|
||||
throw new TypeError("device_gateway_message_repository_required");
|
||||
}
|
||||
if (typeof gatewayToken !== "string" || gatewayToken.length < 32) {
|
||||
throw new TypeError("device_gateway_token_invalid");
|
||||
}
|
||||
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
|
||||
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");
|
||||
}
|
||||
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
|
||||
throw new TypeError("device_identifier_pepper_invalid");
|
||||
}
|
||||
}
|
||||
const ingest = discoveryIngestEnabled
|
||||
? gatewayIngest ?? createDeviceGatewayIngest({ repository, identifierPepper })
|
||||
: gatewayIngest;
|
||||
if (
|
||||
ingest
|
||||
&& (
|
||||
typeof ingest.observeDiscovery !== "function"
|
||||
|| typeof ingest.acceptMessage !== "function"
|
||||
)
|
||||
) {
|
||||
throw new TypeError("device_gateway_ingest_invalid");
|
||||
}
|
||||
if (
|
||||
typedCommandRuntime != null
|
||||
&& (
|
||||
typeof typedCommandRuntime.planServicePing !== "function"
|
||||
|| typeof typedCommandRuntime.status !== "function"
|
||||
)
|
||||
) {
|
||||
throw new TypeError("device_typed_command_runtime_invalid");
|
||||
}
|
||||
if (
|
||||
edgeChannelStatusProvider != null
|
||||
&& typeof edgeChannelStatusProvider !== "function"
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_status_provider_invalid");
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
|
||||
try {
|
||||
const requestUrl = new URL(
|
||||
request.url || "/",
|
||||
`http://${request.headers.host || "127.0.0.1"}`,
|
||||
);
|
||||
|
||||
if (request.method === "GET" && requestUrl.pathname === "/healthz") {
|
||||
const database = await repository.health();
|
||||
return writeJson(response, 200, {
|
||||
ok: true,
|
||||
service: "nodedc-device-control-core",
|
||||
database,
|
||||
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
|
||||
managementApi: managementApiEnabled ? "enabled" : "disabled",
|
||||
edgeChannels: edgeChannelStatusProvider
|
||||
? edgeChannelStatusProvider()
|
||||
: { enabled: false, configured: 0, accepted: 0, degraded: 0 },
|
||||
commandTransport: typedCommandRuntime
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST"
|
||||
&& requestUrl.pathname === "/internal/v1/commands:service-ping"
|
||||
) {
|
||||
if (!managementApiEnabled || !typedCommandRuntime) {
|
||||
return writeJson(response, 404, {
|
||||
ok: false,
|
||||
error: "device_command_transport_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, 8 * 1024);
|
||||
const execution = await typedCommandRuntime.planServicePing({
|
||||
idempotencyKey,
|
||||
actor,
|
||||
input,
|
||||
});
|
||||
response.setHeader("Idempotency-Key", idempotencyKey);
|
||||
response.setHeader(
|
||||
"Idempotency-Replayed",
|
||||
execution.replayed ? "true" : "false",
|
||||
);
|
||||
return writeJson(response, 200, {
|
||||
ok: true,
|
||||
replayed: execution.replayed,
|
||||
result: execution.command,
|
||||
});
|
||||
}
|
||||
|
||||
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 protectedInput = managementCommandKind === "enrollment_intent.ensure"
|
||||
? protectEnrollmentIdentifier(input, identifierPepper)
|
||||
: input;
|
||||
const command = normalizeDeviceManagementCommand(
|
||||
managementCommandKind,
|
||||
protectedInput,
|
||||
);
|
||||
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 === "GET"
|
||||
&& requestUrl.pathname === "/internal/v1/query/projects"
|
||||
) {
|
||||
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",
|
||||
});
|
||||
}
|
||||
if (typeof repository.listAccessibleProjects !== "function") {
|
||||
return writeJson(response, 503, {
|
||||
ok: false,
|
||||
error: "device_query_repository_unavailable",
|
||||
});
|
||||
}
|
||||
const actor = managementActorFromHeaders(request.headers);
|
||||
const projects = await repository.listAccessibleProjects(actor);
|
||||
return writeJson(response, 200, { ok: true, projects });
|
||||
}
|
||||
|
||||
const workspaceProjectId = projectWorkspaceId(requestUrl.pathname);
|
||||
if (request.method === "GET" && workspaceProjectId) {
|
||||
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",
|
||||
});
|
||||
}
|
||||
if (typeof repository.getProjectWorkspace !== "function") {
|
||||
return writeJson(response, 503, {
|
||||
ok: false,
|
||||
error: "device_query_repository_unavailable",
|
||||
});
|
||||
}
|
||||
const actor = managementActorFromHeaders(request.headers);
|
||||
const workspace = await repository.getProjectWorkspace(
|
||||
actor,
|
||||
workspaceProjectId,
|
||||
{
|
||||
commandTransport: typedCommandRuntime
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled",
|
||||
},
|
||||
);
|
||||
return writeJson(response, 200, { ok: true, workspace });
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST"
|
||||
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
||||
) {
|
||||
if (!discoveryIngestEnabled) {
|
||||
return writeJson(response, 404, {
|
||||
ok: false,
|
||||
error: "device_discovery_ingest_disabled",
|
||||
});
|
||||
}
|
||||
if (!matchesBearer(request.headers.authorization, gatewayToken)) {
|
||||
return writeJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_gateway_auth_required",
|
||||
});
|
||||
}
|
||||
|
||||
const input = await readJsonBody(request, 32 * 1024);
|
||||
const discovery = await ingest.observeDiscovery(input);
|
||||
return writeJson(response, discovery.created ? 201 : 200, {
|
||||
ok: true,
|
||||
created: discovery.created,
|
||||
discovery: discovery.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST"
|
||||
&& requestUrl.pathname === "/internal/v1/gateway/messages:accept"
|
||||
) {
|
||||
if (!discoveryIngestEnabled) {
|
||||
return writeJson(response, 404, {
|
||||
ok: false,
|
||||
error: "device_gateway_message_ingest_disabled",
|
||||
});
|
||||
}
|
||||
if (!matchesBearer(request.headers.authorization, gatewayToken)) {
|
||||
return writeJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_gateway_auth_required",
|
||||
});
|
||||
}
|
||||
|
||||
const input = await readJsonBody(request, 1024 * 1024);
|
||||
const receipt = await ingest.acceptMessage(input);
|
||||
const acceptance = receipt.value;
|
||||
return writeJson(response, acceptance.replayed ? 200 : 201, {
|
||||
ok: true,
|
||||
acceptance,
|
||||
});
|
||||
}
|
||||
|
||||
return writeJson(response, 404, {
|
||||
ok: false,
|
||||
error: "device_control_core_route_not_found",
|
||||
});
|
||||
} catch (error) {
|
||||
const status = Number(error?.statusCode || 400);
|
||||
return writeJson(
|
||||
response,
|
||||
Number.isInteger(status) && status >= 400 && status < 600
|
||||
? status
|
||||
: 500,
|
||||
{
|
||||
ok: false,
|
||||
error: safeErrorCode(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function protectEnrollmentIdentifier(input, identifierPepper) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new TypeError("device_enrollment_input_invalid");
|
||||
}
|
||||
const allowedKeys = new Set([
|
||||
"projectRef",
|
||||
"enrollmentKey",
|
||||
"routeRef",
|
||||
"modelProfileRef",
|
||||
"displayName",
|
||||
"identifier",
|
||||
"expiresAt",
|
||||
]);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new TypeError("device_enrollment_input_field_unexpected");
|
||||
}
|
||||
}
|
||||
const identifier = normalizeRestrictedIdentifier(input.identifier);
|
||||
return Object.freeze({
|
||||
projectRef: input.projectRef,
|
||||
enrollmentKey: input.enrollmentKey,
|
||||
routeRef: input.routeRef,
|
||||
modelProfileRef: input.modelProfileRef,
|
||||
displayName: input.displayName,
|
||||
identifierKind: identifier.kind,
|
||||
identifierDigest: hashRestrictedIdentifier(identifier, identifierPepper),
|
||||
identifierMasked: maskRestrictedIdentifier(identifier),
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
function projectWorkspaceId(pathname) {
|
||||
const match = pathname.match(
|
||||
/^\/internal\/v1\/query\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/workspace$/i,
|
||||
);
|
||||
return match?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
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");
|
||||
const required = Buffer.from(expected, "utf8");
|
||||
return (
|
||||
actual.length === required.length
|
||||
&& required.length > 0
|
||||
&& timingSafeEqual(actual, required)
|
||||
);
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) {
|
||||
const error = new Error("device_request_body_too_large");
|
||||
error.statusCode = 413;
|
||||
throw error;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (size === 0) throw new TypeError("device_request_body_required");
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
throw new TypeError("device_request_json_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(response, status, body) {
|
||||
response.statusCode = status;
|
||||
return response.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
function safeErrorCode(error) {
|
||||
const value = error instanceof Error ? error.message : "device_control_error";
|
||||
return /^[a-z0-9_:-]{1,128}$/.test(value)
|
||||
? value
|
||||
: "device_control_error";
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
DEVICE_BINDING_CAPABILITIES,
|
||||
assertSafeProjection,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
export const DEVICE_CONTROL_RESOURCE_COMMAND_KINDS = Object.freeze([
|
||||
"device_binding.ensure",
|
||||
"device_binding.revoke",
|
||||
"device_configuration_revision.create",
|
||||
"device_configuration_desired.set",
|
||||
]);
|
||||
|
||||
const commandKindSet = new Set(DEVICE_CONTROL_RESOURCE_COMMAND_KINDS);
|
||||
const bindingCapabilitySet = new Set(DEVICE_BINDING_CAPABILITIES);
|
||||
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const tokenPattern = /^[a-z][a-z0-9._:-]{1,63}$/;
|
||||
const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/;
|
||||
const targetRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
|
||||
const configurationKeyPattern = /^[a-z][a-z0-9._-]{0,63}$/;
|
||||
const secretReferencePattern = /^(?:ndc-credref:|(?:bearer|basic)\s)|[?&](?:token|secret|password|api[_-]?key)=/i;
|
||||
|
||||
export function isControlResourceManagementCommand(kind) {
|
||||
return commandKindSet.has(kind);
|
||||
}
|
||||
|
||||
export function normalizeControlResourceManagementCommand(kind, input) {
|
||||
if (!commandKindSet.has(kind)) {
|
||||
throw new TypeError("device_control_resource_command_kind_invalid");
|
||||
}
|
||||
assertPlainObject(input, "device_control_resource_command_invalid");
|
||||
|
||||
if (kind === "device_binding.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"bindingKey",
|
||||
"displayName",
|
||||
"source",
|
||||
"targetKind",
|
||||
"targetRef",
|
||||
"capabilities",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
bindingKey: normalizePattern(
|
||||
input.bindingKey,
|
||||
keyPattern,
|
||||
"device_binding_key_invalid",
|
||||
),
|
||||
displayName: normalizeDisplayText(
|
||||
input.displayName,
|
||||
160,
|
||||
"device_binding_name_invalid",
|
||||
),
|
||||
source: normalizeBindingSource(input.source),
|
||||
targetKind: normalizePattern(
|
||||
input.targetKind,
|
||||
tokenPattern,
|
||||
"device_binding_target_kind_invalid",
|
||||
),
|
||||
targetRef: normalizeTargetRef(input.targetRef),
|
||||
capabilities: Object.freeze(normalizeBindingCapabilities(
|
||||
input.capabilities,
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "device_binding.revoke") {
|
||||
assertAllowedKeys(input, ["projectRef", "bindingRef", "resolutionCode"]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
bindingId: normalizeEntityRef(input.bindingRef, "binding"),
|
||||
resolutionCode: normalizePattern(
|
||||
input.resolutionCode,
|
||||
resolutionPattern,
|
||||
"device_binding_resolution_code_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "device_configuration_revision.create") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"deviceRef",
|
||||
"configuration",
|
||||
"changeSummary",
|
||||
]);
|
||||
const configuration = normalizeDeviceConfiguration(input.configuration);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
configuration,
|
||||
configurationDigest: `sha256:${createHash("sha256")
|
||||
.update(JSON.stringify(configuration), "utf8")
|
||||
.digest("hex")}`,
|
||||
changeSummary: normalizeOptionalText(
|
||||
input.changeSummary,
|
||||
1000,
|
||||
"device_configuration_change_summary_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"deviceRef",
|
||||
"configurationRevisionRef",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
configurationRevisionId: normalizeEntityRef(
|
||||
input.configurationRevisionRef,
|
||||
"configuration-revision",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeDeviceConfiguration(input) {
|
||||
const normalized = normalizeConfigurationValue(input, 0, "$configuration");
|
||||
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
|
||||
throw new TypeError("device_configuration_must_be_object");
|
||||
}
|
||||
if (Object.keys(normalized).length === 0) {
|
||||
throw new TypeError("device_configuration_must_not_be_empty");
|
||||
}
|
||||
const serialized = JSON.stringify(normalized);
|
||||
if (Buffer.byteLength(serialized, "utf8") > 32768) {
|
||||
throw new TypeError("device_configuration_too_large");
|
||||
}
|
||||
assertSafeProjection({ configuration: normalized });
|
||||
return deepFreeze(normalized);
|
||||
}
|
||||
|
||||
function normalizeBindingSource(input) {
|
||||
assertPlainObject(input, "device_binding_source_invalid");
|
||||
assertAllowedKeys(input, ["kind", "ref"]);
|
||||
if (input.kind === "device") {
|
||||
return Object.freeze({
|
||||
kind: "device",
|
||||
id: normalizeEntityRef(input.ref, "device"),
|
||||
});
|
||||
}
|
||||
if (input.kind === "collection") {
|
||||
return Object.freeze({
|
||||
kind: "collection",
|
||||
id: normalizeEntityRef(input.ref, "collection"),
|
||||
});
|
||||
}
|
||||
throw new TypeError("device_binding_source_kind_invalid");
|
||||
}
|
||||
|
||||
function normalizeBindingCapabilities(input) {
|
||||
if (!Array.isArray(input) || input.length < 1 || input.length > 16) {
|
||||
throw new TypeError("device_binding_capabilities_invalid");
|
||||
}
|
||||
const normalized = input.map((value) => {
|
||||
if (typeof value !== "string" || !bindingCapabilitySet.has(value)) {
|
||||
throw new TypeError("device_binding_capability_invalid");
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new TypeError("device_binding_capabilities_duplicate");
|
||||
}
|
||||
return normalized.sort();
|
||||
}
|
||||
|
||||
function normalizeTargetRef(value) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !targetRefPattern.test(value)
|
||||
|| secretReferencePattern.test(value)
|
||||
) {
|
||||
throw new TypeError("device_binding_target_ref_invalid");
|
||||
}
|
||||
assertSafeProjection({ targetRef: value });
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeConfigurationValue(value, depth, path) {
|
||||
if (depth > 5) throw new TypeError("device_configuration_depth_exceeded");
|
||||
if (value === null || typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new TypeError(`device_configuration_number_invalid:${path}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (
|
||||
value.length > 1000
|
||||
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new TypeError(`device_configuration_string_invalid:${path}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 64) {
|
||||
throw new TypeError(`device_configuration_array_invalid:${path}`);
|
||||
}
|
||||
return value.map((item, index) =>
|
||||
normalizeConfigurationValue(item, depth + 1, `${path}[${index}]`)
|
||||
);
|
||||
}
|
||||
assertPlainObject(value, `device_configuration_object_invalid:${path}`);
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > 64) {
|
||||
throw new TypeError(`device_configuration_object_invalid:${path}`);
|
||||
}
|
||||
const normalized = {};
|
||||
for (const key of keys.sort()) {
|
||||
if (!configurationKeyPattern.test(key)) {
|
||||
throw new TypeError(`device_configuration_key_invalid:${path}.${key}`);
|
||||
}
|
||||
normalized[key] = normalizeConfigurationValue(
|
||||
value[key],
|
||||
depth + 1,
|
||||
`${path}.${key}`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeEntityRef(value, prefix) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
}
|
||||
const match = 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 new TypeError(`device_${prefix}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizePattern(value, pattern, code) {
|
||||
if (typeof value !== "string" || !pattern.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
|
||||
|| /\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 assertPlainObject(value, code) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedKeys(input, allowed) {
|
||||
const allowedSet = new Set(allowed);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedSet.has(key)) {
|
||||
throw new TypeError(`device_management_command_field_unexpected:${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
isControlResourceManagementCommand,
|
||||
} from "./control-resource-management.mjs";
|
||||
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
|
||||
import { toProjectRef } from "./project-management.mjs";
|
||||
|
||||
export async function applyControlResourceManagementCommand(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isControlResourceManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_control_resource_command_kind_invalid");
|
||||
}
|
||||
if (commandKind === "device_binding.ensure") {
|
||||
return ensureBinding(client, actor, command);
|
||||
}
|
||||
if (commandKind === "device_binding.revoke") {
|
||||
return revokeBinding(client, actor, command);
|
||||
}
|
||||
if (commandKind === "device_configuration_revision.create") {
|
||||
return createConfigurationRevision(client, actor, command);
|
||||
}
|
||||
return setDesiredConfiguration(client, actor, command);
|
||||
}
|
||||
|
||||
export async function authorizeControlResourceManagementReplay(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isControlResourceManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_control_resource_command_kind_invalid");
|
||||
}
|
||||
const capability = commandKind.startsWith("device_binding.")
|
||||
? "binding.manage"
|
||||
: "configuration.manage";
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
capability,
|
||||
);
|
||||
if (command.deviceId) {
|
||||
const current = await client.query(
|
||||
`select project_id from device_instances where id = $1`,
|
||||
[command.deviceId],
|
||||
);
|
||||
const currentProjectId = current.rows[0]?.project_id;
|
||||
if (!currentProjectId) throw domainError("device_not_found", 404);
|
||||
if (currentProjectId !== command.projectId) {
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
currentProjectId,
|
||||
capability,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBinding(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"binding.manage",
|
||||
);
|
||||
const source = await findBindingSource(client, command);
|
||||
const bindingId = randomUUID();
|
||||
const result = await client.query(
|
||||
`insert into device_resource_bindings (
|
||||
id,
|
||||
owner_scope_id,
|
||||
project_id,
|
||||
binding_key,
|
||||
display_name,
|
||||
source_kind,
|
||||
device_id,
|
||||
collection_id,
|
||||
target_kind,
|
||||
target_ref,
|
||||
capabilities,
|
||||
source_approved_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
on conflict (project_id, binding_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
capabilities = excluded.capabilities,
|
||||
updated_at = now()
|
||||
where device_resource_bindings.lifecycle_state = 'pending_external_approval'
|
||||
and device_resource_bindings.source_kind = excluded.source_kind
|
||||
and device_resource_bindings.device_id is not distinct from excluded.device_id
|
||||
and device_resource_bindings.collection_id is not distinct from excluded.collection_id
|
||||
and device_resource_bindings.target_kind = excluded.target_kind
|
||||
and device_resource_bindings.target_ref = excluded.target_ref
|
||||
returning id, owner_scope_id, project_id, binding_key, display_name,
|
||||
source_kind, device_id, collection_id, target_kind, target_ref,
|
||||
capabilities, lifecycle_state, source_approved_at, created_at, updated_at,
|
||||
(xmax = 0) as created`,
|
||||
[
|
||||
bindingId,
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
command.bindingKey,
|
||||
command.displayName,
|
||||
command.source.kind,
|
||||
command.source.kind === "device" ? source.id : null,
|
||||
command.source.kind === "collection" ? source.id : null,
|
||||
command.targetKind,
|
||||
command.targetRef,
|
||||
command.capabilities,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const binding = result.rows[0];
|
||||
if (!binding) throw domainError("device_binding_identity_conflict", 409);
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: binding.created
|
||||
? "device_binding.created"
|
||||
: "device_binding.updated",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: binding.device_id,
|
||||
payload: {
|
||||
bindingRef: `binding:${binding.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
bindingKey: binding.binding_key,
|
||||
sourceKind: binding.source_kind,
|
||||
sourceRef: bindingSourceRef(binding),
|
||||
targetKind: binding.target_kind,
|
||||
targetRef: binding.target_ref,
|
||||
lifecycleState: binding.lifecycle_state,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: binding.created === true,
|
||||
binding: bindingView(binding),
|
||||
};
|
||||
}
|
||||
|
||||
async function revokeBinding(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"binding.manage",
|
||||
);
|
||||
const result = await client.query(
|
||||
`update device_resource_bindings
|
||||
set lifecycle_state = 'revoked',
|
||||
revoked_at = now(),
|
||||
revoked_by_ref = $3,
|
||||
revocation_code = $4,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and project_id = $2
|
||||
and lifecycle_state <> 'revoked'
|
||||
returning id, owner_scope_id, project_id, binding_key, display_name,
|
||||
source_kind, device_id, collection_id, target_kind, target_ref,
|
||||
capabilities, lifecycle_state, source_approved_at, created_at, updated_at`,
|
||||
[command.bindingId, project.id, actor.userRef, command.resolutionCode],
|
||||
);
|
||||
const binding = result.rows[0];
|
||||
if (!binding) throw domainError("device_binding_not_found", 404);
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: "device_binding.revoked",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: binding.device_id,
|
||||
payload: {
|
||||
bindingRef: `binding:${binding.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
sourceKind: binding.source_kind,
|
||||
sourceRef: bindingSourceRef(binding),
|
||||
targetKind: binding.target_kind,
|
||||
targetRef: binding.target_ref,
|
||||
lifecycleState: binding.lifecycle_state,
|
||||
resolutionCode: command.resolutionCode,
|
||||
},
|
||||
});
|
||||
return {
|
||||
revoked: true,
|
||||
binding: bindingView(binding),
|
||||
resolutionCode: command.resolutionCode,
|
||||
};
|
||||
}
|
||||
|
||||
async function createConfigurationRevision(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"configuration.manage",
|
||||
);
|
||||
const device = await findDirectDeviceForUpdate(client, command);
|
||||
const profileResult = await client.query(
|
||||
`select profile_ref, schema_artifact_ref, lifecycle_state
|
||||
from device_model_profiles
|
||||
where profile_ref = $1
|
||||
for share`,
|
||||
[device.model_profile_ref],
|
||||
);
|
||||
const profile = profileResult.rows[0];
|
||||
if (
|
||||
!profile
|
||||
|| profile.lifecycle_state !== "active"
|
||||
|| !profile.schema_artifact_ref
|
||||
) {
|
||||
throw domainError("device_configuration_profile_unavailable", 409);
|
||||
}
|
||||
const nextResult = await client.query(
|
||||
`select coalesce(max(revision_number), 0) + 1 as next_revision
|
||||
from device_configuration_revisions
|
||||
where device_id = $1`,
|
||||
[device.id],
|
||||
);
|
||||
const revisionNumber = Number(nextResult.rows[0]?.next_revision);
|
||||
if (!Number.isSafeInteger(revisionNumber) || revisionNumber < 1) {
|
||||
throw domainError("device_configuration_revision_sequence_invalid", 409);
|
||||
}
|
||||
const revisionId = randomUUID();
|
||||
const inserted = await client.query(
|
||||
`insert into device_configuration_revisions (
|
||||
id,
|
||||
owner_scope_id,
|
||||
project_id,
|
||||
device_id,
|
||||
revision_number,
|
||||
model_profile_ref,
|
||||
schema_artifact_ref,
|
||||
configuration_digest,
|
||||
configuration,
|
||||
change_summary,
|
||||
created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11)
|
||||
returning id, owner_scope_id, project_id, device_id, revision_number,
|
||||
model_profile_ref, schema_artifact_ref, configuration_digest,
|
||||
configuration, change_summary, created_at`,
|
||||
[
|
||||
revisionId,
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
device.id,
|
||||
revisionNumber,
|
||||
profile.profile_ref,
|
||||
profile.schema_artifact_ref,
|
||||
command.configurationDigest,
|
||||
JSON.stringify(command.configuration),
|
||||
command.changeSummary,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const revision = inserted.rows[0];
|
||||
if (!revision) {
|
||||
throw domainError("device_configuration_revision_insert_failed", 409);
|
||||
}
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: "device_configuration_revision.created",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: device.id,
|
||||
payload: {
|
||||
deviceRef: `device:${device.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
configurationRevisionRef: `configuration-revision:${revision.id}`,
|
||||
revisionNumber: Number(revision.revision_number),
|
||||
modelProfileRef: revision.model_profile_ref,
|
||||
schemaArtifactRef: revision.schema_artifact_ref,
|
||||
configurationDigest: revision.configuration_digest,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: true,
|
||||
configurationRevision: configurationRevisionView(revision),
|
||||
};
|
||||
}
|
||||
|
||||
async function setDesiredConfiguration(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"configuration.manage",
|
||||
);
|
||||
const device = await findDirectDeviceForUpdate(client, command);
|
||||
const revisionResult = await client.query(
|
||||
`select id, project_id, device_id, revision_number,
|
||||
model_profile_ref, schema_artifact_ref, configuration_digest,
|
||||
configuration, change_summary, created_at
|
||||
from device_configuration_revisions
|
||||
where id = $1 and device_id = $2 and project_id = $3
|
||||
for share`,
|
||||
[command.configurationRevisionId, device.id, project.id],
|
||||
);
|
||||
const revision = revisionResult.rows[0];
|
||||
if (!revision) throw domainError("device_configuration_revision_not_found", 404);
|
||||
|
||||
const currentResult = await client.query(
|
||||
`select desired_revision_id, applied_revision_id
|
||||
from device_configuration_state
|
||||
where device_id = $1
|
||||
for update`,
|
||||
[device.id],
|
||||
);
|
||||
const current = currentResult.rows[0] ?? null;
|
||||
if (current?.desired_revision_id === revision.id) {
|
||||
return {
|
||||
changed: false,
|
||||
configurationState: configurationStateView({
|
||||
device_id: device.id,
|
||||
project_id: project.id,
|
||||
desired_revision_id: revision.id,
|
||||
applied_revision_id: current.applied_revision_id,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const stateResult = await client.query(
|
||||
`insert into device_configuration_state (
|
||||
device_id,
|
||||
owner_scope_id,
|
||||
project_id,
|
||||
desired_revision_id
|
||||
) values ($1, $2, $3, $4)
|
||||
on conflict (device_id) do update set
|
||||
owner_scope_id = excluded.owner_scope_id,
|
||||
project_id = excluded.project_id,
|
||||
desired_revision_id = excluded.desired_revision_id,
|
||||
updated_at = now()
|
||||
returning device_id, project_id, desired_revision_id, applied_revision_id`,
|
||||
[device.id, project.owner_scope_id, project.id, revision.id],
|
||||
);
|
||||
const state = stateResult.rows[0];
|
||||
if (!state) throw domainError("device_configuration_state_update_failed", 409);
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: "device_configuration.desired_changed",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: device.id,
|
||||
payload: {
|
||||
deviceRef: `device:${device.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
configurationRevisionRef: `configuration-revision:${revision.id}`,
|
||||
previousConfigurationRevisionRef: current?.desired_revision_id
|
||||
? `configuration-revision:${current.desired_revision_id}`
|
||||
: null,
|
||||
configurationDigest: revision.configuration_digest,
|
||||
},
|
||||
});
|
||||
return {
|
||||
changed: true,
|
||||
configurationState: configurationStateView(state),
|
||||
};
|
||||
}
|
||||
|
||||
async function findBindingSource(client, command) {
|
||||
if (command.source.kind === "device") {
|
||||
return findDirectDeviceForUpdate(client, {
|
||||
projectId: command.projectId,
|
||||
deviceId: command.source.id,
|
||||
});
|
||||
}
|
||||
const result = await client.query(
|
||||
`select id, project_id, lifecycle_state
|
||||
from device_collections
|
||||
where id = $1 and project_id = $2
|
||||
for share`,
|
||||
[command.source.id, command.projectId],
|
||||
);
|
||||
const collection = result.rows[0];
|
||||
if (!collection) throw domainError("device_collection_not_found", 404);
|
||||
if (collection.lifecycle_state !== "active") {
|
||||
throw domainError("device_collection_inactive", 409);
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
|
||||
async function findDirectDeviceForUpdate(client, command) {
|
||||
const result = await client.query(
|
||||
`select id, contour_id, owner_scope_id, project_id,
|
||||
model_profile_ref, lifecycle_state
|
||||
from device_instances
|
||||
where id = $1
|
||||
for update`,
|
||||
[command.deviceId],
|
||||
);
|
||||
const device = result.rows[0];
|
||||
if (!device) throw domainError("device_not_found", 404);
|
||||
if (
|
||||
device.contour_id
|
||||
|| !device.owner_scope_id
|
||||
|| !device.project_id
|
||||
|| device.project_id !== command.projectId
|
||||
) {
|
||||
throw domainError("device_control_resource_project_mismatch", 409);
|
||||
}
|
||||
if (device.lifecycle_state === "retired") {
|
||||
throw domainError("device_control_resource_lifecycle_blocked", 409);
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
async function addAudit(client, {
|
||||
eventType,
|
||||
actorRef,
|
||||
projectId,
|
||||
deviceId = null,
|
||||
payload,
|
||||
}) {
|
||||
await client.query(
|
||||
`insert into device_audit_events (
|
||||
id,
|
||||
event_type,
|
||||
actor_ref,
|
||||
project_id,
|
||||
device_id,
|
||||
payload
|
||||
) values ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||||
[
|
||||
randomUUID(),
|
||||
eventType,
|
||||
actorRef,
|
||||
projectId,
|
||||
deviceId,
|
||||
JSON.stringify(payload),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function bindingView(row) {
|
||||
return {
|
||||
bindingRef: `binding:${row.id}`,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
bindingKey: row.binding_key,
|
||||
displayName: row.display_name,
|
||||
source: {
|
||||
kind: row.source_kind,
|
||||
ref: bindingSourceRef(row),
|
||||
},
|
||||
target: {
|
||||
kind: row.target_kind,
|
||||
ref: row.target_ref,
|
||||
},
|
||||
capabilities: row.capabilities ?? [],
|
||||
lifecycleState: row.lifecycle_state,
|
||||
sourceApprovedAt: toIso(row.source_approved_at),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function bindingSourceRef(row) {
|
||||
return row.source_kind === "device"
|
||||
? `device:${row.device_id}`
|
||||
: `collection:${row.collection_id}`;
|
||||
}
|
||||
|
||||
function configurationRevisionView(row) {
|
||||
return {
|
||||
configurationRevisionRef: `configuration-revision:${row.id}`,
|
||||
deviceRef: `device:${row.device_id}`,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
revisionNumber: Number(row.revision_number),
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
schemaArtifactRef: row.schema_artifact_ref,
|
||||
configurationDigest: row.configuration_digest,
|
||||
configuration: row.configuration,
|
||||
changeSummary: row.change_summary ?? null,
|
||||
createdAt: toIso(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
function configurationStateView(row) {
|
||||
return {
|
||||
deviceRef: `device:${row.device_id}`,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
desiredConfigurationRevisionRef: row.desired_revision_id
|
||||
? `configuration-revision:${row.desired_revision_id}`
|
||||
: null,
|
||||
appliedConfigurationRevisionRef: row.applied_revision_id
|
||||
? `configuration-revision:${row.applied_revision_id}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export const NDC_CREDENTIAL_REFERENCE_OWNER = "ndc_l2_credentials";
|
||||
|
||||
const CREDENTIAL_REFERENCE_PATTERN =
|
||||
/^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$/;
|
||||
|
||||
export function normalizeNdcCredentialReference(input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new TypeError("ndc_credential_reference_invalid");
|
||||
}
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!new Set(["owner", "reference"]).has(key)) {
|
||||
throw new TypeError(`ndc_credential_reference_field_unexpected:${key}`);
|
||||
}
|
||||
}
|
||||
if (input.owner !== NDC_CREDENTIAL_REFERENCE_OWNER) {
|
||||
throw new TypeError("ndc_credential_reference_owner_invalid");
|
||||
}
|
||||
if (!isNdcCredentialReferenceValue(input.reference)) {
|
||||
throw new TypeError("ndc_credential_reference_value_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
owner: NDC_CREDENTIAL_REFERENCE_OWNER,
|
||||
reference: input.reference,
|
||||
});
|
||||
}
|
||||
|
||||
export function isNdcCredentialReferenceValue(value) {
|
||||
return typeof value === "string" && CREDENTIAL_REFERENCE_PATTERN.test(value);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
export async function resolveDeviceDatabaseUrl(
|
||||
environment = process.env,
|
||||
readSecret = readFile,
|
||||
) {
|
||||
const explicit = optionalValue(environment.DEVICE_DATABASE_URL);
|
||||
if (explicit) return explicit;
|
||||
|
||||
const host = restrictedValue(
|
||||
environment.DEVICE_DATABASE_HOST,
|
||||
/^[A-Za-z0-9.-]{1,253}$/,
|
||||
"device_database_host_invalid",
|
||||
);
|
||||
const port = parsePort(environment.DEVICE_DATABASE_PORT, 5432);
|
||||
const database = restrictedValue(
|
||||
environment.DEVICE_DATABASE_NAME,
|
||||
/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/,
|
||||
"device_database_name_invalid",
|
||||
);
|
||||
const user = restrictedValue(
|
||||
environment.DEVICE_DATABASE_USER,
|
||||
/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/,
|
||||
"device_database_user_invalid",
|
||||
);
|
||||
const passwordFile = requiredValue(
|
||||
environment.DEVICE_DATABASE_PASSWORD_FILE,
|
||||
"device_database_password_file_required",
|
||||
);
|
||||
const password = (await readSecret(passwordFile, "utf8")).trim();
|
||||
if (password.length < 32 || password.length > 512) {
|
||||
throw new Error("device_database_password_invalid");
|
||||
}
|
||||
|
||||
return [
|
||||
"postgresql://",
|
||||
encodeURIComponent(user),
|
||||
":",
|
||||
encodeURIComponent(password),
|
||||
"@",
|
||||
host,
|
||||
":",
|
||||
String(port),
|
||||
"/",
|
||||
encodeURIComponent(database),
|
||||
"?sslmode=disable",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function optionalValue(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requiredValue(value, errorCode) {
|
||||
const normalized = optionalValue(value);
|
||||
if (!normalized) throw new Error(errorCode);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function restrictedValue(value, pattern, errorCode) {
|
||||
const normalized = requiredValue(value, errorCode);
|
||||
if (!pattern.test(normalized)) throw new Error(errorCode);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error("device_database_port_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { connect as connectHttp2 } from "node:http2";
|
||||
|
||||
import {
|
||||
DEVICE_EDGE_CHANNEL_LIMITS,
|
||||
DEVICE_EDGE_CHANNEL_PATH,
|
||||
createChannelEnvelope,
|
||||
createChannelEnvelopeDecoder,
|
||||
encodeChannelEnvelope,
|
||||
nextReconnectDelay,
|
||||
normalizeCertificateIdentities,
|
||||
normalizeCertificateFingerprint,
|
||||
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
||||
import {
|
||||
DEVICE_DISCOVERY_VIEW_SCHEMA,
|
||||
assertSafeProjection,
|
||||
normalizeAdapterAcceptance,
|
||||
normalizeAdapterMessage,
|
||||
normalizeDiscoverySignal,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
// Runtime-owned transport implementation; kept inside the deployable Core context.
|
||||
const CHANNEL_TRACKER_SESSION_ID = "channel:control";
|
||||
const CHANNEL_PROFILE_REF = "channel.control.v1";
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
||||
|
||||
export function createDeviceGatewayCoreChannelClient(options = {}) {
|
||||
const config = normalizeConfig(options);
|
||||
const readyWaiters = new Set();
|
||||
let running = false;
|
||||
let state = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempt = 0;
|
||||
let connectionSerial = 0;
|
||||
let totalConnectionAttempts = 0;
|
||||
let totalChannelsAccepted = 0;
|
||||
let totalReconnects = 0;
|
||||
let totalEventsAccepted = 0;
|
||||
let totalEventsRejected = 0;
|
||||
let totalProtocolFailures = 0;
|
||||
let lastErrorCode = null;
|
||||
|
||||
return Object.freeze({
|
||||
async start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
void connectNow();
|
||||
},
|
||||
async stop() {
|
||||
running = false;
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
const current = state;
|
||||
state = null;
|
||||
if (current) closeConnection(current, false);
|
||||
rejectReadyWaiters("device_gateway_core_channel_stopped");
|
||||
},
|
||||
waitForReady(timeoutMs = 5_000) {
|
||||
if (state?.ready && !state.closed) return Promise.resolve(status());
|
||||
const normalizedTimeout = normalizeInteger(
|
||||
timeoutMs,
|
||||
10,
|
||||
120_000,
|
||||
5_000,
|
||||
"ready_timeout",
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = { resolve, reject, timer: null };
|
||||
waiter.timer = setTimeout(() => {
|
||||
readyWaiters.delete(waiter);
|
||||
reject(new Error("device_gateway_core_channel_ready_timeout"));
|
||||
}, normalizedTimeout);
|
||||
waiter.timer.unref?.();
|
||||
readyWaiters.add(waiter);
|
||||
});
|
||||
},
|
||||
status,
|
||||
disconnect() {
|
||||
if (state) closeConnection(state, true);
|
||||
},
|
||||
});
|
||||
|
||||
function status() {
|
||||
return Object.freeze({
|
||||
running,
|
||||
channel: state?.ready ? "accepted" : state ? "connecting" : "absent",
|
||||
edgeRegistrationId: state?.registration?.edgeRegistrationId ?? null,
|
||||
channelGeneration: state?.channelGeneration ?? null,
|
||||
edgeTrustGeneration: state?.observedEdgeIdentity?.generationRef ?? null,
|
||||
edgeCertificateFingerprint:
|
||||
state?.observedEdgeIdentity?.fingerprint ?? null,
|
||||
negotiatedCommandTransport: state?.negotiatedCommandTransport ?? null,
|
||||
activeTrackerSessionChains: state?.sessionChains.size ?? 0,
|
||||
connectionAttempts: totalConnectionAttempts,
|
||||
channelsAccepted: totalChannelsAccepted,
|
||||
reconnects: totalReconnects,
|
||||
eventsAccepted: totalEventsAccepted,
|
||||
eventsRejected: totalEventsRejected,
|
||||
protocolFailures: totalProtocolFailures,
|
||||
lastErrorCode,
|
||||
trackerIngress: "remote-edge-only",
|
||||
commandTransport: config.commandTransport,
|
||||
});
|
||||
}
|
||||
|
||||
async function connectNow() {
|
||||
if (!running || state) return;
|
||||
totalConnectionAttempts += 1;
|
||||
const serial = ++connectionSerial;
|
||||
let registration;
|
||||
try {
|
||||
registration = normalizeRegistration(await config.registrationProvider());
|
||||
if (registration.lifecycleState !== "active") {
|
||||
throw new Error("device_gateway_core_edge_registration_inactive");
|
||||
}
|
||||
} catch (error) {
|
||||
lastErrorCode = safeErrorCode(error);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = {
|
||||
serial,
|
||||
registration,
|
||||
session: null,
|
||||
request: null,
|
||||
decoder: createChannelEnvelopeDecoder({
|
||||
direction: "edge-to-core",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
}),
|
||||
sessionChains: new Map(),
|
||||
trackerDevices: new Map(),
|
||||
channelGeneration: null,
|
||||
negotiatedCommandTransport: null,
|
||||
observedEdgeIdentity: null,
|
||||
edgeSequence: 0,
|
||||
coreSequence: 0,
|
||||
lastEdgeActivityAt: config.clock(),
|
||||
connectTimer: null,
|
||||
heartbeatTimer: null,
|
||||
ready: false,
|
||||
closed: false,
|
||||
};
|
||||
state = connection;
|
||||
const endpoint = new URL(registration.endpoint);
|
||||
const authority = `${endpoint.protocol}//${endpoint.host}`;
|
||||
const session = connectHttp2(authority, {
|
||||
key: config.tls.key,
|
||||
cert: config.tls.cert,
|
||||
ca: config.tls.ca,
|
||||
minVersion: "TLSv1.3",
|
||||
maxVersion: "TLSv1.3",
|
||||
rejectUnauthorized: true,
|
||||
servername: registration.servername,
|
||||
ALPNProtocols: ["h2"],
|
||||
settings: {
|
||||
enablePush: false,
|
||||
initialWindowSize: 1024 * 1024,
|
||||
},
|
||||
});
|
||||
connection.session = session;
|
||||
connection.connectTimer = setTimeout(() => {
|
||||
failConnection(connection, new Error(
|
||||
"device_gateway_core_channel_connect_timeout",
|
||||
));
|
||||
}, config.connectTimeoutMs);
|
||||
connection.connectTimer.unref?.();
|
||||
session.once("error", (error) => failConnection(connection, error));
|
||||
session.once("close", () => closeConnection(connection, true));
|
||||
session.once("connect", () => {
|
||||
try {
|
||||
verifyEdgePeer(connection);
|
||||
openChannelStream(connection);
|
||||
} catch (error) {
|
||||
failConnection(connection, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openChannelStream(connection) {
|
||||
assertCurrent(connection);
|
||||
const request = connection.session.request({
|
||||
":method": "POST",
|
||||
":path": DEVICE_EDGE_CHANNEL_PATH,
|
||||
"content-type": "application/x-ndjson",
|
||||
"cache-control": "no-store",
|
||||
}, { endStream: false });
|
||||
connection.request = request;
|
||||
request.once("response", (headers) => {
|
||||
if (Number(headers[":status"]) !== 200) {
|
||||
failConnection(connection, new Error(
|
||||
`device_gateway_core_channel_http_status_${headers[":status"]}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
let processing = Promise.resolve();
|
||||
request.on("data", (chunk) => {
|
||||
request.pause();
|
||||
processing = processing
|
||||
.then(async () => {
|
||||
const envelopes = connection.decoder.push(chunk);
|
||||
for (const envelope of envelopes) {
|
||||
await handleEdgeEnvelope(connection, envelope);
|
||||
}
|
||||
})
|
||||
.catch((error) => failConnection(connection, error))
|
||||
.finally(() => {
|
||||
if (!connection.closed) request.resume();
|
||||
});
|
||||
});
|
||||
request.once("aborted", () => closeConnection(connection, true));
|
||||
request.once("close", () => closeConnection(connection, true));
|
||||
request.once("error", (error) => failConnection(connection, error));
|
||||
connection.heartbeatTimer = setInterval(
|
||||
() => checkChannelHealth(connection),
|
||||
config.keepaliveMs,
|
||||
);
|
||||
connection.heartbeatTimer.unref?.();
|
||||
}
|
||||
|
||||
async function handleEdgeEnvelope(connection, envelope) {
|
||||
assertCurrent(connection);
|
||||
if (
|
||||
envelope.edgeRegistrationId !== connection.registration.edgeRegistrationId
|
||||
|| envelope.sequence !== connection.edgeSequence + 1
|
||||
) {
|
||||
throw new Error("device_gateway_core_edge_envelope_mismatch");
|
||||
}
|
||||
if (
|
||||
connection.channelGeneration
|
||||
&& envelope.channelGeneration !== connection.channelGeneration
|
||||
) {
|
||||
throw new Error("device_gateway_core_channel_generation_mismatch");
|
||||
}
|
||||
connection.edgeSequence = envelope.sequence;
|
||||
connection.lastEdgeActivityAt = config.clock();
|
||||
|
||||
if (!connection.ready) {
|
||||
if (envelope.messageKind !== "channel.hello") {
|
||||
throw new Error("device_gateway_core_channel_hello_required");
|
||||
}
|
||||
if (
|
||||
envelope.channelGeneration !== connection.registration.channelGeneration
|
||||
) {
|
||||
throw new Error("device_gateway_core_channel_generation_mismatch");
|
||||
}
|
||||
if (
|
||||
envelope.payload?.status !== "ready"
|
||||
|| envelope.payload?.transport !== "http2-mtls"
|
||||
|| envelope.payload?.trustGeneration
|
||||
!== connection.observedEdgeIdentity?.generationRef
|
||||
|| !isCompatibleCommandTransport(
|
||||
config.commandTransport,
|
||||
envelope.payload?.commandTransport,
|
||||
)
|
||||
) {
|
||||
throw new Error("device_gateway_core_channel_hello_invalid");
|
||||
}
|
||||
connection.negotiatedCommandTransport = envelope.payload.commandTransport;
|
||||
connection.channelGeneration = connection.registration.channelGeneration;
|
||||
send(connection, "channel.accepted", {
|
||||
status: "accepted",
|
||||
coreIdentity: config.coreIdentity,
|
||||
commandTransport: connection.negotiatedCommandTransport,
|
||||
}, {
|
||||
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
|
||||
adapterProfileRef: CHANNEL_PROFILE_REF,
|
||||
correlationId: envelope.correlationId,
|
||||
});
|
||||
connection.ready = true;
|
||||
clearTimeout(connection.connectTimer);
|
||||
connection.connectTimer = null;
|
||||
reconnectAttempt = 0;
|
||||
totalChannelsAccepted += 1;
|
||||
lastErrorCode = null;
|
||||
resolveReadyWaiters();
|
||||
return;
|
||||
}
|
||||
if (envelope.messageKind === "channel.heartbeat") return;
|
||||
if (["discovery.observed", "adapter.message", "command.status"].includes(envelope.messageKind)) {
|
||||
scheduleTrackerEvent(connection, envelope);
|
||||
return;
|
||||
}
|
||||
throw new Error("device_gateway_core_edge_message_unhandled");
|
||||
}
|
||||
|
||||
function scheduleTrackerEvent(connection, envelope) {
|
||||
if (envelope.trackerSessionId === CHANNEL_TRACKER_SESSION_ID) {
|
||||
throw new Error("device_gateway_core_tracker_session_invalid");
|
||||
}
|
||||
const previous = connection.sessionChains.get(envelope.trackerSessionId);
|
||||
if (!previous && connection.sessionChains.size >= 128) {
|
||||
throw new Error("device_gateway_core_tracker_session_limit_reached");
|
||||
}
|
||||
const work = (previous ?? Promise.resolve())
|
||||
.then(() => envelope.messageKind === "discovery.observed"
|
||||
? acceptDiscovery(connection, envelope)
|
||||
: envelope.messageKind === "adapter.message"
|
||||
? acceptAdapterMessage(connection, envelope)
|
||||
: acceptCommandStatus(connection, envelope))
|
||||
.catch((error) => failConnection(connection, error))
|
||||
.finally(() => {
|
||||
if (connection.sessionChains.get(envelope.trackerSessionId) === work) {
|
||||
connection.sessionChains.delete(envelope.trackerSessionId);
|
||||
}
|
||||
});
|
||||
connection.sessionChains.set(envelope.trackerSessionId, work);
|
||||
}
|
||||
|
||||
async function acceptDiscovery(connection, envelope) {
|
||||
try {
|
||||
const signal = normalizeDiscoverySignal(envelope.payload?.signal);
|
||||
const receipt = normalizeDiscoveryReceipt(
|
||||
await config.observeDiscovery(signal),
|
||||
);
|
||||
if (receipt.claimedDeviceRef) {
|
||||
connection.trackerDevices.set(
|
||||
envelope.trackerSessionId,
|
||||
receipt.claimedDeviceRef,
|
||||
);
|
||||
} else {
|
||||
connection.trackerDevices.delete(envelope.trackerSessionId);
|
||||
}
|
||||
const commandOffer = (
|
||||
connection.negotiatedCommandTransport === "typed-service-ping-v1"
|
||||
&& receipt.claimedDeviceRef
|
||||
)
|
||||
? await config.offerCommand(receipt.claimedDeviceRef)
|
||||
: null;
|
||||
sendEventResult(connection, envelope, {
|
||||
discovery: receipt.discovery,
|
||||
...(commandOffer ? { commandOffer } : {}),
|
||||
});
|
||||
totalEventsAccepted += 1;
|
||||
} catch (error) {
|
||||
sendEventRejection(connection, envelope, error);
|
||||
totalEventsRejected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptAdapterMessage(connection, envelope) {
|
||||
try {
|
||||
const message = normalizeAdapterMessage(envelope.payload?.message, {
|
||||
maxBytes: config.maxEnvelopeBytes,
|
||||
});
|
||||
const receipt = normalizeAdapterReceipt(
|
||||
await config.acceptMessage(message),
|
||||
);
|
||||
if (receipt.claimedDeviceRef) {
|
||||
connection.trackerDevices.set(
|
||||
envelope.trackerSessionId,
|
||||
receipt.claimedDeviceRef,
|
||||
);
|
||||
}
|
||||
const claimedDeviceRef = receipt.claimedDeviceRef
|
||||
?? connection.trackerDevices.get(envelope.trackerSessionId);
|
||||
const commandOffer = (
|
||||
connection.negotiatedCommandTransport === "typed-service-ping-v1"
|
||||
&& claimedDeviceRef
|
||||
)
|
||||
? await config.offerCommand(claimedDeviceRef)
|
||||
: null;
|
||||
sendEventResult(connection, envelope, {
|
||||
acceptance: receipt.acceptance,
|
||||
...(commandOffer ? { commandOffer } : {}),
|
||||
});
|
||||
totalEventsAccepted += 1;
|
||||
} catch (error) {
|
||||
sendEventRejection(connection, envelope, error);
|
||||
totalEventsRejected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptCommandStatus(connection, envelope) {
|
||||
try {
|
||||
await config.recordCommandStatus(envelope.payload?.status);
|
||||
sendEventResult(connection, envelope, { status: "recorded" });
|
||||
totalEventsAccepted += 1;
|
||||
} catch (error) {
|
||||
sendEventRejection(connection, envelope, error);
|
||||
totalEventsRejected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function sendEventResult(connection, envelope, result) {
|
||||
send(connection, "event.accepted", { result }, {
|
||||
trackerSessionId: envelope.trackerSessionId,
|
||||
adapterProfileRef: envelope.adapterProfileRef,
|
||||
correlationId: envelope.correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
function sendEventRejection(connection, envelope, error) {
|
||||
send(connection, "event.rejected", {
|
||||
errorCode: safeErrorCode(error),
|
||||
}, {
|
||||
trackerSessionId: envelope.trackerSessionId,
|
||||
adapterProfileRef: envelope.adapterProfileRef,
|
||||
correlationId: envelope.correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
function send(connection, messageKind, payload, metadata) {
|
||||
assertCurrent(connection);
|
||||
if (!connection.channelGeneration) {
|
||||
throw new Error("device_gateway_core_channel_generation_absent");
|
||||
}
|
||||
connection.coreSequence += 1;
|
||||
const now = config.now();
|
||||
const envelope = createChannelEnvelope({
|
||||
edgeRegistrationId: connection.registration.edgeRegistrationId,
|
||||
channelGeneration: connection.channelGeneration,
|
||||
trackerSessionId: metadata.trackerSessionId,
|
||||
adapterProfileRef: metadata.adapterProfileRef,
|
||||
sequence: connection.coreSequence,
|
||||
eventAt: metadata.eventAt ?? now,
|
||||
receivedAt: now,
|
||||
messageKind,
|
||||
correlationId: metadata.correlationId,
|
||||
payload,
|
||||
}, {
|
||||
direction: "core-to-edge",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
});
|
||||
connection.request.write(encodeChannelEnvelope(envelope, {
|
||||
direction: "core-to-edge",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
}));
|
||||
}
|
||||
|
||||
function checkChannelHealth(connection) {
|
||||
if (connection.closed || state !== connection) return;
|
||||
if (config.clock() - connection.lastEdgeActivityAt >= config.deadPeerMs) {
|
||||
failConnection(connection, new Error("device_gateway_core_edge_dead_peer"));
|
||||
return;
|
||||
}
|
||||
if (connection.ready) {
|
||||
try {
|
||||
send(connection, "channel.heartbeat", { status: "alive" }, {
|
||||
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
|
||||
adapterProfileRef: CHANNEL_PROFILE_REF,
|
||||
correlationId: `correlation:${randomUUID()}`,
|
||||
});
|
||||
} catch (error) {
|
||||
failConnection(connection, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyEdgePeer(connection) {
|
||||
const socket = connection.session.socket;
|
||||
if (!socket?.authorized || socket.alpnProtocol !== "h2") {
|
||||
throw new Error("device_gateway_core_edge_tls_unauthorized");
|
||||
}
|
||||
const observed = normalizeCertificateFingerprint(
|
||||
socket.getPeerCertificate()?.fingerprint256,
|
||||
);
|
||||
const identity = connection.registration.certificateIdentities.find(
|
||||
(candidate) => candidate.fingerprint === observed,
|
||||
);
|
||||
if (!identity) {
|
||||
throw new Error("device_gateway_core_edge_identity_mismatch");
|
||||
}
|
||||
connection.observedEdgeIdentity = identity;
|
||||
}
|
||||
|
||||
function failConnection(connection, error) {
|
||||
if (connection.closed) return;
|
||||
totalProtocolFailures += 1;
|
||||
lastErrorCode = safeErrorCode(error);
|
||||
closeConnection(connection, true);
|
||||
}
|
||||
|
||||
function closeConnection(connection, reconnect) {
|
||||
if (connection.closed) return;
|
||||
connection.closed = true;
|
||||
clearTimeout(connection.connectTimer);
|
||||
connection.connectTimer = null;
|
||||
clearInterval(connection.heartbeatTimer);
|
||||
connection.heartbeatTimer = null;
|
||||
connection.sessionChains.clear();
|
||||
try {
|
||||
connection.request?.close();
|
||||
} catch {}
|
||||
try {
|
||||
connection.session?.close();
|
||||
} catch {}
|
||||
if (state === connection) state = null;
|
||||
if (reconnect && running) scheduleReconnect();
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!running || reconnectTimer || state) return;
|
||||
const delay = nextReconnectDelay(reconnectAttempt, {
|
||||
minimumMs: config.reconnectMinimumMs,
|
||||
maximumMs: config.reconnectMaximumMs,
|
||||
random: config.random,
|
||||
});
|
||||
reconnectAttempt += 1;
|
||||
totalReconnects += 1;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
void connectNow();
|
||||
}, delay);
|
||||
reconnectTimer.unref?.();
|
||||
}
|
||||
|
||||
function assertCurrent(connection) {
|
||||
if (!running || connection.closed || state !== connection) {
|
||||
throw new Error("device_gateway_core_channel_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReadyWaiters() {
|
||||
const value = status();
|
||||
for (const waiter of readyWaiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(value);
|
||||
}
|
||||
readyWaiters.clear();
|
||||
}
|
||||
|
||||
function rejectReadyWaiters(code) {
|
||||
for (const waiter of readyWaiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error(code));
|
||||
}
|
||||
readyWaiters.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDiscoveryReceipt(input) {
|
||||
if (
|
||||
!input
|
||||
|| typeof input !== "object"
|
||||
|| Array.isArray(input)
|
||||
|| typeof input.created !== "boolean"
|
||||
) {
|
||||
throw new TypeError("device_gateway_core_discovery_receipt_invalid");
|
||||
}
|
||||
const discovery = assertSafeProjection(input.value);
|
||||
if (
|
||||
discovery.schemaVersion !== DEVICE_DISCOVERY_VIEW_SCHEMA
|
||||
|| !["quarantine", "claimed"].includes(discovery.lifecycleState)
|
||||
|| discovery.commandTransport !== "disabled"
|
||||
) {
|
||||
throw new TypeError("device_gateway_core_discovery_receipt_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
discovery,
|
||||
claimedDeviceRef: input.claimedDeviceRef ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAdapterReceipt(input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new TypeError("device_gateway_core_adapter_receipt_invalid");
|
||||
}
|
||||
const acceptanceValue = input.value ?? (
|
||||
input.schemaVersion === "nodedc.device-adapter-acceptance.v1"
|
||||
? input
|
||||
: null
|
||||
);
|
||||
return Object.freeze({
|
||||
acceptance: normalizeAdapterAcceptance(acceptanceValue),
|
||||
claimedDeviceRef: input.claimedDeviceRef ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeConfig(options) {
|
||||
if (typeof options.observeDiscovery !== "function") {
|
||||
throw new TypeError("device_gateway_core_observe_discovery_invalid");
|
||||
}
|
||||
if (typeof options.acceptMessage !== "function") {
|
||||
throw new TypeError("device_gateway_core_accept_message_invalid");
|
||||
}
|
||||
const commandTransport = options.commandTransport ?? "disabled";
|
||||
if (!["disabled", "typed-service-ping-v1"].includes(commandTransport)) {
|
||||
throw new TypeError("device_gateway_core_command_transport_invalid");
|
||||
}
|
||||
const offerCommand = options.offerCommand ?? (async () => null);
|
||||
const recordCommandStatus = options.recordCommandStatus ?? (async () => undefined);
|
||||
if (typeof offerCommand !== "function" || typeof recordCommandStatus !== "function") {
|
||||
throw new TypeError("device_gateway_core_command_runtime_invalid");
|
||||
}
|
||||
const registrationProvider = typeof options.registrationProvider === "function"
|
||||
? options.registrationProvider
|
||||
: async () => options.registration;
|
||||
const tls = normalizeTls(options.tls);
|
||||
const keepaliveMs = normalizeInteger(
|
||||
options.keepaliveMs,
|
||||
10,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs,
|
||||
"keepalive",
|
||||
);
|
||||
const deadPeerMs = normalizeInteger(
|
||||
options.deadPeerMs,
|
||||
keepaliveMs * 2,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.deadPeerMs,
|
||||
"dead_peer",
|
||||
);
|
||||
const reconnectMinimumMs = normalizeInteger(
|
||||
options.reconnectMinimumMs,
|
||||
10,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs,
|
||||
"reconnect_minimum",
|
||||
);
|
||||
const reconnectMaximumMs = normalizeInteger(
|
||||
options.reconnectMaximumMs,
|
||||
10,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs,
|
||||
"reconnect_maximum",
|
||||
);
|
||||
if (reconnectMaximumMs < reconnectMinimumMs) {
|
||||
throw new TypeError("device_gateway_core_reconnect_range_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
registrationProvider,
|
||||
tls,
|
||||
coreIdentity: normalizeRef(options.coreIdentity, "core_identity"),
|
||||
observeDiscovery: options.observeDiscovery,
|
||||
acceptMessage: options.acceptMessage,
|
||||
commandTransport,
|
||||
offerCommand,
|
||||
recordCommandStatus,
|
||||
keepaliveMs,
|
||||
deadPeerMs,
|
||||
connectTimeoutMs: normalizeInteger(
|
||||
options.connectTimeoutMs,
|
||||
10,
|
||||
120_000,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
"connect_timeout",
|
||||
),
|
||||
reconnectMinimumMs,
|
||||
reconnectMaximumMs,
|
||||
maxEnvelopeBytes: normalizeInteger(
|
||||
options.maxEnvelopeBytes,
|
||||
256,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
"max_envelope_bytes",
|
||||
),
|
||||
random: typeof options.random === "function" ? options.random : Math.random,
|
||||
clock: typeof options.clock === "function" ? options.clock : Date.now,
|
||||
now: typeof options.now === "function"
|
||||
? () => new Date(options.now()).toISOString()
|
||||
: () => new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRegistration(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_gateway_core_edge_registration_invalid");
|
||||
}
|
||||
let endpoint;
|
||||
try {
|
||||
endpoint = new URL(String(value.endpoint || ""));
|
||||
} catch {
|
||||
throw new TypeError("device_gateway_core_edge_endpoint_invalid");
|
||||
}
|
||||
if (
|
||||
endpoint.protocol !== "https:"
|
||||
|| endpoint.username
|
||||
|| endpoint.password
|
||||
|| endpoint.pathname !== "/"
|
||||
|| endpoint.search
|
||||
|| endpoint.hash
|
||||
) {
|
||||
throw new TypeError("device_gateway_core_edge_endpoint_invalid");
|
||||
}
|
||||
if (!["active", "revoked", "disabled"].includes(value.lifecycleState)) {
|
||||
throw new TypeError("device_gateway_core_edge_lifecycle_invalid");
|
||||
}
|
||||
const servername = String(value.servername || "");
|
||||
if (!/^[A-Za-z0-9.-]{1,253}$/.test(servername)) {
|
||||
throw new TypeError("device_gateway_core_edge_servername_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
edgeRegistrationId: normalizeRef(
|
||||
value.edgeRegistrationId,
|
||||
"edge_registration_id",
|
||||
),
|
||||
channelGeneration: normalizeRef(
|
||||
value.channelGeneration,
|
||||
"channel_generation",
|
||||
),
|
||||
endpoint: endpoint.toString(),
|
||||
servername,
|
||||
certificateIdentities: normalizeCertificateIdentities(
|
||||
value.certificateIdentities,
|
||||
),
|
||||
lifecycleState: value.lifecycleState,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTls(value) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new TypeError("device_gateway_core_channel_tls_invalid");
|
||||
}
|
||||
for (const key of ["key", "cert", "ca"]) {
|
||||
if (!(typeof value[key] === "string" || Buffer.isBuffer(value[key]))) {
|
||||
throw new TypeError(`device_gateway_core_channel_tls_${key}_invalid`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({ key: value.key, cert: value.cert, ca: value.ca });
|
||||
}
|
||||
|
||||
function safeErrorCode(error) {
|
||||
const value = String(error?.message || error || "device_gateway_core_error")
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9._:-]/g, "_")
|
||||
.slice(0, 128);
|
||||
return /^[a-z][a-z0-9._:-]{2,127}$/.test(value)
|
||||
? value
|
||||
: "device_gateway_core_event_rejected";
|
||||
}
|
||||
|
||||
function isCompatibleCommandTransport(configured, offered) {
|
||||
if (offered === configured) return true;
|
||||
return configured === "typed-service-ping-v1" && offered === "disabled";
|
||||
}
|
||||
|
||||
function normalizeRef(value, field) {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
|
||||
throw new TypeError(`device_gateway_core_${field}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeInteger(value, minimum, maximum, fallback, field) {
|
||||
const number = value == null ? fallback : Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
|
||||
throw new TypeError(`device_gateway_core_${field}_invalid`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export async function observeQuarantineDiscovery({
|
||||
pool,
|
||||
identifierDigest,
|
||||
safeView,
|
||||
sessionRef,
|
||||
routeRef = null,
|
||||
}) {
|
||||
if ((safeView.routeRef ?? null) !== routeRef) {
|
||||
throw new TypeError("device_discovery_route_ref_mismatch");
|
||||
}
|
||||
const routeId = routeRef == null ? null : parseEntityRef(routeRef, "route");
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
const route = routeId == null
|
||||
? null
|
||||
: await findActiveRoute(client, routeId, safeView);
|
||||
const enrollment = route == null
|
||||
? null
|
||||
: await findMatchingEnrollment(client, {
|
||||
route,
|
||||
identifierDigest,
|
||||
safeView,
|
||||
});
|
||||
const result = await client.query(
|
||||
`insert into device_discoveries (
|
||||
id,
|
||||
identifier_kind,
|
||||
identifier_digest,
|
||||
identifier_masked,
|
||||
model_profile_ref,
|
||||
protocol,
|
||||
lifecycle_state,
|
||||
first_observed_at,
|
||||
last_observed_at,
|
||||
evidence,
|
||||
session_ref,
|
||||
project_id,
|
||||
route_id,
|
||||
enrollment_intent_id
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb,
|
||||
$9, $10, $11, $12
|
||||
)
|
||||
on conflict (identifier_kind, identifier_digest, model_profile_ref)
|
||||
do update set
|
||||
last_observed_at = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
and excluded.route_id is distinct from device_discoveries.route_id
|
||||
then device_discoveries.last_observed_at
|
||||
else greatest(
|
||||
device_discoveries.last_observed_at,
|
||||
excluded.last_observed_at
|
||||
)
|
||||
end,
|
||||
evidence = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
and excluded.route_id is distinct from device_discoveries.route_id
|
||||
then device_discoveries.evidence
|
||||
else excluded.evidence
|
||||
end,
|
||||
session_ref = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
and excluded.route_id is distinct from device_discoveries.route_id
|
||||
then device_discoveries.session_ref
|
||||
else excluded.session_ref
|
||||
end,
|
||||
project_id = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
then device_discoveries.project_id
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and (
|
||||
device_discoveries.enrollment_intent_id is null
|
||||
or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id
|
||||
or device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
) then excluded.project_id
|
||||
when device_discoveries.project_id is null
|
||||
then excluded.project_id
|
||||
else device_discoveries.project_id
|
||||
end,
|
||||
route_id = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
then device_discoveries.route_id
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and (
|
||||
device_discoveries.enrollment_intent_id is null
|
||||
or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id
|
||||
or device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
) then excluded.route_id
|
||||
when device_discoveries.route_id is null
|
||||
then excluded.route_id
|
||||
else device_discoveries.route_id
|
||||
end,
|
||||
enrollment_intent_id = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
then device_discoveries.enrollment_intent_id
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and (
|
||||
device_discoveries.enrollment_intent_id is null
|
||||
or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id
|
||||
or device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
) then excluded.enrollment_intent_id
|
||||
else device_discoveries.enrollment_intent_id
|
||||
end,
|
||||
lifecycle_state = case
|
||||
when device_discoveries.lifecycle_state = 'claimed' then 'claimed'
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
then 'quarantine'
|
||||
else device_discoveries.lifecycle_state
|
||||
end,
|
||||
resolution_code = case
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
then null
|
||||
else device_discoveries.resolution_code
|
||||
end,
|
||||
resolved_at = case
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
then null
|
||||
else device_discoveries.resolved_at
|
||||
end,
|
||||
resolved_by_ref = case
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
then null
|
||||
else device_discoveries.resolved_by_ref
|
||||
end,
|
||||
updated_at = now()
|
||||
returning id, lifecycle_state, model_profile_ref, protocol,
|
||||
identifier_kind, identifier_masked, first_observed_at,
|
||||
last_observed_at, evidence, project_id, route_id,
|
||||
enrollment_intent_id, claimed_device_id, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.identifier.masked,
|
||||
safeView.modelProfileRef,
|
||||
safeView.protocol,
|
||||
safeView.observedAt,
|
||||
JSON.stringify(safeView.evidence),
|
||||
sessionRef,
|
||||
route?.project_id ?? null,
|
||||
route?.id ?? null,
|
||||
enrollment?.id ?? null,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (
|
||||
enrollment
|
||||
&& row.enrollment_intent_id !== enrollment.id
|
||||
) {
|
||||
throw domainError("device_discovery_enrollment_conflict", 409);
|
||||
}
|
||||
if (enrollment && row.lifecycle_state === "quarantine") {
|
||||
const observed = await client.query(
|
||||
`update device_enrollment_intents
|
||||
set lifecycle_state = 'observed',
|
||||
observed_discovery_id = $2,
|
||||
observed_at = greatest(coalesce(observed_at, $3), $3),
|
||||
resolution_code = null,
|
||||
resolved_at = null,
|
||||
resolved_by_ref = null,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and lifecycle_state in ('pending', 'observed')
|
||||
returning id`,
|
||||
[enrollment.id, row.id, safeView.observedAt],
|
||||
);
|
||||
if (!observed.rows[0]) {
|
||||
throw domainError("device_enrollment_not_observable", 409);
|
||||
}
|
||||
}
|
||||
await client.query("commit");
|
||||
return {
|
||||
created: row.created === true,
|
||||
value: discoveryView(row),
|
||||
claimedDeviceRef: row.claimed_device_id
|
||||
? `device:${row.claimed_device_id}`
|
||||
: null,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query("rollback").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function findActiveRoute(client, routeId, safeView) {
|
||||
const result = await client.query(
|
||||
`select id, project_id, model_profile_ref, protocol, lifecycle_state
|
||||
from device_routes
|
||||
where id = $1
|
||||
for share`,
|
||||
[routeId],
|
||||
);
|
||||
const route = result.rows[0];
|
||||
if (!route) throw domainError("device_discovery_route_not_found", 404);
|
||||
if (route.lifecycle_state !== "active") {
|
||||
throw domainError("device_discovery_route_inactive", 409);
|
||||
}
|
||||
if (
|
||||
route.model_profile_ref !== safeView.modelProfileRef
|
||||
|| route.protocol !== safeView.protocol
|
||||
) {
|
||||
throw domainError("device_discovery_route_profile_mismatch", 409);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
async function findMatchingEnrollment(client, {
|
||||
route,
|
||||
identifierDigest,
|
||||
safeView,
|
||||
}) {
|
||||
await client.query(
|
||||
`update device_enrollment_intents
|
||||
set lifecycle_state = 'expired',
|
||||
resolution_code = 'deadline_elapsed',
|
||||
resolved_at = $6,
|
||||
updated_at = now()
|
||||
where project_id = $1
|
||||
and route_id = $2
|
||||
and model_profile_ref = $3
|
||||
and expected_identifier_kind = $4
|
||||
and expected_identifier_digest = $5
|
||||
and lifecycle_state = 'pending'
|
||||
and expires_at is not null
|
||||
and expires_at <= $6`,
|
||||
[
|
||||
route.project_id,
|
||||
route.id,
|
||||
safeView.modelProfileRef,
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.observedAt,
|
||||
],
|
||||
);
|
||||
const result = await client.query(
|
||||
`select id, project_id, route_id, model_profile_ref, lifecycle_state
|
||||
from device_enrollment_intents
|
||||
where project_id = $1
|
||||
and route_id = $2
|
||||
and model_profile_ref = $3
|
||||
and expected_identifier_kind = $4
|
||||
and expected_identifier_digest = $5
|
||||
and lifecycle_state in ('pending', 'observed')
|
||||
and (expires_at is null or expires_at > $6)
|
||||
for update`,
|
||||
[
|
||||
route.project_id,
|
||||
route.id,
|
||||
safeView.modelProfileRef,
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.observedAt,
|
||||
],
|
||||
);
|
||||
if (result.rows.length > 1) {
|
||||
throw domainError("device_enrollment_identity_ambiguous", 409);
|
||||
}
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
function discoveryView(row) {
|
||||
return {
|
||||
schemaVersion: "nodedc.device.discovery-view.v1",
|
||||
discoveryRef: `discovery:${row.id}`,
|
||||
...(row.route_id ? { routeRef: `route:${row.route_id}` } : {}),
|
||||
...(row.enrollment_intent_id
|
||||
? { enrollmentIntentRef: `enrollment-intent:${row.enrollment_intent_id}` }
|
||||
: {}),
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
protocol: row.protocol,
|
||||
observedAt: new Date(row.last_observed_at).toISOString(),
|
||||
lifecycleState: row.lifecycle_state,
|
||||
identifier: {
|
||||
kind: row.identifier_kind,
|
||||
masked: row.identifier_masked,
|
||||
},
|
||||
evidence: row.evidence,
|
||||
commandTransport: "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
function parseEntityRef(value, prefix) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
}
|
||||
const match = 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 new TypeError(`device_${prefix}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { createHash, X509Certificate } from "node:crypto";
|
||||
import { lstat, readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
normalizeCertificateFingerprint,
|
||||
normalizeCertificateIdentities,
|
||||
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
||||
import {
|
||||
createDeviceGatewayCoreChannelClient,
|
||||
} from "./device-gateway-core-runtime.mjs";
|
||||
|
||||
const DEFAULT_TRUST_ROOT = "/run/nodedc-secrets/device-edge-channel/peers";
|
||||
|
||||
export function createDeviceEdgeChannelSupervisor(options = {}) {
|
||||
const config = normalizeConfiguration(options);
|
||||
const clients = new Map();
|
||||
const failures = new Map();
|
||||
let running = false;
|
||||
let timer = null;
|
||||
let reconcilePromise = null;
|
||||
let requestedCount = 0;
|
||||
let reconciliationFailures = 0;
|
||||
let lastErrorCode = null;
|
||||
|
||||
return Object.freeze({
|
||||
async start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
await reconcile();
|
||||
schedule();
|
||||
},
|
||||
async stop() {
|
||||
running = false;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
if (reconcilePromise) await reconcilePromise.catch(() => undefined);
|
||||
const stopping = [...clients.values()].map(({ client }) => client.stop());
|
||||
clients.clear();
|
||||
failures.clear();
|
||||
await Promise.allSettled(stopping);
|
||||
},
|
||||
reconcile,
|
||||
status,
|
||||
});
|
||||
|
||||
async function reconcile() {
|
||||
if (!running) return status();
|
||||
if (reconcilePromise) return reconcilePromise;
|
||||
reconcilePromise = performReconcile().finally(() => {
|
||||
reconcilePromise = null;
|
||||
});
|
||||
return reconcilePromise;
|
||||
}
|
||||
|
||||
async function performReconcile() {
|
||||
let registrations;
|
||||
try {
|
||||
registrations = await config.repository
|
||||
.listActiveEdgeChannelRegistrations(config.maxEdges);
|
||||
if (!Array.isArray(registrations) || registrations.length > config.maxEdges) {
|
||||
throw new TypeError("device_edge_channel_registration_set_invalid");
|
||||
}
|
||||
registrations = registrations.map(normalizeRegistration);
|
||||
if (new Set(registrations.map((item) => item.edgeRegistrationId)).size
|
||||
!== registrations.length) {
|
||||
throw new TypeError("device_edge_channel_registration_set_invalid");
|
||||
}
|
||||
requestedCount = registrations.length;
|
||||
} catch (error) {
|
||||
reconciliationFailures += 1;
|
||||
lastErrorCode = safeErrorCode(error);
|
||||
return status();
|
||||
}
|
||||
|
||||
const desiredIds = new Set(registrations.map((item) => item.edgeRegistrationId));
|
||||
for (const [edgeRegistrationId, active] of clients) {
|
||||
if (!desiredIds.has(edgeRegistrationId)) {
|
||||
clients.delete(edgeRegistrationId);
|
||||
await active.client.stop().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
for (const edgeRegistrationId of failures.keys()) {
|
||||
if (!desiredIds.has(edgeRegistrationId)) failures.delete(edgeRegistrationId);
|
||||
}
|
||||
|
||||
for (const registration of registrations) {
|
||||
const digest = registrationDigest(registration);
|
||||
const current = clients.get(registration.edgeRegistrationId);
|
||||
if (current?.digest === digest) {
|
||||
failures.delete(registration.edgeRegistrationId);
|
||||
continue;
|
||||
}
|
||||
if (current) {
|
||||
clients.delete(registration.edgeRegistrationId);
|
||||
await current.client.stop().catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
const ca = await config.readPeerTrust({
|
||||
registration,
|
||||
trustRoot: config.trustRoot,
|
||||
});
|
||||
const client = config.clientFactory({
|
||||
registration,
|
||||
tls: {
|
||||
key: config.coreIdentity.key,
|
||||
cert: config.coreIdentity.cert,
|
||||
ca,
|
||||
},
|
||||
coreIdentity: config.coreIdentity.identityRef,
|
||||
observeDiscovery: (signal) => config.gatewayIngest.observeDiscovery(
|
||||
signal,
|
||||
{ authenticatedEdgeRef: registration.edgeRegistrationId },
|
||||
),
|
||||
acceptMessage: (message) => config.gatewayIngest.acceptMessage(
|
||||
message,
|
||||
{ authenticatedEdgeRef: registration.edgeRegistrationId },
|
||||
),
|
||||
commandTransport: config.typedCommandRuntime
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled",
|
||||
offerCommand: config.typedCommandRuntime?.offerForDevice,
|
||||
recordCommandStatus: config.typedCommandRuntime?.recordStatus,
|
||||
});
|
||||
assertClient(client);
|
||||
clients.set(registration.edgeRegistrationId, { client, digest });
|
||||
failures.delete(registration.edgeRegistrationId);
|
||||
await client.start();
|
||||
} catch (error) {
|
||||
const code = safeErrorCode(error);
|
||||
failures.set(registration.edgeRegistrationId, code);
|
||||
lastErrorCode = code;
|
||||
}
|
||||
}
|
||||
return status();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (!running) return;
|
||||
timer = setTimeout(async () => {
|
||||
timer = null;
|
||||
await reconcile().catch(() => undefined);
|
||||
schedule();
|
||||
}, config.reconcileIntervalMs);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
function status() {
|
||||
let accepted = 0;
|
||||
let connecting = 0;
|
||||
let degraded = failures.size;
|
||||
const edges = [];
|
||||
for (const [edgeRegistrationId, { client }] of clients) {
|
||||
const clientStatus = client.status();
|
||||
if (clientStatus.channel === "accepted") accepted += 1;
|
||||
else connecting += 1;
|
||||
if (clientStatus.lastErrorCode) degraded += 1;
|
||||
edges.push(Object.freeze({
|
||||
edgeRegistrationId,
|
||||
channel: clientStatus.channel,
|
||||
lastErrorCode: clientStatus.lastErrorCode ?? null,
|
||||
}));
|
||||
}
|
||||
for (const [edgeRegistrationId, code] of failures) {
|
||||
edges.push(Object.freeze({
|
||||
edgeRegistrationId,
|
||||
channel: "absent",
|
||||
lastErrorCode: code,
|
||||
}));
|
||||
}
|
||||
edges.sort((left, right) =>
|
||||
left.edgeRegistrationId.localeCompare(right.edgeRegistrationId)
|
||||
);
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
running,
|
||||
configured: requestedCount,
|
||||
accepted,
|
||||
connecting,
|
||||
degraded,
|
||||
reconciliationFailures,
|
||||
lastErrorCode,
|
||||
commandTransport: config.typedCommandRuntime
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled",
|
||||
edges: Object.freeze(edges),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPinnedEdgeTrust({ registration, trustRoot }) {
|
||||
const match = registration.trustBundleRef.match(
|
||||
/^edge-trust:([a-z][a-z0-9-]{1,62})$/,
|
||||
);
|
||||
if (!match) throw new TypeError("device_edge_channel_trust_bundle_ref_invalid");
|
||||
const root = resolve(trustRoot);
|
||||
const path = resolve(root, `${match[1]}.pem`);
|
||||
if (dirname(path) !== root) {
|
||||
throw new TypeError("device_edge_channel_trust_bundle_path_invalid");
|
||||
}
|
||||
const state = await lstat(path);
|
||||
if (state.isSymbolicLink() || !state.isFile() || state.size < 1 || state.size > 64 * 1024) {
|
||||
throw new Error("device_edge_channel_trust_bundle_file_invalid");
|
||||
}
|
||||
const pem = await readFile(path);
|
||||
const blocks = pem.toString("utf8").match(
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g,
|
||||
);
|
||||
if (!blocks || blocks.length < 1 || blocks.length > 2) {
|
||||
throw new Error("device_edge_channel_trust_bundle_invalid");
|
||||
}
|
||||
const expected = new Set(
|
||||
registration.certificateIdentities.map((item) => item.fingerprint),
|
||||
);
|
||||
const observed = new Set(blocks.map((block) => normalizeCertificateFingerprint(
|
||||
new X509Certificate(block).fingerprint256,
|
||||
)));
|
||||
if (
|
||||
observed.size !== expected.size
|
||||
|| [...observed].some((fingerprint) => !expected.has(fingerprint))
|
||||
) {
|
||||
throw new Error("device_edge_channel_trust_bundle_identity_mismatch");
|
||||
}
|
||||
return pem;
|
||||
}
|
||||
|
||||
function normalizeConfiguration(options) {
|
||||
if (
|
||||
!options.repository
|
||||
|| typeof options.repository.listActiveEdgeChannelRegistrations !== "function"
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_repository_required");
|
||||
}
|
||||
if (
|
||||
!options.gatewayIngest
|
||||
|| typeof options.gatewayIngest.observeDiscovery !== "function"
|
||||
|| typeof options.gatewayIngest.acceptMessage !== "function"
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_gateway_ingest_required");
|
||||
}
|
||||
const coreIdentity = options.coreIdentity;
|
||||
if (
|
||||
!coreIdentity
|
||||
|| !(typeof coreIdentity.key === "string" || Buffer.isBuffer(coreIdentity.key))
|
||||
|| !(typeof coreIdentity.cert === "string" || Buffer.isBuffer(coreIdentity.cert))
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(coreIdentity.identityRef)
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_core_identity_invalid");
|
||||
}
|
||||
const maxEdges = normalizeInteger(options.maxEdges, 1, 64, 32);
|
||||
const reconcileIntervalMs = normalizeInteger(
|
||||
options.reconcileIntervalMs,
|
||||
1_000,
|
||||
300_000,
|
||||
15_000,
|
||||
);
|
||||
const trustRoot = resolve(options.trustRoot ?? DEFAULT_TRUST_ROOT);
|
||||
return Object.freeze({
|
||||
repository: options.repository,
|
||||
gatewayIngest: options.gatewayIngest,
|
||||
typedCommandRuntime: normalizeTypedCommandRuntime(options.typedCommandRuntime),
|
||||
coreIdentity: Object.freeze({ ...coreIdentity }),
|
||||
maxEdges,
|
||||
reconcileIntervalMs,
|
||||
trustRoot,
|
||||
readPeerTrust: options.readPeerTrust ?? readPinnedEdgeTrust,
|
||||
clientFactory: options.clientFactory ?? createDeviceGatewayCoreChannelClient,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTypedCommandRuntime(value) {
|
||||
if (value == null) return null;
|
||||
if (
|
||||
typeof value.offerForDevice !== "function"
|
||||
|| typeof value.recordStatus !== "function"
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_typed_command_runtime_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeRegistration(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_edge_channel_registration_invalid");
|
||||
}
|
||||
const endpoint = new URL(String(value.endpoint || ""));
|
||||
if (
|
||||
endpoint.protocol !== "https:"
|
||||
|| endpoint.username
|
||||
|| endpoint.password
|
||||
|| endpoint.pathname !== "/"
|
||||
|| endpoint.search
|
||||
|| endpoint.hash
|
||||
|| endpoint.port !== ""
|
||||
|| endpoint.hostname !== String(value.servername || "").toLowerCase()
|
||||
|| !isPublicIpv4(endpoint.hostname)
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_registration_endpoint_invalid");
|
||||
}
|
||||
if (value.lifecycleState !== "active") {
|
||||
throw new TypeError("device_edge_channel_registration_inactive");
|
||||
}
|
||||
return Object.freeze({
|
||||
edgeRegistrationId: normalizeRef(value.edgeRegistrationId),
|
||||
endpoint: endpoint.toString(),
|
||||
servername: endpoint.hostname,
|
||||
channelGeneration: normalizeRef(value.channelGeneration),
|
||||
trustBundleRef: normalizeTrustRef(value.trustBundleRef),
|
||||
certificateIdentities: normalizeCertificateIdentities(
|
||||
value.certificateIdentities,
|
||||
),
|
||||
lifecycleState: "active",
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRef(value) {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
|
||||
throw new TypeError("device_edge_channel_registration_ref_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTrustRef(value) {
|
||||
if (typeof value !== "string" || !/^edge-trust:[a-z][a-z0-9-]{1,62}$/.test(value)) {
|
||||
throw new TypeError("device_edge_channel_trust_bundle_ref_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPublicIpv4(value) {
|
||||
const octets = value.split(".").map(Number);
|
||||
if (octets.length !== 4 || octets.some((item) =>
|
||||
!Number.isInteger(item) || item < 0 || item > 255
|
||||
)) return false;
|
||||
const [a, b, c] = octets;
|
||||
return a >= 1 && a < 224
|
||||
&& a !== 10 && a !== 127
|
||||
&& !(a === 100 && b >= 64 && b <= 127)
|
||||
&& !(a === 169 && b === 254)
|
||||
&& !(a === 172 && b >= 16 && b <= 31)
|
||||
&& !(a === 192 && (b === 0 || b === 168))
|
||||
&& !(a === 192 && b === 88 && c === 99)
|
||||
&& !(a === 198 && (b === 18 || b === 19 || b === 51))
|
||||
&& !(a === 203 && b === 0 && c === 113);
|
||||
}
|
||||
|
||||
function registrationDigest(value) {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function assertClient(value) {
|
||||
if (
|
||||
!value
|
||||
|| typeof value.start !== "function"
|
||||
|| typeof value.stop !== "function"
|
||||
|| typeof value.status !== "function"
|
||||
) throw new TypeError("device_edge_channel_client_invalid");
|
||||
}
|
||||
|
||||
function normalizeInteger(value, minimum, maximum, fallback) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new TypeError("device_edge_channel_integer_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function safeErrorCode(error) {
|
||||
const value = String(error?.message || error || "device_edge_channel_error")
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9._:-]/g, "_")
|
||||
.slice(0, 128);
|
||||
return /^[a-z][a-z0-9._:-]{2,127}$/.test(value)
|
||||
? value
|
||||
: "device_edge_channel_error";
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
assertSafeProjection,
|
||||
hashRestrictedIdentifier,
|
||||
normalizeAdapterAcceptance,
|
||||
normalizeAdapterMessage,
|
||||
normalizeDiscoverySignal,
|
||||
toSafeAdapterMessageView,
|
||||
toSafeDiscoveryView,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
export function createDeviceGatewayIngest({ repository, identifierPepper } = {}) {
|
||||
if (!repository || typeof repository.upsertQuarantineDiscovery !== "function") {
|
||||
throw new TypeError("device_discovery_repository_required");
|
||||
}
|
||||
if (typeof repository.acceptAdapterMessage !== "function") {
|
||||
throw new TypeError("device_gateway_message_repository_required");
|
||||
}
|
||||
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
|
||||
throw new TypeError("device_identifier_pepper_invalid");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async observeDiscovery(input, context = {}) {
|
||||
const receivedSignal = normalizeDiscoverySignal(input);
|
||||
const identifierDigest = hashRestrictedIdentifier(
|
||||
receivedSignal.identifier,
|
||||
identifierPepper,
|
||||
);
|
||||
const routeRef = await resolveAuthenticatedRoute(repository, {
|
||||
edgeRef: context.authenticatedEdgeRef,
|
||||
modelProfileRef: receivedSignal.modelProfileRef,
|
||||
protocol: receivedSignal.protocol,
|
||||
identifierKind: receivedSignal.identifier.kind,
|
||||
identifierDigest,
|
||||
observedAt: receivedSignal.observedAt,
|
||||
});
|
||||
const signal = routeRef === undefined
|
||||
? receivedSignal
|
||||
: normalizeDiscoverySignal({
|
||||
...withoutKeys(receivedSignal, ["routeRef"]),
|
||||
...(routeRef ? { routeRef } : {}),
|
||||
});
|
||||
const safeView = assertSafeProjection(toSafeDiscoveryView(signal));
|
||||
const discovery = await repository.upsertQuarantineDiscovery({
|
||||
identifierDigest,
|
||||
safeView,
|
||||
sessionRef: signal.sessionRef,
|
||||
routeRef: signal.routeRef ?? null,
|
||||
});
|
||||
return Object.freeze({
|
||||
created: discovery.created === true,
|
||||
value: assertSafeProjection(discovery.value),
|
||||
claimedDeviceRef: discovery.claimedDeviceRef ?? null,
|
||||
});
|
||||
},
|
||||
|
||||
async acceptMessage(input, context = {}) {
|
||||
const receivedMessage = normalizeAdapterMessage(input);
|
||||
const identifierDigest = hashRestrictedIdentifier(
|
||||
receivedMessage.identifier,
|
||||
identifierPepper,
|
||||
);
|
||||
const routeRef = await resolveAuthenticatedRoute(repository, {
|
||||
edgeRef: context.authenticatedEdgeRef,
|
||||
modelProfileRef: receivedMessage.protocolProfileRef,
|
||||
protocol: receivedMessage.protocol,
|
||||
identifierKind: receivedMessage.identifier.kind,
|
||||
identifierDigest,
|
||||
observedAt: receivedMessage.observedAt,
|
||||
});
|
||||
const message = routeRef === undefined
|
||||
? receivedMessage
|
||||
: normalizeAdapterMessage({
|
||||
...withoutKeys(receivedMessage, ["edgeRef", "routeRef"]),
|
||||
edgeRef: context.authenticatedEdgeRef,
|
||||
...(routeRef ? { routeRef } : {}),
|
||||
});
|
||||
const safeView = assertSafeProjection(toSafeAdapterMessageView(message));
|
||||
const requestDigest = gatewayMessageRequestDigest({
|
||||
edgeRef: safeView.edgeRef,
|
||||
adapterRef: safeView.adapterRef,
|
||||
protocolProfileRef: safeView.protocolProfileRef,
|
||||
protocol: safeView.protocol,
|
||||
routeRef: safeView.routeRef ?? null,
|
||||
idempotencyKey: safeView.idempotencyKey,
|
||||
identifierKind: safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
payloadSchemaRef: safeView.payloadSchemaRef,
|
||||
payload: safeView.payload,
|
||||
});
|
||||
const receipt = await repository.acceptAdapterMessage({
|
||||
identifierDigest,
|
||||
requestDigest,
|
||||
safeView,
|
||||
});
|
||||
return Object.freeze({
|
||||
value: normalizeAdapterAcceptance(receipt.acceptance),
|
||||
claimedDeviceRef: receipt.claimedDeviceRef ?? null,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveAuthenticatedRoute(repository, input) {
|
||||
if (input.edgeRef == null) return undefined;
|
||||
if (typeof repository.resolveInboundRoute !== "function") {
|
||||
throw new TypeError("device_inbound_route_repository_required");
|
||||
}
|
||||
return repository.resolveInboundRoute(input);
|
||||
}
|
||||
|
||||
function withoutKeys(value, keys) {
|
||||
const omitted = new Set(keys);
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([key]) => !omitted.has(key)),
|
||||
);
|
||||
}
|
||||
|
||||
function gatewayMessageRequestDigest(value) {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(JSON.stringify(value), "utf8")
|
||||
.digest("hex")}`;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export async function acceptGatewayMessage({
|
||||
pool,
|
||||
identifierDigest,
|
||||
requestDigest,
|
||||
safeView,
|
||||
}) {
|
||||
const routeId = safeView.routeRef == null
|
||||
? null
|
||||
: parseEntityRef(safeView.routeRef, "route");
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
const route = routeId == null
|
||||
? null
|
||||
: await findActiveRoute(client, routeId, safeView);
|
||||
const claimedDeviceRef = route == null
|
||||
? null
|
||||
: await findClaimedDeviceRef(client, {
|
||||
identifierDigest,
|
||||
route,
|
||||
safeView,
|
||||
});
|
||||
const id = randomUUID();
|
||||
const inserted = await client.query(
|
||||
`insert into device_gateway_message_receipts (
|
||||
id,
|
||||
idempotency_key,
|
||||
request_digest,
|
||||
edge_ref,
|
||||
adapter_ref,
|
||||
protocol_profile_ref,
|
||||
protocol,
|
||||
route_id,
|
||||
project_id,
|
||||
session_ref,
|
||||
message_ref,
|
||||
message_type,
|
||||
sequence,
|
||||
identifier_kind,
|
||||
identifier_digest,
|
||||
identifier_masked,
|
||||
payload_schema_ref,
|
||||
payload,
|
||||
observed_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19
|
||||
)
|
||||
on conflict (idempotency_key) do nothing
|
||||
returning id, idempotency_key, accepted_at`,
|
||||
[
|
||||
id,
|
||||
safeView.idempotencyKey,
|
||||
requestDigest,
|
||||
safeView.edgeRef,
|
||||
safeView.adapterRef,
|
||||
safeView.protocolProfileRef,
|
||||
safeView.protocol,
|
||||
route?.id ?? null,
|
||||
route?.project_id ?? null,
|
||||
safeView.sessionRef,
|
||||
safeView.messageRef,
|
||||
safeView.messageType,
|
||||
safeView.sequence,
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.identifier.masked,
|
||||
safeView.payloadSchemaRef,
|
||||
JSON.stringify(safeView.payload),
|
||||
safeView.observedAt,
|
||||
],
|
||||
);
|
||||
if (inserted.rows[0]) {
|
||||
await client.query("commit");
|
||||
return receiptView(inserted.rows[0], false, claimedDeviceRef);
|
||||
}
|
||||
|
||||
const existing = await client.query(
|
||||
`select id, idempotency_key, request_digest, accepted_at
|
||||
from device_gateway_message_receipts
|
||||
where idempotency_key = $1
|
||||
for share`,
|
||||
[safeView.idempotencyKey],
|
||||
);
|
||||
const row = existing.rows[0];
|
||||
if (!row) throw domainError("device_gateway_receipt_missing", 409);
|
||||
if (row.request_digest !== requestDigest) {
|
||||
throw domainError("device_gateway_idempotency_conflict", 409);
|
||||
}
|
||||
await client.query("commit");
|
||||
return receiptView(row, true, claimedDeviceRef);
|
||||
} catch (error) {
|
||||
await client.query("rollback").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function findClaimedDeviceRef(client, {
|
||||
identifierDigest,
|
||||
route,
|
||||
safeView,
|
||||
}) {
|
||||
const result = await client.query(
|
||||
`select claimed_device_id
|
||||
from device_discoveries
|
||||
where identifier_kind = $1
|
||||
and identifier_digest = $2
|
||||
and model_profile_ref = $3
|
||||
and lifecycle_state = 'claimed'
|
||||
and project_id = $4
|
||||
and route_id = $5
|
||||
and claimed_device_id is not null
|
||||
for share`,
|
||||
[
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.protocolProfileRef,
|
||||
route.project_id,
|
||||
route.id,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row?.claimed_device_id
|
||||
? `device:${row.claimed_device_id}`
|
||||
: null;
|
||||
}
|
||||
|
||||
async function findActiveRoute(client, routeId, safeView) {
|
||||
const edgeId = parseEntityRef(safeView.edgeRef, "edge");
|
||||
const result = await client.query(
|
||||
`select r.id, r.project_id, r.edge_id, r.model_profile_ref,
|
||||
r.protocol, r.lifecycle_state,
|
||||
e.lifecycle_state as edge_lifecycle_state,
|
||||
p.lifecycle_state as profile_lifecycle_state,
|
||||
ap.package_key as adapter_ref,
|
||||
ap.lifecycle_state as adapter_lifecycle_state,
|
||||
av.lifecycle_state as adapter_version_lifecycle_state
|
||||
from device_routes r
|
||||
join device_edges e on e.id = r.edge_id
|
||||
join device_model_profiles p on p.profile_ref = r.model_profile_ref
|
||||
join device_adapter_versions av on av.id = p.adapter_version_id
|
||||
join device_adapter_packages ap on ap.id = av.adapter_package_id
|
||||
where r.id = $1
|
||||
for share`,
|
||||
[routeId],
|
||||
);
|
||||
const route = result.rows[0];
|
||||
if (!route) throw domainError("device_gateway_route_not_found", 404);
|
||||
if (
|
||||
route.lifecycle_state !== "active"
|
||||
|| route.edge_lifecycle_state !== "active"
|
||||
|| route.profile_lifecycle_state !== "active"
|
||||
|| route.adapter_lifecycle_state !== "active"
|
||||
|| route.adapter_version_lifecycle_state !== "active"
|
||||
) {
|
||||
throw domainError("device_gateway_route_not_active", 409);
|
||||
}
|
||||
if (
|
||||
route.edge_id !== edgeId
|
||||
|| route.model_profile_ref !== safeView.protocolProfileRef
|
||||
|| route.protocol !== safeView.protocol
|
||||
|| route.adapter_ref !== safeView.adapterRef
|
||||
) {
|
||||
throw domainError("device_gateway_route_contract_mismatch", 409);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
function receiptView(row, replayed, claimedDeviceRef) {
|
||||
return {
|
||||
acceptance: {
|
||||
schemaVersion: "nodedc.device-adapter-acceptance.v1",
|
||||
acceptanceRef: `acceptance:${row.id}`,
|
||||
idempotencyKey: row.idempotency_key,
|
||||
status: "accepted",
|
||||
replayed,
|
||||
acceptedAt: new Date(row.accepted_at).toISOString(),
|
||||
},
|
||||
claimedDeviceRef,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEntityRef(value, prefix) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
}
|
||||
const match = 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 new TypeError(`device_${prefix}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export async function resolveInboundRoute(client, input = {}) {
|
||||
if (!client || typeof client.query !== "function") {
|
||||
throw new TypeError("device_inbound_route_client_required");
|
||||
}
|
||||
const edgeId = parseEntityRef(input.edgeRef, "edge");
|
||||
const modelProfileRef = normalizeOpaqueRef(
|
||||
input.modelProfileRef,
|
||||
"model_profile_ref",
|
||||
);
|
||||
const protocol = normalizeUpperToken(input.protocol, "protocol");
|
||||
const identifierKind = normalizeLowerToken(
|
||||
input.identifierKind,
|
||||
"identifier_kind",
|
||||
);
|
||||
const identifierDigest = normalizeIdentifierDigest(input.identifierDigest);
|
||||
const observedAt = normalizeTimestamp(input.observedAt, "observed_at");
|
||||
|
||||
const result = await client.query(
|
||||
`select r.id
|
||||
from device_enrollment_intents ei
|
||||
join device_routes r
|
||||
on r.id = ei.route_id
|
||||
and r.project_id = ei.project_id
|
||||
and r.model_profile_ref = ei.model_profile_ref
|
||||
join device_edges e on e.id = r.edge_id
|
||||
where r.edge_id = $1
|
||||
and r.model_profile_ref = $2
|
||||
and r.protocol = $3
|
||||
and r.lifecycle_state = 'active'
|
||||
and e.lifecycle_state = 'active'
|
||||
and e.channel_lifecycle_state = 'active'
|
||||
and ei.expected_identifier_kind = $4
|
||||
and ei.expected_identifier_digest = $5
|
||||
and ei.lifecycle_state in ('pending', 'observed', 'claimed')
|
||||
and (
|
||||
ei.lifecycle_state = 'claimed'
|
||||
or ei.expires_at is null
|
||||
or ei.expires_at > $6
|
||||
)
|
||||
order by r.id
|
||||
limit 2`,
|
||||
[
|
||||
edgeId,
|
||||
modelProfileRef,
|
||||
protocol,
|
||||
identifierKind,
|
||||
identifierDigest,
|
||||
observedAt,
|
||||
],
|
||||
);
|
||||
if (result.rows.length > 1) {
|
||||
throw domainError("device_inbound_route_ambiguous", 409);
|
||||
}
|
||||
return result.rows[0]?.id ? `route:${result.rows[0].id}` : null;
|
||||
}
|
||||
|
||||
function parseEntityRef(value, prefix) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
}
|
||||
const match = 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 new TypeError(`device_${prefix}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeOpaqueRef(value, name) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)
|
||||
) {
|
||||
throw new TypeError(`device_inbound_route_${name}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUpperToken(value, name) {
|
||||
if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) {
|
||||
throw new TypeError(`device_inbound_route_${name}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeLowerToken(value, name) {
|
||||
if (typeof value !== "string" || !/^[a-z][a-z0-9._:-]{1,63}$/.test(value)) {
|
||||
throw new TypeError(`device_inbound_route_${name}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeIdentifierDigest(value) {
|
||||
if (typeof value !== "string" || !/^hmac-sha256:[a-f0-9]{64}$/.test(value)) {
|
||||
throw new TypeError("device_inbound_route_identifier_digest_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value, name) {
|
||||
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
|
||||
throw new TypeError(`device_inbound_route_${name}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import {
|
||||
assertIdentifierDigest,
|
||||
assertSafeProjection,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import {
|
||||
normalizeCertificateIdentities,
|
||||
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
||||
import { normalizeManagementActor } from "./project-management.mjs";
|
||||
|
||||
export const DEVICE_INFRASTRUCTURE_COMMAND_KINDS = Object.freeze([
|
||||
"adapter_package.ensure",
|
||||
"adapter_version.register",
|
||||
"model_profile.register",
|
||||
"edge.ensure",
|
||||
"route.ensure",
|
||||
"enrollment_intent.ensure",
|
||||
]);
|
||||
|
||||
const commandKindSet = new Set(DEVICE_INFRASTRUCTURE_COMMAND_KINDS);
|
||||
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
|
||||
const profileRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/;
|
||||
const protocolPattern = /^[A-Z][A-Z0-9_]{0,31}$/;
|
||||
const capabilityPattern = /^[a-z][a-z0-9._-]{1,63}$/;
|
||||
const semverPattern = /^[0-9]+\.[0-9]+\.[0-9]+(?:[+-][A-Za-z0-9.-]+)?$/;
|
||||
const digestPattern = /^sha256:[a-f0-9]{64}$/;
|
||||
const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
export function isInfrastructureManagementCommand(kind) {
|
||||
return commandKindSet.has(kind);
|
||||
}
|
||||
|
||||
export function normalizeInfrastructureManagementCommand(kind, input) {
|
||||
if (!commandKindSet.has(kind)) {
|
||||
throw new TypeError("device_infrastructure_command_kind_invalid");
|
||||
}
|
||||
assertPlainObject(input, "device_infrastructure_command_invalid");
|
||||
|
||||
if (kind === "adapter_package.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"packageKey",
|
||||
"displayName",
|
||||
"publisherRef",
|
||||
"lifecycleState",
|
||||
]);
|
||||
return Object.freeze({
|
||||
packageKey: normalizeKey(input.packageKey, "device_adapter_package_key_invalid"),
|
||||
displayName: normalizeDisplayText(
|
||||
input.displayName,
|
||||
160,
|
||||
"device_adapter_package_name_invalid",
|
||||
),
|
||||
publisherRef: normalizeOpaqueRef(
|
||||
input.publisherRef,
|
||||
"device_adapter_publisher_ref_invalid",
|
||||
),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "active",
|
||||
new Set(["active", "retired"]),
|
||||
"device_adapter_package_state_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "adapter_version.register") {
|
||||
assertAllowedKeys(input, [
|
||||
"adapterPackageRef",
|
||||
"version",
|
||||
"runtimePackageRef",
|
||||
"contentDigest",
|
||||
"contractVersion",
|
||||
"capabilities",
|
||||
"lifecycleState",
|
||||
]);
|
||||
return Object.freeze({
|
||||
adapterPackageId: normalizeEntityRef(
|
||||
input.adapterPackageRef,
|
||||
"adapter-package",
|
||||
"device_adapter_package_ref_invalid",
|
||||
),
|
||||
version: normalizePattern(
|
||||
input.version,
|
||||
semverPattern,
|
||||
"device_adapter_version_invalid",
|
||||
),
|
||||
runtimePackageRef: normalizeOpaqueRef(
|
||||
input.runtimePackageRef,
|
||||
"device_adapter_runtime_package_ref_invalid",
|
||||
),
|
||||
contentDigest: normalizePattern(
|
||||
input.contentDigest,
|
||||
digestPattern,
|
||||
"device_adapter_content_digest_invalid",
|
||||
),
|
||||
contractVersion: normalizeProfileRef(
|
||||
input.contractVersion,
|
||||
"device_adapter_contract_version_invalid",
|
||||
),
|
||||
capabilities: Object.freeze(normalizeCapabilities(input.capabilities ?? [])),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "draft",
|
||||
new Set(["draft", "active", "retired"]),
|
||||
"device_adapter_version_state_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "model_profile.register") {
|
||||
assertAllowedKeys(input, [
|
||||
"adapterVersionRef",
|
||||
"profileRef",
|
||||
"schemaVersion",
|
||||
"vendor",
|
||||
"model",
|
||||
"deviceType",
|
||||
"protocol",
|
||||
"schemaArtifactRef",
|
||||
"profileDigest",
|
||||
"capabilities",
|
||||
"lifecycleState",
|
||||
]);
|
||||
return Object.freeze({
|
||||
adapterVersionId: normalizeEntityRef(
|
||||
input.adapterVersionRef,
|
||||
"adapter-version",
|
||||
"device_adapter_version_ref_invalid",
|
||||
),
|
||||
profileRef: normalizeProfileRef(
|
||||
input.profileRef,
|
||||
"device_model_profile_ref_invalid",
|
||||
),
|
||||
schemaVersion: normalizeProfileRef(
|
||||
input.schemaVersion,
|
||||
"device_model_profile_schema_version_invalid",
|
||||
),
|
||||
vendor: normalizeDisplayText(input.vendor, 120, "device_model_vendor_invalid"),
|
||||
model: normalizeDisplayText(input.model, 120, "device_model_name_invalid"),
|
||||
deviceType: normalizePattern(
|
||||
input.deviceType,
|
||||
capabilityPattern,
|
||||
"device_model_type_invalid",
|
||||
),
|
||||
protocol: normalizePattern(
|
||||
input.protocol,
|
||||
protocolPattern,
|
||||
"device_model_protocol_invalid",
|
||||
),
|
||||
schemaArtifactRef: normalizeOpaqueRef(
|
||||
input.schemaArtifactRef,
|
||||
"device_model_schema_artifact_ref_invalid",
|
||||
),
|
||||
profileDigest: normalizePattern(
|
||||
input.profileDigest,
|
||||
digestPattern,
|
||||
"device_model_profile_digest_invalid",
|
||||
),
|
||||
capabilities: Object.freeze(normalizeCapabilities(input.capabilities ?? [])),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "draft",
|
||||
new Set(["draft", "active", "retired"]),
|
||||
"device_model_profile_state_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "edge.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"edgeKey",
|
||||
"displayName",
|
||||
"deploymentRef",
|
||||
"lifecycleState",
|
||||
"channel",
|
||||
]);
|
||||
const normalized = {
|
||||
edgeKey: normalizeKey(input.edgeKey, "device_edge_key_invalid"),
|
||||
displayName: normalizeDisplayText(
|
||||
input.displayName,
|
||||
160,
|
||||
"device_edge_name_invalid",
|
||||
),
|
||||
deploymentRef: normalizeOptionalOpaqueRef(
|
||||
input.deploymentRef,
|
||||
"device_edge_deployment_ref_invalid",
|
||||
),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "provisioning",
|
||||
new Set(["provisioning", "active", "suspended", "retired"]),
|
||||
"device_edge_state_invalid",
|
||||
),
|
||||
};
|
||||
if (input.channel !== undefined) {
|
||||
normalized.channel = normalizeEdgeChannel(input.channel);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
if (kind === "route.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"routeKey",
|
||||
"displayName",
|
||||
"edgeRef",
|
||||
"modelProfileRef",
|
||||
"listenerRef",
|
||||
"protocol",
|
||||
"direction",
|
||||
"lifecycleState",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(
|
||||
input.projectRef,
|
||||
"project",
|
||||
"device_project_ref_invalid",
|
||||
),
|
||||
routeKey: normalizeKey(input.routeKey, "device_route_key_invalid"),
|
||||
displayName: normalizeDisplayText(
|
||||
input.displayName,
|
||||
160,
|
||||
"device_route_name_invalid",
|
||||
),
|
||||
edgeId: normalizeEntityRef(
|
||||
input.edgeRef,
|
||||
"edge",
|
||||
"device_edge_ref_invalid",
|
||||
),
|
||||
modelProfileRef: normalizeProfileRef(
|
||||
input.modelProfileRef,
|
||||
"device_model_profile_ref_invalid",
|
||||
),
|
||||
listenerRef: normalizeOpaqueRef(
|
||||
input.listenerRef,
|
||||
"device_route_listener_ref_invalid",
|
||||
),
|
||||
protocol: normalizePattern(
|
||||
input.protocol,
|
||||
protocolPattern,
|
||||
"device_route_protocol_invalid",
|
||||
),
|
||||
direction: normalizeEnum(
|
||||
input.direction ?? "telemetry",
|
||||
new Set(["telemetry", "bidirectional"]),
|
||||
"device_route_direction_invalid",
|
||||
),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "draft",
|
||||
new Set(["draft", "active", "suspended", "retired"]),
|
||||
"device_route_state_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"enrollmentKey",
|
||||
"routeRef",
|
||||
"modelProfileRef",
|
||||
"displayName",
|
||||
"identifierKind",
|
||||
"identifierDigest",
|
||||
"identifierMasked",
|
||||
"expiresAt",
|
||||
]);
|
||||
const identifierMasked = normalizeDisplayText(
|
||||
input.identifierMasked,
|
||||
64,
|
||||
"device_enrollment_identifier_masked_invalid",
|
||||
);
|
||||
assertSafeProjection({ identifierMasked });
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(
|
||||
input.projectRef,
|
||||
"project",
|
||||
"device_project_ref_invalid",
|
||||
),
|
||||
enrollmentKey: normalizeKey(
|
||||
input.enrollmentKey,
|
||||
"device_enrollment_key_invalid",
|
||||
),
|
||||
routeId: normalizeEntityRef(
|
||||
input.routeRef,
|
||||
"route",
|
||||
"device_route_ref_invalid",
|
||||
),
|
||||
modelProfileRef: normalizeProfileRef(
|
||||
input.modelProfileRef,
|
||||
"device_model_profile_ref_invalid",
|
||||
),
|
||||
displayName: normalizeDisplayText(
|
||||
input.displayName,
|
||||
160,
|
||||
"device_enrollment_name_invalid",
|
||||
),
|
||||
identifierKind: normalizePattern(
|
||||
input.identifierKind,
|
||||
/^[a-z][a-z0-9._-]{1,31}$/,
|
||||
"device_enrollment_identifier_kind_invalid",
|
||||
),
|
||||
identifierDigest: assertIdentifierDigest(input.identifierDigest),
|
||||
identifierMasked,
|
||||
expiresAt: normalizeOptionalTimestamp(input.expiresAt),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertPlatformCatalogAuthority(actorInput) {
|
||||
const actor = normalizeManagementActor(actorInput);
|
||||
if (actor.hubRole !== "owner") {
|
||||
throw domainError("device_platform_catalog_access_denied", 403);
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
function normalizeCapabilities(input) {
|
||||
if (!Array.isArray(input) || input.length > 64) {
|
||||
throw new TypeError("device_adapter_capabilities_invalid");
|
||||
}
|
||||
return [...new Set(input.map((capability) => normalizePattern(
|
||||
capability,
|
||||
capabilityPattern,
|
||||
"device_adapter_capability_invalid",
|
||||
)))].sort();
|
||||
}
|
||||
|
||||
function normalizeEntityRef(value, prefix, code) {
|
||||
if (typeof value !== "string") throw new TypeError(code);
|
||||
const match = 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 new TypeError(code);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeKey(value, code) {
|
||||
return normalizePattern(value, keyPattern, code);
|
||||
}
|
||||
|
||||
function normalizeProfileRef(value, code) {
|
||||
return normalizePattern(value, profileRefPattern, code);
|
||||
}
|
||||
|
||||
function normalizeOpaqueRef(value, code) {
|
||||
return normalizePattern(value, opaqueRefPattern, code);
|
||||
}
|
||||
|
||||
function normalizeOptionalOpaqueRef(value, code) {
|
||||
if (value == null || value === "") return null;
|
||||
return normalizeOpaqueRef(value, code);
|
||||
}
|
||||
|
||||
function normalizeEdgeChannel(input) {
|
||||
assertPlainObject(input, "device_edge_channel_invalid");
|
||||
assertAllowedKeys(input, [
|
||||
"endpoint",
|
||||
"servername",
|
||||
"generationRef",
|
||||
"trustBundleRef",
|
||||
"certificateIdentities",
|
||||
"lifecycleState",
|
||||
]);
|
||||
const lifecycleState = normalizeEnum(
|
||||
input.lifecycleState ?? "disabled",
|
||||
new Set(["disabled", "active", "revoked"]),
|
||||
"device_edge_channel_state_invalid",
|
||||
);
|
||||
if (lifecycleState === "disabled") {
|
||||
if (Object.keys(input).some((key) => key !== "lifecycleState")) {
|
||||
throw new TypeError("device_edge_channel_disabled_configuration_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
endpoint: null,
|
||||
servername: null,
|
||||
generationRef: null,
|
||||
trustBundleRef: null,
|
||||
certificateIdentities: Object.freeze([]),
|
||||
lifecycleState,
|
||||
});
|
||||
}
|
||||
|
||||
const endpoint = normalizeEdgeEndpoint(input.endpoint);
|
||||
const servername = normalizePattern(
|
||||
input.servername,
|
||||
/^[A-Za-z0-9.-]{1,253}$/,
|
||||
"device_edge_channel_servername_invalid",
|
||||
).toLowerCase();
|
||||
if (servername !== endpoint.hostname) {
|
||||
throw new TypeError("device_edge_channel_servername_mismatch");
|
||||
}
|
||||
return Object.freeze({
|
||||
endpoint: endpoint.toString(),
|
||||
servername,
|
||||
generationRef: normalizeProfileRef(
|
||||
input.generationRef,
|
||||
"device_edge_channel_generation_invalid",
|
||||
),
|
||||
trustBundleRef: normalizePattern(
|
||||
input.trustBundleRef,
|
||||
/^edge-trust:[a-z][a-z0-9-]{1,62}$/,
|
||||
"device_edge_channel_trust_bundle_ref_invalid",
|
||||
),
|
||||
certificateIdentities: normalizeCertificateIdentities(
|
||||
input.certificateIdentities,
|
||||
),
|
||||
lifecycleState,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEdgeEndpoint(value) {
|
||||
let endpoint;
|
||||
try {
|
||||
endpoint = new URL(String(value || ""));
|
||||
} catch {
|
||||
throw new TypeError("device_edge_channel_endpoint_invalid");
|
||||
}
|
||||
if (
|
||||
endpoint.protocol !== "https:"
|
||||
|| endpoint.username
|
||||
|| endpoint.password
|
||||
|| endpoint.pathname !== "/"
|
||||
|| endpoint.search
|
||||
|| endpoint.hash
|
||||
|| endpoint.port !== ""
|
||||
|| !isPublicIpv4(endpoint.hostname)
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_endpoint_invalid");
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function isPublicIpv4(value) {
|
||||
const octets = value.split(".").map(Number);
|
||||
if (
|
||||
octets.length !== 4
|
||||
|| octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||
) return false;
|
||||
const [a, b, c] = octets;
|
||||
if (a < 1 || a >= 224) return false;
|
||||
if (a === 10 || a === 127) return false;
|
||||
if (a === 100 && b >= 64 && b <= 127) return false;
|
||||
if (a === 169 && b === 254) return false;
|
||||
if (a === 172 && b >= 16 && b <= 31) return false;
|
||||
if (a === 192 && (b === 0 || b === 168)) return false;
|
||||
if (a === 192 && b === 88 && c === 99) return false;
|
||||
if (a === 198 && (b === 18 || b === 19 || b === 51)) return false;
|
||||
if (a === 203 && b === 0 && c === 113) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizePattern(value, pattern, code) {
|
||||
if (typeof value !== "string" || !pattern.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 normalizeOptionalTimestamp(value) {
|
||||
if (value == null || value === "") return null;
|
||||
if (typeof value !== "string" || !isoTimestampPattern.test(value)) {
|
||||
throw new TypeError("device_enrollment_expires_at_invalid");
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) {
|
||||
throw new TypeError("device_enrollment_expires_at_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeEnum(value, allowed, code) {
|
||||
if (typeof value !== "string" || !allowed.has(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPlainObject(value, code) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedKeys(input, allowed) {
|
||||
const allowedSet = new Set(allowed);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedSet.has(key)) {
|
||||
throw new TypeError(`device_management_command_field_unexpected:${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,964 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
assertPlatformCatalogAuthority,
|
||||
isInfrastructureManagementCommand,
|
||||
} from "./infrastructure-management.mjs";
|
||||
import {
|
||||
assertProjectCapability,
|
||||
toProjectRef,
|
||||
} from "./project-management.mjs";
|
||||
|
||||
export async function applyInfrastructureManagementCommand(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isInfrastructureManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_infrastructure_command_kind_invalid");
|
||||
}
|
||||
if (commandKind === "adapter_package.ensure") {
|
||||
return ensureAdapterPackage(client, actor, command);
|
||||
}
|
||||
if (commandKind === "adapter_version.register") {
|
||||
return registerAdapterVersion(client, actor, command);
|
||||
}
|
||||
if (commandKind === "model_profile.register") {
|
||||
return registerModelProfile(client, actor, command);
|
||||
}
|
||||
if (commandKind === "edge.ensure") {
|
||||
return ensureEdge(client, actor, command);
|
||||
}
|
||||
if (commandKind === "route.ensure") {
|
||||
return ensureRoute(client, actor, command);
|
||||
}
|
||||
return ensureEnrollmentIntent(client, actor, command);
|
||||
}
|
||||
|
||||
export async function authorizeInfrastructureManagementReplay(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isInfrastructureManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_infrastructure_command_kind_invalid");
|
||||
}
|
||||
if (
|
||||
commandKind === "adapter_package.ensure"
|
||||
|| commandKind === "adapter_version.register"
|
||||
|| commandKind === "model_profile.register"
|
||||
|| commandKind === "edge.ensure"
|
||||
) {
|
||||
assertPlatformCatalogAuthority(actor);
|
||||
return;
|
||||
}
|
||||
|
||||
const capability = commandKind === "route.ensure"
|
||||
? "route.manage"
|
||||
: "device.enroll";
|
||||
await assertCurrentProjectCapability(client, actor, command.projectId, capability);
|
||||
}
|
||||
|
||||
async function ensureAdapterPackage(client, actor, command) {
|
||||
assertPlatformCatalogAuthority(actor);
|
||||
const result = await client.query(
|
||||
`insert into device_adapter_packages (
|
||||
id,
|
||||
package_key,
|
||||
display_name,
|
||||
publisher_ref,
|
||||
lifecycle_state,
|
||||
created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6)
|
||||
on conflict (package_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
updated_at = now()
|
||||
where device_adapter_packages.publisher_ref = excluded.publisher_ref
|
||||
and (
|
||||
device_adapter_packages.lifecycle_state = excluded.lifecycle_state
|
||||
or (
|
||||
device_adapter_packages.lifecycle_state = 'active'
|
||||
and excluded.lifecycle_state = 'retired'
|
||||
)
|
||||
)
|
||||
returning id, package_key, display_name, publisher_ref, lifecycle_state,
|
||||
created_at, updated_at, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
command.packageKey,
|
||||
command.displayName,
|
||||
command.publisherRef,
|
||||
command.lifecycleState,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireMutationRow(
|
||||
result,
|
||||
"device_adapter_package_identity_conflict",
|
||||
);
|
||||
await addAudit(client, {
|
||||
eventType: row.created
|
||||
? "adapter_package.created"
|
||||
: "adapter_package.updated",
|
||||
actorRef: actor.userRef,
|
||||
payload: {
|
||||
adapterPackageRef: `adapter-package:${row.id}`,
|
||||
packageKey: row.package_key,
|
||||
publisherRef: row.publisher_ref,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: row.created === true,
|
||||
adapterPackage: adapterPackageView(row),
|
||||
};
|
||||
}
|
||||
|
||||
async function registerAdapterVersion(client, actor, command) {
|
||||
assertPlatformCatalogAuthority(actor);
|
||||
const adapterPackage = await findAdapterPackage(
|
||||
client,
|
||||
command.adapterPackageId,
|
||||
);
|
||||
if (adapterPackage.lifecycle_state !== "active") {
|
||||
throw domainError("device_adapter_package_inactive", 409);
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`insert into device_adapter_versions (
|
||||
id,
|
||||
adapter_package_id,
|
||||
version,
|
||||
runtime_package_ref,
|
||||
content_digest,
|
||||
contract_version,
|
||||
capabilities,
|
||||
lifecycle_state,
|
||||
registered_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
on conflict (adapter_package_id, version) do update set
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
updated_at = now()
|
||||
where device_adapter_versions.runtime_package_ref = excluded.runtime_package_ref
|
||||
and device_adapter_versions.content_digest = excluded.content_digest
|
||||
and device_adapter_versions.contract_version = excluded.contract_version
|
||||
and device_adapter_versions.capabilities = excluded.capabilities
|
||||
and (
|
||||
device_adapter_versions.lifecycle_state = excluded.lifecycle_state
|
||||
or (
|
||||
device_adapter_versions.lifecycle_state = 'draft'
|
||||
and excluded.lifecycle_state in ('active', 'retired')
|
||||
)
|
||||
or (
|
||||
device_adapter_versions.lifecycle_state = 'active'
|
||||
and excluded.lifecycle_state = 'retired'
|
||||
)
|
||||
)
|
||||
returning id, adapter_package_id, version, runtime_package_ref,
|
||||
content_digest, contract_version, capabilities, lifecycle_state,
|
||||
created_at, updated_at, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
command.adapterPackageId,
|
||||
command.version,
|
||||
command.runtimePackageRef,
|
||||
command.contentDigest,
|
||||
command.contractVersion,
|
||||
command.capabilities,
|
||||
command.lifecycleState,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireMutationRow(
|
||||
result,
|
||||
"device_adapter_version_identity_conflict",
|
||||
);
|
||||
await addAudit(client, {
|
||||
eventType: row.created
|
||||
? "adapter_version.registered"
|
||||
: "adapter_version.lifecycle_updated",
|
||||
actorRef: actor.userRef,
|
||||
payload: {
|
||||
adapterPackageRef: `adapter-package:${row.adapter_package_id}`,
|
||||
adapterVersionRef: `adapter-version:${row.id}`,
|
||||
version: row.version,
|
||||
contentDigest: row.content_digest,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: row.created === true,
|
||||
adapterPackage: adapterPackageView(adapterPackage),
|
||||
adapterVersion: adapterVersionView(row),
|
||||
};
|
||||
}
|
||||
|
||||
async function registerModelProfile(client, actor, command) {
|
||||
assertPlatformCatalogAuthority(actor);
|
||||
const adapterVersion = await findAdapterVersion(
|
||||
client,
|
||||
command.adapterVersionId,
|
||||
);
|
||||
if (
|
||||
adapterVersion.package_lifecycle_state !== "active"
|
||||
|| adapterVersion.lifecycle_state === "retired"
|
||||
) {
|
||||
throw domainError("device_adapter_version_inactive", 409);
|
||||
}
|
||||
if (
|
||||
command.lifecycleState === "active"
|
||||
&& adapterVersion.lifecycle_state !== "active"
|
||||
) {
|
||||
throw domainError("device_model_profile_adapter_not_active", 409);
|
||||
}
|
||||
|
||||
const profile = {
|
||||
schemaVersion: command.schemaVersion,
|
||||
profileRef: command.profileRef,
|
||||
vendor: command.vendor,
|
||||
model: command.model,
|
||||
deviceType: command.deviceType,
|
||||
protocol: command.protocol,
|
||||
schemaArtifactRef: command.schemaArtifactRef,
|
||||
capabilities: command.capabilities,
|
||||
};
|
||||
const existingProfile = await findOptionalModelProfileRegistration(
|
||||
client,
|
||||
command.profileRef,
|
||||
);
|
||||
const adoptsLegacyProfile = isLegacyMetadataOnlyProfile(existingProfile);
|
||||
const result = await client.query(
|
||||
`insert into device_model_profiles (
|
||||
profile_ref,
|
||||
schema_version,
|
||||
vendor,
|
||||
model,
|
||||
device_type,
|
||||
protocol,
|
||||
profile,
|
||||
adapter_version_id,
|
||||
schema_artifact_ref,
|
||||
profile_digest,
|
||||
capabilities,
|
||||
lifecycle_state
|
||||
) values ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, $12)
|
||||
on conflict (profile_ref) do update set
|
||||
adapter_version_id = case
|
||||
when device_model_profiles.adapter_version_id is null
|
||||
then excluded.adapter_version_id
|
||||
else device_model_profiles.adapter_version_id
|
||||
end,
|
||||
schema_artifact_ref = case
|
||||
when device_model_profiles.schema_artifact_ref is null
|
||||
then excluded.schema_artifact_ref
|
||||
else device_model_profiles.schema_artifact_ref
|
||||
end,
|
||||
profile_digest = case
|
||||
when device_model_profiles.profile_digest is null
|
||||
then excluded.profile_digest
|
||||
else device_model_profiles.profile_digest
|
||||
end,
|
||||
capabilities = case
|
||||
when cardinality(device_model_profiles.capabilities) = 0
|
||||
then excluded.capabilities
|
||||
else device_model_profiles.capabilities
|
||||
end,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
updated_at = now()
|
||||
where device_model_profiles.schema_version = excluded.schema_version
|
||||
and device_model_profiles.vendor = excluded.vendor
|
||||
and device_model_profiles.model = excluded.model
|
||||
and device_model_profiles.device_type = excluded.device_type
|
||||
and device_model_profiles.protocol = excluded.protocol
|
||||
and (
|
||||
(
|
||||
(
|
||||
device_model_profiles.profile = excluded.profile
|
||||
or (
|
||||
jsonb_typeof(device_model_profiles.profile) = 'object'
|
||||
and device_model_profiles.profile ->> 'schemaVersion' = excluded.schema_version
|
||||
and device_model_profiles.profile ->> 'profileRef' = excluded.profile_ref
|
||||
and device_model_profiles.profile ->> 'vendor' = excluded.vendor
|
||||
and device_model_profiles.profile ->> 'model' = excluded.model
|
||||
and device_model_profiles.profile ->> 'deviceType' = excluded.device_type
|
||||
and device_model_profiles.profile ->> 'protocol' = excluded.protocol
|
||||
)
|
||||
)
|
||||
and device_model_profiles.adapter_version_id = excluded.adapter_version_id
|
||||
and device_model_profiles.schema_artifact_ref = excluded.schema_artifact_ref
|
||||
and device_model_profiles.profile_digest = excluded.profile_digest
|
||||
and device_model_profiles.capabilities = excluded.capabilities
|
||||
and (
|
||||
device_model_profiles.lifecycle_state = excluded.lifecycle_state
|
||||
or (
|
||||
device_model_profiles.lifecycle_state = 'draft'
|
||||
and excluded.lifecycle_state in ('active', 'retired')
|
||||
)
|
||||
or (
|
||||
device_model_profiles.lifecycle_state = 'active'
|
||||
and excluded.lifecycle_state = 'retired'
|
||||
)
|
||||
)
|
||||
)
|
||||
or (
|
||||
jsonb_typeof(device_model_profiles.profile) = 'object'
|
||||
and device_model_profiles.profile ->> 'schemaVersion' = excluded.schema_version
|
||||
and device_model_profiles.profile ->> 'profileRef' = excluded.profile_ref
|
||||
and device_model_profiles.profile ->> 'vendor' = excluded.vendor
|
||||
and device_model_profiles.profile ->> 'model' = excluded.model
|
||||
and device_model_profiles.profile ->> 'deviceType' = excluded.device_type
|
||||
and device_model_profiles.profile ->> 'protocol' = excluded.protocol
|
||||
and device_model_profiles.adapter_version_id is null
|
||||
and device_model_profiles.schema_artifact_ref is null
|
||||
and device_model_profiles.profile_digest is null
|
||||
and cardinality(device_model_profiles.capabilities) = 0
|
||||
and device_model_profiles.lifecycle_state = 'active'
|
||||
and excluded.lifecycle_state = 'draft'
|
||||
)
|
||||
)
|
||||
returning profile_ref, schema_version, vendor, model, device_type,
|
||||
protocol, adapter_version_id, schema_artifact_ref, profile_digest,
|
||||
capabilities, lifecycle_state, created_at, updated_at,
|
||||
(xmax = 0) as created`,
|
||||
[
|
||||
command.profileRef,
|
||||
command.schemaVersion,
|
||||
command.vendor,
|
||||
command.model,
|
||||
command.deviceType,
|
||||
command.protocol,
|
||||
JSON.stringify(profile),
|
||||
command.adapterVersionId,
|
||||
command.schemaArtifactRef,
|
||||
command.profileDigest,
|
||||
command.capabilities,
|
||||
command.lifecycleState,
|
||||
],
|
||||
);
|
||||
const row = requireMutationRow(
|
||||
result,
|
||||
"device_model_profile_identity_conflict",
|
||||
);
|
||||
await addAudit(client, {
|
||||
eventType: row.created
|
||||
? "model_profile.registered"
|
||||
: adoptsLegacyProfile
|
||||
? "model_profile.registry_adopted"
|
||||
: "model_profile.lifecycle_updated",
|
||||
actorRef: actor.userRef,
|
||||
payload: {
|
||||
adapterVersionRef: `adapter-version:${row.adapter_version_id}`,
|
||||
modelProfileRef: row.profile_ref,
|
||||
profileDigest: row.profile_digest,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: row.created === true,
|
||||
adapterVersion: adapterVersionView(adapterVersion),
|
||||
modelProfile: modelProfileView(row),
|
||||
};
|
||||
}
|
||||
|
||||
async function findOptionalModelProfileRegistration(client, profileRef) {
|
||||
const result = await client.query(
|
||||
`select profile_ref, adapter_version_id, schema_artifact_ref,
|
||||
profile_digest, capabilities, lifecycle_state
|
||||
from device_model_profiles
|
||||
where profile_ref = $1
|
||||
for update`,
|
||||
[profileRef],
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
function isLegacyMetadataOnlyProfile(profile) {
|
||||
return profile != null
|
||||
&& profile.adapter_version_id == null
|
||||
&& profile.schema_artifact_ref == null
|
||||
&& profile.profile_digest == null
|
||||
&& Array.isArray(profile.capabilities)
|
||||
&& profile.capabilities.length === 0
|
||||
&& profile.lifecycle_state === "active";
|
||||
}
|
||||
|
||||
async function ensureEdge(client, actor, command) {
|
||||
assertPlatformCatalogAuthority(actor);
|
||||
const channelProvided = command.channel !== undefined;
|
||||
const channel = command.channel ?? {
|
||||
endpoint: null,
|
||||
servername: null,
|
||||
generationRef: null,
|
||||
trustBundleRef: null,
|
||||
certificateIdentities: [],
|
||||
lifecycleState: "disabled",
|
||||
};
|
||||
const result = await client.query(
|
||||
`insert into device_edges (
|
||||
id,
|
||||
edge_key,
|
||||
display_name,
|
||||
deployment_ref,
|
||||
lifecycle_state,
|
||||
channel_endpoint,
|
||||
channel_servername,
|
||||
channel_generation_ref,
|
||||
channel_trust_bundle_ref,
|
||||
channel_certificate_identities,
|
||||
channel_lifecycle_state,
|
||||
created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $13)
|
||||
on conflict (edge_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
deployment_ref = excluded.deployment_ref,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
channel_endpoint = case when $12 then excluded.channel_endpoint
|
||||
else device_edges.channel_endpoint end,
|
||||
channel_servername = case when $12 then excluded.channel_servername
|
||||
else device_edges.channel_servername end,
|
||||
channel_generation_ref = case when $12 then excluded.channel_generation_ref
|
||||
else device_edges.channel_generation_ref end,
|
||||
channel_trust_bundle_ref = case when $12 then excluded.channel_trust_bundle_ref
|
||||
else device_edges.channel_trust_bundle_ref end,
|
||||
channel_certificate_identities = case when $12
|
||||
then excluded.channel_certificate_identities
|
||||
else device_edges.channel_certificate_identities end,
|
||||
channel_lifecycle_state = case when $12
|
||||
then excluded.channel_lifecycle_state
|
||||
else device_edges.channel_lifecycle_state end,
|
||||
updated_at = now()
|
||||
where (
|
||||
device_edges.lifecycle_state = excluded.lifecycle_state
|
||||
or (
|
||||
device_edges.lifecycle_state = 'provisioning'
|
||||
and excluded.lifecycle_state in ('active', 'retired')
|
||||
)
|
||||
or (
|
||||
device_edges.lifecycle_state = 'active'
|
||||
and excluded.lifecycle_state in ('suspended', 'retired')
|
||||
)
|
||||
or (
|
||||
device_edges.lifecycle_state = 'suspended'
|
||||
and excluded.lifecycle_state in ('active', 'retired')
|
||||
)
|
||||
)
|
||||
and (
|
||||
not $12
|
||||
or device_edges.channel_lifecycle_state = excluded.channel_lifecycle_state
|
||||
or (
|
||||
device_edges.channel_lifecycle_state = 'disabled'
|
||||
and excluded.channel_lifecycle_state = 'active'
|
||||
)
|
||||
or (
|
||||
device_edges.channel_lifecycle_state = 'active'
|
||||
and excluded.channel_lifecycle_state in ('disabled', 'revoked')
|
||||
)
|
||||
)
|
||||
returning id, edge_key, display_name, deployment_ref, lifecycle_state,
|
||||
channel_endpoint, channel_servername, channel_generation_ref,
|
||||
channel_trust_bundle_ref, channel_certificate_identities,
|
||||
channel_lifecycle_state, created_at, updated_at,
|
||||
(xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
command.edgeKey,
|
||||
command.displayName,
|
||||
command.deploymentRef,
|
||||
command.lifecycleState,
|
||||
channel.endpoint,
|
||||
channel.servername,
|
||||
channel.generationRef,
|
||||
channel.trustBundleRef,
|
||||
JSON.stringify(channel.certificateIdentities),
|
||||
channel.lifecycleState,
|
||||
channelProvided,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireMutationRow(result, "device_edge_identity_conflict");
|
||||
await addAudit(client, {
|
||||
eventType: row.created ? "edge.created" : "edge.updated",
|
||||
actorRef: actor.userRef,
|
||||
payload: {
|
||||
edgeRef: `edge:${row.id}`,
|
||||
edgeKey: row.edge_key,
|
||||
deploymentRef: row.deployment_ref,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
channelLifecycleState: row.channel_lifecycle_state,
|
||||
channelGenerationRef: row.channel_generation_ref,
|
||||
channelTrustBundleRef: row.channel_trust_bundle_ref,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: row.created === true,
|
||||
edge: edgeView(row),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureRoute(client, actor, command) {
|
||||
await assertCurrentProjectCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"route.manage",
|
||||
);
|
||||
const edge = await findEdge(client, command.edgeId);
|
||||
const profile = await findModelProfile(client, command.modelProfileRef);
|
||||
assertRouteDependencies(command, edge, profile);
|
||||
|
||||
const result = await client.query(
|
||||
`insert into device_routes (
|
||||
id,
|
||||
project_id,
|
||||
route_key,
|
||||
display_name,
|
||||
edge_id,
|
||||
model_profile_ref,
|
||||
listener_ref,
|
||||
protocol,
|
||||
direction,
|
||||
lifecycle_state,
|
||||
created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
on conflict (project_id, route_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
edge_id = excluded.edge_id,
|
||||
model_profile_ref = excluded.model_profile_ref,
|
||||
listener_ref = excluded.listener_ref,
|
||||
protocol = excluded.protocol,
|
||||
direction = excluded.direction,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
updated_at = now()
|
||||
where
|
||||
device_routes.lifecycle_state = excluded.lifecycle_state
|
||||
or (
|
||||
device_routes.lifecycle_state = 'draft'
|
||||
and excluded.lifecycle_state in ('active', 'retired')
|
||||
)
|
||||
or (
|
||||
device_routes.lifecycle_state = 'active'
|
||||
and excluded.lifecycle_state in ('suspended', 'retired')
|
||||
)
|
||||
or (
|
||||
device_routes.lifecycle_state = 'suspended'
|
||||
and excluded.lifecycle_state in ('active', 'retired')
|
||||
)
|
||||
returning id, project_id, route_key, display_name, edge_id,
|
||||
model_profile_ref, listener_ref, protocol, direction, lifecycle_state,
|
||||
created_at, updated_at, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
command.projectId,
|
||||
command.routeKey,
|
||||
command.displayName,
|
||||
command.edgeId,
|
||||
command.modelProfileRef,
|
||||
command.listenerRef,
|
||||
command.protocol,
|
||||
command.direction,
|
||||
command.lifecycleState,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireMutationRow(result, "device_route_identity_conflict");
|
||||
await addAudit(client, {
|
||||
eventType: row.created ? "route.created" : "route.updated",
|
||||
actorRef: actor.userRef,
|
||||
projectId: command.projectId,
|
||||
payload: {
|
||||
projectRef: toProjectRef(command.projectId),
|
||||
routeRef: `route:${row.id}`,
|
||||
routeKey: row.route_key,
|
||||
edgeRef: `edge:${row.edge_id}`,
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
listenerRef: row.listener_ref,
|
||||
direction: row.direction,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: row.created === true,
|
||||
route: routeView(row),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureEnrollmentIntent(client, actor, command) {
|
||||
await assertCurrentProjectCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"device.enroll",
|
||||
);
|
||||
const route = await findProjectRoute(
|
||||
client,
|
||||
command.projectId,
|
||||
command.routeId,
|
||||
);
|
||||
if (route.lifecycle_state !== "active") {
|
||||
throw domainError("device_enrollment_route_inactive", 409);
|
||||
}
|
||||
if (route.model_profile_ref !== command.modelProfileRef) {
|
||||
throw domainError("device_enrollment_profile_mismatch", 409);
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`insert into device_enrollment_intents (
|
||||
id,
|
||||
project_id,
|
||||
enrollment_key,
|
||||
route_id,
|
||||
model_profile_ref,
|
||||
display_name,
|
||||
expected_identifier_kind,
|
||||
expected_identifier_digest,
|
||||
expected_identifier_masked,
|
||||
expires_at,
|
||||
created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
on conflict (project_id, enrollment_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
expires_at = excluded.expires_at,
|
||||
updated_at = now()
|
||||
where device_enrollment_intents.route_id = excluded.route_id
|
||||
and device_enrollment_intents.model_profile_ref = excluded.model_profile_ref
|
||||
and device_enrollment_intents.expected_identifier_kind = excluded.expected_identifier_kind
|
||||
and device_enrollment_intents.expected_identifier_digest = excluded.expected_identifier_digest
|
||||
and device_enrollment_intents.expected_identifier_masked = excluded.expected_identifier_masked
|
||||
and device_enrollment_intents.lifecycle_state = 'pending'
|
||||
returning id, project_id, enrollment_key, route_id, model_profile_ref,
|
||||
display_name, expected_identifier_kind, expected_identifier_masked,
|
||||
lifecycle_state, expires_at, claimed_device_id, created_at, updated_at,
|
||||
(xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
command.projectId,
|
||||
command.enrollmentKey,
|
||||
command.routeId,
|
||||
command.modelProfileRef,
|
||||
command.displayName,
|
||||
command.identifierKind,
|
||||
command.identifierDigest,
|
||||
command.identifierMasked,
|
||||
command.expiresAt,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireMutationRow(
|
||||
result,
|
||||
"device_enrollment_intent_identity_conflict",
|
||||
);
|
||||
await addAudit(client, {
|
||||
eventType: row.created
|
||||
? "enrollment_intent.created"
|
||||
: "enrollment_intent.updated",
|
||||
actorRef: actor.userRef,
|
||||
projectId: command.projectId,
|
||||
payload: {
|
||||
projectRef: toProjectRef(command.projectId),
|
||||
enrollmentIntentRef: `enrollment-intent:${row.id}`,
|
||||
enrollmentKey: row.enrollment_key,
|
||||
routeRef: `route:${row.route_id}`,
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
identifier: {
|
||||
kind: row.expected_identifier_kind,
|
||||
masked: row.expected_identifier_masked,
|
||||
},
|
||||
lifecycleState: row.lifecycle_state,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: row.created === true,
|
||||
enrollmentIntent: enrollmentIntentView(row),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertCurrentProjectCapability(
|
||||
client,
|
||||
actor,
|
||||
projectId,
|
||||
capability,
|
||||
) {
|
||||
const project = await client.query(
|
||||
`select p.id, p.lifecycle_state, os.lifecycle_state as owner_lifecycle_state
|
||||
from device_projects p
|
||||
join device_owner_scopes os on os.id = p.owner_scope_id
|
||||
where p.id = $1
|
||||
for share of p, os`,
|
||||
[projectId],
|
||||
);
|
||||
const row = project.rows[0];
|
||||
if (!row) throw domainError("device_project_not_found", 404);
|
||||
if (row.owner_lifecycle_state !== "active") {
|
||||
throw domainError("device_owner_scope_inactive", 409);
|
||||
}
|
||||
if (row.lifecycle_state !== "active") {
|
||||
throw domainError("device_project_inactive", 409);
|
||||
}
|
||||
const grants = await client.query(
|
||||
`select id, principal_kind, principal_ref, project_role,
|
||||
capability_allow, capability_deny, lifecycle_state
|
||||
from device_project_grants
|
||||
where project_id = $1
|
||||
order by created_at, id
|
||||
for share`,
|
||||
[projectId],
|
||||
);
|
||||
assertProjectCapability(
|
||||
actor,
|
||||
grants.rows.map((grant) => ({
|
||||
grantRef: `grant:${grant.id}`,
|
||||
principalKind: grant.principal_kind,
|
||||
principalRef: grant.principal_ref,
|
||||
projectRole: grant.project_role,
|
||||
capabilityAllow: grant.capability_allow ?? [],
|
||||
capabilityDeny: grant.capability_deny ?? [],
|
||||
lifecycleState: grant.lifecycle_state,
|
||||
})),
|
||||
capability,
|
||||
);
|
||||
}
|
||||
|
||||
async function findAdapterPackage(client, adapterPackageId) {
|
||||
const result = await client.query(
|
||||
`select id, package_key, display_name, publisher_ref, lifecycle_state,
|
||||
created_at, updated_at
|
||||
from device_adapter_packages
|
||||
where id = $1
|
||||
for share`,
|
||||
[adapterPackageId],
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
throw domainError("device_adapter_package_not_found", 404);
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function findAdapterVersion(client, adapterVersionId) {
|
||||
const result = await client.query(
|
||||
`select av.id, av.adapter_package_id, av.version,
|
||||
av.runtime_package_ref, av.content_digest, av.contract_version,
|
||||
av.capabilities, av.lifecycle_state, av.created_at, av.updated_at,
|
||||
ap.lifecycle_state as package_lifecycle_state
|
||||
from device_adapter_versions av
|
||||
join device_adapter_packages ap on ap.id = av.adapter_package_id
|
||||
where av.id = $1
|
||||
for share of av, ap`,
|
||||
[adapterVersionId],
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
throw domainError("device_adapter_version_not_found", 404);
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function findEdge(client, edgeId) {
|
||||
const result = await client.query(
|
||||
`select id, edge_key, display_name, deployment_ref, lifecycle_state,
|
||||
created_at, updated_at
|
||||
from device_edges
|
||||
where id = $1
|
||||
for share`,
|
||||
[edgeId],
|
||||
);
|
||||
if (!result.rows[0]) throw domainError("device_edge_not_found", 404);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function findModelProfile(client, profileRef) {
|
||||
const result = await client.query(
|
||||
`select mp.profile_ref, mp.schema_version, mp.vendor, mp.model,
|
||||
mp.device_type, mp.protocol, mp.adapter_version_id,
|
||||
mp.schema_artifact_ref, mp.profile_digest, mp.capabilities,
|
||||
mp.lifecycle_state, mp.created_at, mp.updated_at,
|
||||
av.lifecycle_state as adapter_lifecycle_state,
|
||||
ap.lifecycle_state as package_lifecycle_state
|
||||
from device_model_profiles mp
|
||||
left join device_adapter_versions av on av.id = mp.adapter_version_id
|
||||
left join device_adapter_packages ap on ap.id = av.adapter_package_id
|
||||
where mp.profile_ref = $1
|
||||
for share of mp`,
|
||||
[profileRef],
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
throw domainError("device_model_profile_not_found", 404);
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function findProjectRoute(client, projectId, routeId) {
|
||||
const result = await client.query(
|
||||
`select id, project_id, route_key, display_name, edge_id,
|
||||
model_profile_ref, listener_ref, protocol, direction, lifecycle_state,
|
||||
created_at, updated_at
|
||||
from device_routes
|
||||
where id = $1 and project_id = $2
|
||||
for share`,
|
||||
[routeId, projectId],
|
||||
);
|
||||
if (!result.rows[0]) throw domainError("device_route_not_found", 404);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
function assertRouteDependencies(command, edge, profile) {
|
||||
if (profile.adapter_version_id == null) {
|
||||
throw domainError("device_model_profile_unregistered", 409);
|
||||
}
|
||||
if (profile.protocol !== command.protocol) {
|
||||
throw domainError("device_route_protocol_mismatch", 409);
|
||||
}
|
||||
if (
|
||||
edge.lifecycle_state === "retired"
|
||||
|| profile.lifecycle_state === "retired"
|
||||
|| profile.adapter_lifecycle_state === "retired"
|
||||
|| profile.package_lifecycle_state === "retired"
|
||||
) {
|
||||
throw domainError("device_route_dependency_inactive", 409);
|
||||
}
|
||||
if (
|
||||
command.lifecycleState === "active"
|
||||
&& (
|
||||
edge.lifecycle_state !== "active"
|
||||
|| profile.lifecycle_state !== "active"
|
||||
|| profile.adapter_lifecycle_state !== "active"
|
||||
|| profile.package_lifecycle_state !== "active"
|
||||
)
|
||||
) {
|
||||
throw domainError("device_route_dependency_not_active", 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function addAudit(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 requireMutationRow(result, code) {
|
||||
if (!result.rows[0]) throw domainError(code, 409);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
function adapterPackageView(row) {
|
||||
return {
|
||||
adapterPackageRef: `adapter-package:${row.id}`,
|
||||
packageKey: row.package_key,
|
||||
displayName: row.display_name,
|
||||
publisherRef: row.publisher_ref,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function adapterVersionView(row) {
|
||||
return {
|
||||
adapterVersionRef: `adapter-version:${row.id}`,
|
||||
adapterPackageRef: `adapter-package:${row.adapter_package_id}`,
|
||||
version: row.version,
|
||||
runtimePackageRef: row.runtime_package_ref,
|
||||
contentDigest: row.content_digest,
|
||||
contractVersion: row.contract_version,
|
||||
capabilities: [...(row.capabilities ?? [])].sort(),
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function modelProfileView(row) {
|
||||
return {
|
||||
modelProfileRef: row.profile_ref,
|
||||
adapterVersionRef: `adapter-version:${row.adapter_version_id}`,
|
||||
schemaVersion: row.schema_version,
|
||||
vendor: row.vendor,
|
||||
model: row.model,
|
||||
deviceType: row.device_type,
|
||||
protocol: row.protocol,
|
||||
schemaArtifactRef: row.schema_artifact_ref,
|
||||
profileDigest: row.profile_digest,
|
||||
capabilities: [...(row.capabilities ?? [])].sort(),
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function edgeView(row) {
|
||||
return {
|
||||
edgeRef: `edge:${row.id}`,
|
||||
edgeKey: row.edge_key,
|
||||
displayName: row.display_name,
|
||||
deploymentRef: row.deployment_ref ?? null,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
channel: {
|
||||
lifecycleState: row.channel_lifecycle_state ?? "disabled",
|
||||
endpoint: row.channel_endpoint ?? null,
|
||||
servername: row.channel_servername ?? null,
|
||||
generationRef: row.channel_generation_ref ?? null,
|
||||
trustBundleRef: row.channel_trust_bundle_ref ?? null,
|
||||
certificateIdentities: [...(row.channel_certificate_identities ?? [])],
|
||||
},
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function routeView(row) {
|
||||
return {
|
||||
routeRef: `route:${row.id}`,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
routeKey: row.route_key,
|
||||
displayName: row.display_name,
|
||||
edgeRef: `edge:${row.edge_id}`,
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
listenerRef: row.listener_ref,
|
||||
protocol: row.protocol,
|
||||
direction: row.direction,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function enrollmentIntentView(row) {
|
||||
return {
|
||||
enrollmentIntentRef: `enrollment-intent:${row.id}`,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
enrollmentKey: row.enrollment_key,
|
||||
routeRef: `route:${row.route_id}`,
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
displayName: row.display_name,
|
||||
identifier: {
|
||||
kind: row.expected_identifier_kind,
|
||||
masked: row.expected_identifier_masked,
|
||||
},
|
||||
lifecycleState: row.lifecycle_state,
|
||||
expiresAt: toIso(row.expires_at),
|
||||
claimedDeviceRef: row.claimed_device_id
|
||||
? `device:${row.claimed_device_id}`
|
||||
: null,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
return value == null ? null : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
export const DEVICE_LIFECYCLE_COMMAND_KINDS = Object.freeze([
|
||||
"device.claim",
|
||||
"device.update",
|
||||
"device.transfer",
|
||||
"discovery.reject",
|
||||
"discovery.expire",
|
||||
]);
|
||||
|
||||
const commandKindSet = new Set(DEVICE_LIFECYCLE_COMMAND_KINDS);
|
||||
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/;
|
||||
|
||||
export function isLifecycleManagementCommand(kind) {
|
||||
return commandKindSet.has(kind);
|
||||
}
|
||||
|
||||
export function normalizeLifecycleManagementCommand(kind, input) {
|
||||
if (!commandKindSet.has(kind)) {
|
||||
throw new TypeError("device_lifecycle_command_kind_invalid");
|
||||
}
|
||||
assertPlainObject(input);
|
||||
|
||||
if (kind === "device.claim") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"enrollmentIntentRef",
|
||||
"discoveryRef",
|
||||
"deviceKey",
|
||||
"displayName",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
enrollmentIntentId: normalizeEntityRef(
|
||||
input.enrollmentIntentRef,
|
||||
"enrollment-intent",
|
||||
),
|
||||
discoveryId: normalizeEntityRef(input.discoveryRef, "discovery"),
|
||||
deviceKey: normalizePattern(
|
||||
input.deviceKey,
|
||||
keyPattern,
|
||||
"device_key_invalid",
|
||||
),
|
||||
displayName: normalizeDisplayText(input.displayName, 160),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "device.update") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"deviceRef",
|
||||
"displayName",
|
||||
"integrationDeviceId",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
displayName: normalizeDisplayText(input.displayName, 160),
|
||||
integrationDeviceId: Object.prototype.hasOwnProperty.call(input, "integrationDeviceId")
|
||||
? normalizeOptionalDisplayText(
|
||||
input.integrationDeviceId,
|
||||
160,
|
||||
"device_integration_device_id_invalid",
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "device.transfer") {
|
||||
assertAllowedKeys(input, [
|
||||
"deviceRef",
|
||||
"sourceProjectRef",
|
||||
"targetProjectRef",
|
||||
"targetDeviceKey",
|
||||
]);
|
||||
const sourceProjectId = normalizeEntityRef(
|
||||
input.sourceProjectRef,
|
||||
"project",
|
||||
);
|
||||
const targetProjectId = normalizeEntityRef(
|
||||
input.targetProjectRef,
|
||||
"project",
|
||||
);
|
||||
if (sourceProjectId === targetProjectId) {
|
||||
throw new TypeError("device_transfer_target_same_as_source");
|
||||
}
|
||||
return Object.freeze({
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
sourceProjectId,
|
||||
targetProjectId,
|
||||
targetDeviceKey: normalizePattern(
|
||||
input.targetDeviceKey,
|
||||
keyPattern,
|
||||
"device_transfer_target_key_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"discoveryRef",
|
||||
"resolutionCode",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
discoveryId: normalizeEntityRef(input.discoveryRef, "discovery"),
|
||||
resolutionCode: normalizePattern(
|
||||
input.resolutionCode,
|
||||
resolutionPattern,
|
||||
"device_discovery_resolution_code_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEntityRef(value, prefix) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
}
|
||||
const match = 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 new TypeError(`device_${prefix}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizePattern(value, pattern, code) {
|
||||
if (typeof value !== "string" || !pattern.test(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeDisplayText(value, maxLength) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError("device_display_name_invalid");
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (
|
||||
normalized.length < 1
|
||||
|| normalized.length > maxLength
|
||||
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)
|
||||
) {
|
||||
throw new TypeError("device_display_name_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeOptionalDisplayText(value, maxLength, code) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
if (typeof value !== "string") throw new TypeError(code);
|
||||
const normalized = value.trim();
|
||||
if (
|
||||
normalized.length < 1
|
||||
|| normalized.length > maxLength
|
||||
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)
|
||||
) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertPlainObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_lifecycle_command_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedKeys(input, allowed) {
|
||||
const allowedSet = new Set(allowed);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedSet.has(key)) {
|
||||
throw new TypeError(`device_management_command_field_unexpected:${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
normalizeRestrictedIdentifierProjection,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import { isLifecycleManagementCommand } from "./lifecycle-management.mjs";
|
||||
import {
|
||||
assertProjectCapability,
|
||||
toProjectRef,
|
||||
} from "./project-management.mjs";
|
||||
|
||||
export async function applyLifecycleManagementCommand(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isLifecycleManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_lifecycle_command_kind_invalid");
|
||||
}
|
||||
if (commandKind === "device.claim") {
|
||||
return claimDevice(client, actor, command);
|
||||
}
|
||||
if (commandKind === "device.update") {
|
||||
return updateDevice(client, actor, command);
|
||||
}
|
||||
if (commandKind === "device.transfer") {
|
||||
return transferDevice(client, actor, command);
|
||||
}
|
||||
return resolveDiscovery(client, actor, command, commandKind);
|
||||
}
|
||||
|
||||
export async function authorizeLifecycleManagementReplay(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isLifecycleManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_lifecycle_command_kind_invalid");
|
||||
}
|
||||
if (commandKind === "device.transfer") {
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.sourceProjectId,
|
||||
"device.transfer",
|
||||
);
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.targetProjectId,
|
||||
"device.transfer",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (commandKind === "device.update") {
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"project.manage",
|
||||
);
|
||||
const device = await findDeviceForUpdate(client, command.deviceId);
|
||||
if (device.project_id !== command.projectId) {
|
||||
throw domainError("device_update_project_mismatch", 409);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"device.claim",
|
||||
);
|
||||
if (commandKind === "device.claim") {
|
||||
const current = await client.query(
|
||||
`select di.project_id
|
||||
from device_discoveries dd
|
||||
join device_instances di on di.id = dd.claimed_device_id
|
||||
where dd.id = $1`,
|
||||
[command.discoveryId],
|
||||
);
|
||||
const currentProjectId = current.rows[0]?.project_id;
|
||||
if (currentProjectId && currentProjectId !== command.projectId) {
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
currentProjectId,
|
||||
"device.claim",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDevice(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"project.manage",
|
||||
);
|
||||
const current = await findDeviceForUpdate(client, command.deviceId);
|
||||
if (current.project_id !== project.id) {
|
||||
throw domainError("device_update_project_mismatch", 409);
|
||||
}
|
||||
if (current.lifecycle_state !== "claimed" && current.lifecycle_state !== "active") {
|
||||
throw domainError("device_update_lifecycle_blocked", 409);
|
||||
}
|
||||
|
||||
const updated = await client.query(
|
||||
`update device_instances
|
||||
set display_name = $3,
|
||||
integration_device_id = case when $5 then $4 else integration_device_id end,
|
||||
updated_at = now()
|
||||
where id = $1 and project_id = $2
|
||||
returning id, owner_scope_id, project_id, device_key,
|
||||
model_profile_ref, display_name, integration_device_id, identifier_kind,
|
||||
identifier_masked, lifecycle_state, created_at, updated_at`,
|
||||
[
|
||||
current.id,
|
||||
project.id,
|
||||
command.displayName,
|
||||
command.integrationDeviceId ?? null,
|
||||
command.integrationDeviceId !== undefined,
|
||||
],
|
||||
);
|
||||
const device = updated.rows[0];
|
||||
if (!device) throw domainError("device_update_failed", 409);
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: "device.updated",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: device.id,
|
||||
payload: {
|
||||
deviceRef: `device:${device.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
changedFields: command.integrationDeviceId === undefined
|
||||
? ["displayName"]
|
||||
: ["displayName", "integrationDeviceId"],
|
||||
},
|
||||
});
|
||||
return {
|
||||
updated: true,
|
||||
device: deviceView(device, project),
|
||||
};
|
||||
}
|
||||
|
||||
async function claimDevice(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"device.claim",
|
||||
);
|
||||
const enrollment = await findEnrollmentForUpdate(
|
||||
client,
|
||||
command.projectId,
|
||||
command.enrollmentIntentId,
|
||||
);
|
||||
const discovery = await findDiscoveryForUpdate(
|
||||
client,
|
||||
command.projectId,
|
||||
command.discoveryId,
|
||||
);
|
||||
assertClaimEvidence(command, enrollment, discovery);
|
||||
|
||||
const deviceId = randomUUID();
|
||||
const inserted = await client.query(
|
||||
`insert into device_instances (
|
||||
id,
|
||||
contour_id,
|
||||
owner_scope_id,
|
||||
project_id,
|
||||
device_key,
|
||||
model_profile_ref,
|
||||
display_name,
|
||||
identifier_kind,
|
||||
identifier_digest,
|
||||
identifier_masked,
|
||||
lifecycle_state
|
||||
) values ($1, null, $2, $3, $4, $5, $6, $7, $8, $9, 'claimed')
|
||||
returning id, owner_scope_id, project_id, device_key,
|
||||
model_profile_ref, display_name, integration_device_id, identifier_kind,
|
||||
identifier_masked, lifecycle_state, created_at, updated_at`,
|
||||
[
|
||||
deviceId,
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
command.deviceKey,
|
||||
discovery.model_profile_ref,
|
||||
command.displayName,
|
||||
discovery.identifier_kind,
|
||||
discovery.identifier_digest,
|
||||
discovery.identifier_masked,
|
||||
],
|
||||
);
|
||||
const device = inserted.rows[0];
|
||||
if (!device) throw domainError("device_claim_insert_failed", 409);
|
||||
|
||||
const identifierId = randomUUID();
|
||||
await client.query(
|
||||
`insert into device_restricted_identifiers (
|
||||
id,
|
||||
device_id,
|
||||
owner_scope_id,
|
||||
project_id,
|
||||
identifier_kind,
|
||||
identifier_digest,
|
||||
identifier_masked,
|
||||
provenance_kind,
|
||||
is_primary,
|
||||
created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, 'claim', true, $8)`,
|
||||
[
|
||||
identifierId,
|
||||
device.id,
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
discovery.identifier_kind,
|
||||
discovery.identifier_digest,
|
||||
discovery.identifier_masked,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
|
||||
const claimedDiscovery = await client.query(
|
||||
`update device_discoveries
|
||||
set lifecycle_state = 'claimed',
|
||||
claimed_device_id = $2,
|
||||
claimed_at = now(),
|
||||
claimed_by = $3,
|
||||
resolution_code = 'claimed',
|
||||
resolved_at = now(),
|
||||
resolved_by_ref = $3,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and lifecycle_state = 'quarantine'
|
||||
and enrollment_intent_id = $4
|
||||
returning id`,
|
||||
[discovery.id, device.id, actor.userRef, enrollment.id],
|
||||
);
|
||||
if (!claimedDiscovery.rows[0]) {
|
||||
throw domainError("device_discovery_not_claimable", 409);
|
||||
}
|
||||
|
||||
const claimedEnrollment = await client.query(
|
||||
`update device_enrollment_intents
|
||||
set lifecycle_state = 'claimed',
|
||||
claimed_device_id = $2,
|
||||
claimed_at = now(),
|
||||
resolution_code = 'claimed',
|
||||
resolved_at = now(),
|
||||
resolved_by_ref = $3,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and lifecycle_state = 'observed'
|
||||
and observed_discovery_id = $4
|
||||
and (expires_at is null or expires_at > now())
|
||||
returning id`,
|
||||
[enrollment.id, device.id, actor.userRef, discovery.id],
|
||||
);
|
||||
if (!claimedEnrollment.rows[0]) {
|
||||
throw domainError("device_enrollment_not_claimable", 409);
|
||||
}
|
||||
|
||||
const transitionId = randomUUID();
|
||||
await client.query(
|
||||
`insert into device_ownership_transitions (
|
||||
id,
|
||||
device_id,
|
||||
transition_kind,
|
||||
target_owner_scope_id,
|
||||
target_project_id,
|
||||
actor_ref
|
||||
) values ($1, $2, 'claim', $3, $4, $5)`,
|
||||
[
|
||||
transitionId,
|
||||
device.id,
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
await addAudit(client, {
|
||||
eventType: "device.claimed",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: device.id,
|
||||
discoveryId: discovery.id,
|
||||
payload: {
|
||||
deviceRef: `device:${device.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
||||
discoveryRef: `discovery:${discovery.id}`,
|
||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||
identifierRef: `identifier:${identifierId}`,
|
||||
modelProfileRef: device.model_profile_ref,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: true,
|
||||
device: deviceView(device, project),
|
||||
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
||||
discoveryRef: `discovery:${discovery.id}`,
|
||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||
identifierRef: `identifier:${identifierId}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDiscovery(client, actor, command, commandKind) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"device.claim",
|
||||
);
|
||||
const discovery = await findDiscoveryForUpdate(
|
||||
client,
|
||||
command.projectId,
|
||||
command.discoveryId,
|
||||
);
|
||||
if (
|
||||
discovery.lifecycle_state !== "quarantine"
|
||||
|| !discovery.enrollment_intent_id
|
||||
) {
|
||||
throw domainError("device_discovery_not_resolvable", 409);
|
||||
}
|
||||
const enrollment = await findEnrollmentForUpdate(
|
||||
client,
|
||||
command.projectId,
|
||||
discovery.enrollment_intent_id,
|
||||
);
|
||||
if (
|
||||
enrollment.lifecycle_state !== "observed"
|
||||
|| enrollment.observed_discovery_id !== discovery.id
|
||||
) {
|
||||
throw domainError("device_enrollment_not_resolvable", 409);
|
||||
}
|
||||
|
||||
const discoveryState = commandKind === "discovery.reject"
|
||||
? "rejected"
|
||||
: "expired";
|
||||
const enrollmentState = commandKind === "discovery.reject"
|
||||
? "cancelled"
|
||||
: "expired";
|
||||
await client.query(
|
||||
`update device_discoveries
|
||||
set lifecycle_state = $2,
|
||||
resolution_code = $3,
|
||||
resolved_at = now(),
|
||||
resolved_by_ref = $4,
|
||||
updated_at = now()
|
||||
where id = $1 and lifecycle_state = 'quarantine'`,
|
||||
[discovery.id, discoveryState, command.resolutionCode, actor.userRef],
|
||||
);
|
||||
await client.query(
|
||||
`update device_enrollment_intents
|
||||
set lifecycle_state = $2,
|
||||
resolution_code = $3,
|
||||
resolved_at = now(),
|
||||
resolved_by_ref = $4,
|
||||
updated_at = now()
|
||||
where id = $1 and lifecycle_state = 'observed'`,
|
||||
[enrollment.id, enrollmentState, command.resolutionCode, actor.userRef],
|
||||
);
|
||||
await addAudit(client, {
|
||||
eventType: `discovery.${discoveryState}`,
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
discoveryId: discovery.id,
|
||||
payload: {
|
||||
projectRef: toProjectRef(project.id),
|
||||
discoveryRef: `discovery:${discovery.id}`,
|
||||
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
||||
lifecycleState: discoveryState,
|
||||
resolutionCode: command.resolutionCode,
|
||||
},
|
||||
});
|
||||
return {
|
||||
discovery: {
|
||||
discoveryRef: `discovery:${discovery.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
||||
lifecycleState: discoveryState,
|
||||
identifier: {
|
||||
kind: discovery.identifier_kind,
|
||||
masked: discovery.identifier_masked,
|
||||
},
|
||||
resolutionCode: command.resolutionCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function transferDevice(client, actor, command) {
|
||||
const device = await findDeviceForUpdate(client, command.deviceId);
|
||||
if (device.project_id !== command.sourceProjectId) {
|
||||
throw domainError("device_transfer_source_mismatch", 409);
|
||||
}
|
||||
if (!device.owner_scope_id || !device.project_id || device.contour_id) {
|
||||
throw domainError("device_transfer_legacy_ownership_unsupported", 409);
|
||||
}
|
||||
if (["online", "retired"].includes(device.lifecycle_state)) {
|
||||
throw domainError("device_transfer_lifecycle_blocked", 409);
|
||||
}
|
||||
|
||||
const sourceProject = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.sourceProjectId,
|
||||
"device.transfer",
|
||||
);
|
||||
const targetProject = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.targetProjectId,
|
||||
"device.transfer",
|
||||
);
|
||||
if (sourceProject.owner_scope_id !== device.owner_scope_id) {
|
||||
throw domainError("device_transfer_owner_mismatch", 409);
|
||||
}
|
||||
|
||||
const activeSessions = await client.query(
|
||||
`select exists (
|
||||
select 1 from device_sessions
|
||||
where device_id = $1
|
||||
and lifecycle_state in ('connecting', 'online', 'closing')
|
||||
) as active`,
|
||||
[device.id],
|
||||
);
|
||||
if (activeSessions.rows[0]?.active === true) {
|
||||
throw domainError("device_transfer_active_session", 409);
|
||||
}
|
||||
|
||||
const activeCredentialBindings = await client.query(
|
||||
`select exists (
|
||||
select 1 from device_credential_bindings
|
||||
where device_id = $1 and lifecycle_state = 'active'
|
||||
) as active`,
|
||||
[device.id],
|
||||
);
|
||||
if (activeCredentialBindings.rows[0]?.active === true) {
|
||||
throw domainError("device_transfer_active_credential_binding", 409);
|
||||
}
|
||||
|
||||
const activeResourceBindings = await client.query(
|
||||
`select exists (
|
||||
select 1 from device_resource_bindings
|
||||
where device_id = $1
|
||||
and lifecycle_state in ('pending_external_approval', 'active')
|
||||
) as active`,
|
||||
[device.id],
|
||||
);
|
||||
if (activeResourceBindings.rows[0]?.active === true) {
|
||||
throw domainError("device_transfer_active_resource_binding", 409);
|
||||
}
|
||||
|
||||
const configurationState = await client.query(
|
||||
`select desired_revision_id, applied_revision_id
|
||||
from device_configuration_state
|
||||
where device_id = $1
|
||||
for update`,
|
||||
[device.id],
|
||||
);
|
||||
if (configurationState.rows[0]?.applied_revision_id) {
|
||||
throw domainError("device_transfer_applied_configuration", 409);
|
||||
}
|
||||
|
||||
const nonterminalCommands = await client.query(
|
||||
`select exists (
|
||||
select 1 from device_commands
|
||||
where device_id = $1
|
||||
and lifecycle_state not in ('verified', 'failed', 'expired', 'unknown')
|
||||
) as active`,
|
||||
[device.id],
|
||||
);
|
||||
if (nonterminalCommands.rows[0]?.active === true) {
|
||||
throw domainError("device_transfer_nonterminal_command", 409);
|
||||
}
|
||||
|
||||
const clearedConfiguration = await client.query(
|
||||
`delete from device_configuration_state
|
||||
where device_id = $1 and applied_revision_id is null`,
|
||||
[device.id],
|
||||
);
|
||||
|
||||
const detached = await client.query(
|
||||
`delete from device_collection_members
|
||||
where device_id = $1 and project_id = $2`,
|
||||
[device.id, sourceProject.id],
|
||||
);
|
||||
const updated = await client.query(
|
||||
`update device_instances
|
||||
set owner_scope_id = $2,
|
||||
project_id = $3,
|
||||
device_key = $4,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning id, contour_id, owner_scope_id, project_id, device_key,
|
||||
model_profile_ref, display_name, integration_device_id, identifier_kind,
|
||||
identifier_masked, lifecycle_state, created_at, updated_at`,
|
||||
[
|
||||
device.id,
|
||||
targetProject.owner_scope_id,
|
||||
targetProject.id,
|
||||
command.targetDeviceKey,
|
||||
],
|
||||
);
|
||||
const moved = updated.rows[0];
|
||||
if (!moved) throw domainError("device_transfer_update_failed", 409);
|
||||
|
||||
const movedIdentifiers = await client.query(
|
||||
`update device_restricted_identifiers
|
||||
set owner_scope_id = $2,
|
||||
project_id = $3,
|
||||
updated_at = now()
|
||||
where device_id = $1 and lifecycle_state = 'active'`,
|
||||
[device.id, targetProject.owner_scope_id, targetProject.id],
|
||||
);
|
||||
if (Number(movedIdentifiers.rowCount || 0) < 1) {
|
||||
throw domainError("device_identifier_projection_missing", 409);
|
||||
}
|
||||
|
||||
const transitionId = randomUUID();
|
||||
await client.query(
|
||||
`insert into device_ownership_transitions (
|
||||
id,
|
||||
device_id,
|
||||
transition_kind,
|
||||
source_owner_scope_id,
|
||||
source_project_id,
|
||||
target_owner_scope_id,
|
||||
target_project_id,
|
||||
actor_ref
|
||||
) values ($1, $2, 'transfer', $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
transitionId,
|
||||
device.id,
|
||||
sourceProject.owner_scope_id,
|
||||
sourceProject.id,
|
||||
targetProject.owner_scope_id,
|
||||
targetProject.id,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const auditPayload = {
|
||||
deviceRef: `device:${device.id}`,
|
||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||
sourceProjectRef: toProjectRef(sourceProject.id),
|
||||
targetProjectRef: toProjectRef(targetProject.id),
|
||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
||||
clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0,
|
||||
};
|
||||
await addAudit(client, {
|
||||
eventType: "device.transferred_out",
|
||||
actorRef: actor.userRef,
|
||||
projectId: sourceProject.id,
|
||||
deviceId: device.id,
|
||||
payload: auditPayload,
|
||||
});
|
||||
await addAudit(client, {
|
||||
eventType: "device.transferred_in",
|
||||
actorRef: actor.userRef,
|
||||
projectId: targetProject.id,
|
||||
deviceId: device.id,
|
||||
payload: auditPayload,
|
||||
});
|
||||
return {
|
||||
transferred: true,
|
||||
device: deviceView(moved, targetProject),
|
||||
sourceProjectRef: toProjectRef(sourceProject.id),
|
||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
||||
clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
projectId,
|
||||
capability,
|
||||
{ lock = true } = {},
|
||||
) {
|
||||
const projectLockClause = lock ? "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
|
||||
from device_projects p
|
||||
join device_owner_scopes os on os.id = p.owner_scope_id
|
||||
where p.id = $1
|
||||
${projectLockClause}`,
|
||||
[projectId],
|
||||
);
|
||||
const project = result.rows[0];
|
||||
if (!project) throw domainError("device_project_not_found", 404);
|
||||
if (project.owner_lifecycle_state !== "active") {
|
||||
throw domainError("device_owner_scope_inactive", 409);
|
||||
}
|
||||
if (project.lifecycle_state !== "active") {
|
||||
throw domainError("device_project_inactive", 409);
|
||||
}
|
||||
const grants = await client.query(
|
||||
`select id, principal_kind, principal_ref, project_role,
|
||||
capability_allow, capability_deny, lifecycle_state
|
||||
from device_project_grants
|
||||
where project_id = $1
|
||||
order by created_at, id
|
||||
${lock ? "for share" : ""}`,
|
||||
[projectId],
|
||||
);
|
||||
assertProjectCapability(
|
||||
actor,
|
||||
grants.rows.map((grant) => ({
|
||||
grantRef: `grant:${grant.id}`,
|
||||
principalKind: grant.principal_kind,
|
||||
principalRef: grant.principal_ref,
|
||||
projectRole: grant.project_role,
|
||||
capabilityAllow: grant.capability_allow ?? [],
|
||||
capabilityDeny: grant.capability_deny ?? [],
|
||||
lifecycleState: grant.lifecycle_state,
|
||||
})),
|
||||
capability,
|
||||
);
|
||||
return project;
|
||||
}
|
||||
|
||||
async function findEnrollmentForUpdate(client, projectId, enrollmentId) {
|
||||
const result = await client.query(
|
||||
`select id, project_id, route_id, model_profile_ref,
|
||||
expected_identifier_kind, expected_identifier_digest,
|
||||
expected_identifier_masked, lifecycle_state,
|
||||
observed_discovery_id, claimed_device_id, expires_at
|
||||
from device_enrollment_intents
|
||||
where id = $1 and project_id = $2
|
||||
for update`,
|
||||
[enrollmentId, projectId],
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
throw domainError("device_enrollment_intent_not_found", 404);
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function findDiscoveryForUpdate(client, projectId, discoveryId) {
|
||||
const result = await client.query(
|
||||
`select id, project_id, route_id, enrollment_intent_id,
|
||||
model_profile_ref, protocol, identifier_kind, identifier_digest,
|
||||
identifier_masked, lifecycle_state, claimed_device_id
|
||||
from device_discoveries
|
||||
where id = $1 and project_id = $2
|
||||
for update`,
|
||||
[discoveryId, projectId],
|
||||
);
|
||||
if (!result.rows[0]) throw domainError("device_discovery_not_found", 404);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function findDeviceForUpdate(client, deviceId) {
|
||||
const result = await client.query(
|
||||
`select id, contour_id, owner_scope_id, project_id, device_key,
|
||||
model_profile_ref, display_name, integration_device_id, identifier_kind,
|
||||
identifier_masked, lifecycle_state, created_at, updated_at
|
||||
from device_instances
|
||||
where id = $1
|
||||
for update`,
|
||||
[deviceId],
|
||||
);
|
||||
if (!result.rows[0]) throw domainError("device_not_found", 404);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
function assertClaimEvidence(command, enrollment, discovery) {
|
||||
if (
|
||||
enrollment.lifecycle_state !== "observed"
|
||||
|| enrollment.observed_discovery_id !== discovery.id
|
||||
|| discovery.lifecycle_state !== "quarantine"
|
||||
|| discovery.enrollment_intent_id !== enrollment.id
|
||||
|| discovery.project_id !== command.projectId
|
||||
|| discovery.route_id !== enrollment.route_id
|
||||
|| discovery.model_profile_ref !== enrollment.model_profile_ref
|
||||
|| discovery.identifier_kind !== enrollment.expected_identifier_kind
|
||||
|| discovery.identifier_digest !== enrollment.expected_identifier_digest
|
||||
|| discovery.identifier_masked !== enrollment.expected_identifier_masked
|
||||
) {
|
||||
throw domainError("device_claim_evidence_mismatch", 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function addAudit(client, {
|
||||
eventType,
|
||||
actorRef,
|
||||
projectId,
|
||||
deviceId = null,
|
||||
discoveryId = null,
|
||||
payload,
|
||||
}) {
|
||||
await client.query(
|
||||
`insert into device_audit_events (
|
||||
id,
|
||||
event_type,
|
||||
actor_ref,
|
||||
project_id,
|
||||
device_id,
|
||||
discovery_id,
|
||||
payload
|
||||
) values ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
||||
[
|
||||
randomUUID(),
|
||||
eventType,
|
||||
actorRef,
|
||||
projectId,
|
||||
deviceId,
|
||||
discoveryId,
|
||||
JSON.stringify(payload),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function deviceView(row, project) {
|
||||
return {
|
||||
deviceRef: `device:${row.id}`,
|
||||
deviceKey: row.device_key,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
ownerScope: {
|
||||
ownerScopeRef: `owner-scope:${row.owner_scope_id}`,
|
||||
scopeKind: project.scope_kind,
|
||||
ownerRef: project.owner_ref,
|
||||
},
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
displayName: row.display_name,
|
||||
integrationDeviceId: row.integration_device_id ?? null,
|
||||
identifier: normalizeRestrictedIdentifierProjection({
|
||||
kind: row.identifier_kind,
|
||||
masked: row.identifier_masked,
|
||||
}),
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
|
||||
isInfrastructureManagementCommand,
|
||||
normalizeInfrastructureManagementCommand,
|
||||
} from "./infrastructure-management.mjs";
|
||||
import {
|
||||
DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||
isControlResourceManagementCommand,
|
||||
normalizeControlResourceManagementCommand,
|
||||
} from "./control-resource-management.mjs";
|
||||
import {
|
||||
DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
isLifecycleManagementCommand,
|
||||
normalizeLifecycleManagementCommand,
|
||||
} from "./lifecycle-management.mjs";
|
||||
import {
|
||||
DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||
normalizeManagementCommand,
|
||||
} from "./project-management.mjs";
|
||||
import {
|
||||
DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||
isSensitiveReferenceManagementCommand,
|
||||
normalizeSensitiveReferenceManagementCommand,
|
||||
} from "./sensitive-reference-management.mjs";
|
||||
|
||||
export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
|
||||
...DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||
...DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
|
||||
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||
]);
|
||||
|
||||
export function normalizeDeviceManagementCommand(kind, input) {
|
||||
if (isControlResourceManagementCommand(kind)) {
|
||||
return normalizeControlResourceManagementCommand(kind, input);
|
||||
}
|
||||
if (isSensitiveReferenceManagementCommand(kind)) {
|
||||
return normalizeSensitiveReferenceManagementCommand(kind, input);
|
||||
}
|
||||
if (isLifecycleManagementCommand(kind)) {
|
||||
return normalizeLifecycleManagementCommand(kind, input);
|
||||
}
|
||||
if (isInfrastructureManagementCommand(kind)) {
|
||||
return normalizeInfrastructureManagementCommand(kind, input);
|
||||
}
|
||||
return normalizeManagementCommand(kind, input);
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import pg from "pg";
|
||||
|
||||
import { observeQuarantineDiscovery } from "./discovery-repository.mjs";
|
||||
import { acceptGatewayMessage } from "./gateway-message-repository.mjs";
|
||||
import { resolveInboundRoute } from "./inbound-route-repository.mjs";
|
||||
import {
|
||||
applyControlResourceManagementCommand,
|
||||
authorizeControlResourceManagementReplay,
|
||||
} from "./control-resource-repository.mjs";
|
||||
import {
|
||||
isControlResourceManagementCommand,
|
||||
} from "./control-resource-management.mjs";
|
||||
import {
|
||||
getDeviceProjectWorkspace,
|
||||
listAccessibleDeviceProjects,
|
||||
} from "./project-query-repository.mjs";
|
||||
import {
|
||||
applyInfrastructureManagementCommand,
|
||||
authorizeInfrastructureManagementReplay,
|
||||
} from "./infrastructure-repository.mjs";
|
||||
import { isInfrastructureManagementCommand } from "./infrastructure-management.mjs";
|
||||
import { isLifecycleManagementCommand } from "./lifecycle-management.mjs";
|
||||
import {
|
||||
applyLifecycleManagementCommand,
|
||||
authorizeLifecycleManagementReplay,
|
||||
} from "./lifecycle-repository.mjs";
|
||||
import {
|
||||
applySensitiveReferenceManagementCommand,
|
||||
authorizeSensitiveReferenceManagementReplay,
|
||||
} from "./sensitive-reference-repository.mjs";
|
||||
import {
|
||||
isSensitiveReferenceManagementCommand,
|
||||
} from "./sensitive-reference-management.mjs";
|
||||
import {
|
||||
assertActorCanManageOwnerScope,
|
||||
assertGrantMutationAllowed,
|
||||
assertProjectCapability,
|
||||
toProjectRef,
|
||||
} from "./project-management.mjs";
|
||||
import {
|
||||
dispatchTypedCommand,
|
||||
planTypedServicePing,
|
||||
recordTypedCommandStatus,
|
||||
} from "./typed-command-repository.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",
|
||||
"004_device_registry_foundation.sql",
|
||||
"005_device_registry_commands.sql",
|
||||
"006_device_lifecycle_ownership.sql",
|
||||
"007_device_lifecycle_commands.sql",
|
||||
"008_device_sensitive_references.sql",
|
||||
"009_device_sensitive_reference_commands.sql",
|
||||
"010_device_control_resources.sql",
|
||||
"011_device_control_resource_commands.sql",
|
||||
"012_device_gateway_message_receipts.sql",
|
||||
"013_device_edge_channels.sql",
|
||||
"014_device_registry_profile_commands.sql",
|
||||
"015_device_integration_identity.sql",
|
||||
];
|
||||
|
||||
export class PostgresDeviceRepository {
|
||||
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");
|
||||
}
|
||||
this.pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
max: normalizePoolSize(poolSize),
|
||||
});
|
||||
}
|
||||
|
||||
async migrate() {
|
||||
for (const migrationFile of migrationFiles) {
|
||||
const sql = await readFile(
|
||||
resolve(serviceRoot, "migrations", migrationFile),
|
||||
"utf8",
|
||||
);
|
||||
await this.pool.query(sql);
|
||||
}
|
||||
}
|
||||
|
||||
async health() {
|
||||
await this.pool.query("select 1");
|
||||
return "ready";
|
||||
}
|
||||
|
||||
async upsertQuarantineDiscovery(input) {
|
||||
return observeQuarantineDiscovery({
|
||||
pool: this.pool,
|
||||
...input,
|
||||
});
|
||||
}
|
||||
|
||||
async acceptAdapterMessage(input) {
|
||||
return acceptGatewayMessage({
|
||||
pool: this.pool,
|
||||
...input,
|
||||
});
|
||||
}
|
||||
|
||||
async resolveInboundRoute(input) {
|
||||
return this.#executeRead((client) => resolveInboundRoute(client, input));
|
||||
}
|
||||
|
||||
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 listAccessibleProjects(actor) {
|
||||
return this.#executeRead((client) =>
|
||||
listAccessibleDeviceProjects(client, actor)
|
||||
);
|
||||
}
|
||||
|
||||
async getProjectWorkspace(actor, projectId, options) {
|
||||
return this.#executeRead((client) =>
|
||||
getDeviceProjectWorkspace(client, actor, projectId, options)
|
||||
);
|
||||
}
|
||||
|
||||
async listActiveEdgeChannelRegistrations(limit = 64) {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
||||
throw new TypeError("device_edge_channel_registration_limit_invalid");
|
||||
}
|
||||
return this.#executeRead(async (client) => {
|
||||
const result = await client.query(
|
||||
`select id, channel_endpoint, channel_servername,
|
||||
channel_generation_ref, channel_trust_bundle_ref,
|
||||
channel_certificate_identities, channel_lifecycle_state
|
||||
from device_edges
|
||||
where lifecycle_state = 'active'
|
||||
and channel_lifecycle_state = 'active'
|
||||
order by id
|
||||
limit $1`,
|
||||
[limit],
|
||||
);
|
||||
return result.rows.map((row) => Object.freeze({
|
||||
edgeRegistrationId: `edge:${row.id}`,
|
||||
endpoint: row.channel_endpoint,
|
||||
servername: row.channel_servername,
|
||||
channelGeneration: row.channel_generation_ref,
|
||||
trustBundleRef: row.channel_trust_bundle_ref,
|
||||
certificateIdentities: Object.freeze(
|
||||
[...(row.channel_certificate_identities ?? [])],
|
||||
),
|
||||
lifecycleState: row.channel_lifecycle_state,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async planTypedServicePing(input) {
|
||||
return this.#executeWrite((client) => planTypedServicePing(client, input));
|
||||
}
|
||||
|
||||
async dispatchTypedCommand(input) {
|
||||
return this.#executeWrite((client) => dispatchTypedCommand(client, input));
|
||||
}
|
||||
|
||||
async recordTypedCommandStatus(input) {
|
||||
return this.#executeWrite((client) => recordTypedCommandStatus(client, input));
|
||||
}
|
||||
|
||||
async #executeWrite(operation) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
const result = await operation(client);
|
||||
await client.query("commit");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("rollback").catch(() => undefined);
|
||||
throw mapPostgresError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async #executeRead(operation) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("begin transaction read only");
|
||||
const result = await operation(client);
|
||||
await client.query("commit");
|
||||
return 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 (isControlResourceManagementCommand(commandKind)) {
|
||||
return applyControlResourceManagementCommand(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
||||
return applySensitiveReferenceManagementCommand(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isLifecycleManagementCommand(commandKind)) {
|
||||
return applyLifecycleManagementCommand(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isInfrastructureManagementCommand(commandKind)) {
|
||||
return applyInfrastructureManagementCommand(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 (isControlResourceManagementCommand(commandKind)) {
|
||||
return authorizeControlResourceManagementReplay(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
||||
return authorizeSensitiveReferenceManagementReplay(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isLifecycleManagementCommand(commandKind)) {
|
||||
return authorizeLifecycleManagementReplay(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isInfrastructureManagementCommand(commandKind)) {
|
||||
return authorizeInfrastructureManagementReplay(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) {
|
||||
throw new TypeError("device_database_pool_size_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
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",
|
||||
"configuration.manage",
|
||||
"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",
|
||||
"configuration.manage",
|
||||
"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",
|
||||
"configuration.manage",
|
||||
"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;
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
import {
|
||||
resolveProjectAccess,
|
||||
toProjectRef,
|
||||
} from "./project-management.mjs";
|
||||
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
|
||||
|
||||
export async function listAccessibleDeviceProjects(client, actor) {
|
||||
const result = await client.query(
|
||||
`select p.id, p.project_key, p.name, p.description,
|
||||
p.lifecycle_state, p.created_at, p.updated_at,
|
||||
os.id as owner_scope_id, os.scope_kind, os.owner_ref,
|
||||
os.display_name as owner_display_name,
|
||||
g.id as grant_id, g.principal_kind, g.principal_ref,
|
||||
g.project_role, g.capability_allow, g.capability_deny,
|
||||
g.lifecycle_state as grant_lifecycle_state,
|
||||
(select count(*)::bigint from device_instances di
|
||||
where di.project_id = p.id) as device_count,
|
||||
(select count(*)::bigint from device_collections dc
|
||||
where dc.project_id = p.id and dc.lifecycle_state = 'active') as collection_count,
|
||||
(select count(*)::bigint from device_discoveries dd
|
||||
where dd.project_id = p.id and dd.lifecycle_state = 'quarantine') as discovery_count
|
||||
from device_projects p
|
||||
join device_owner_scopes os on os.id = p.owner_scope_id
|
||||
join device_project_grants g on g.project_id = p.id
|
||||
where p.lifecycle_state <> 'archived'
|
||||
and os.lifecycle_state = 'active'
|
||||
and g.lifecycle_state = 'active'
|
||||
and (
|
||||
(g.principal_kind = 'user' and g.principal_ref = $1)
|
||||
or (g.principal_kind = 'group' and g.principal_ref = any($2::text[]))
|
||||
)
|
||||
order by os.display_name, p.name, g.created_at, g.id`,
|
||||
[actor.userRef, actor.groupRefs],
|
||||
);
|
||||
|
||||
const projects = new Map();
|
||||
for (const row of result.rows) {
|
||||
const entry = projects.get(row.id) ?? { row, grants: [] };
|
||||
entry.grants.push(grantView(row));
|
||||
projects.set(row.id, entry);
|
||||
}
|
||||
|
||||
return [...projects.values()].flatMap(({ row, grants }) => {
|
||||
const access = resolveProjectAccess({ actor, grants });
|
||||
return access.allowed ? [projectSummaryView(row, access)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDeviceProjectWorkspace(
|
||||
client,
|
||||
actor,
|
||||
projectId,
|
||||
{ commandTransport = "disabled" } = {},
|
||||
) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
projectId,
|
||||
"project.read",
|
||||
{ lock: false },
|
||||
);
|
||||
const grantsResult = await client.query(
|
||||
`select id, principal_kind, principal_ref, project_role,
|
||||
capability_allow, capability_deny, lifecycle_state
|
||||
from device_project_grants
|
||||
where project_id = $1
|
||||
order by created_at, id`,
|
||||
[projectId],
|
||||
);
|
||||
const access = resolveProjectAccess({
|
||||
actor,
|
||||
grants: grantsResult.rows.map(storedGrantView),
|
||||
});
|
||||
|
||||
const devices = await client.query(
|
||||
`select di.id, di.device_key, di.display_name, di.integration_device_id,
|
||||
di.model_profile_ref,
|
||||
di.lifecycle_state, di.created_at, di.updated_at,
|
||||
identifier.identifier_kind, identifier.identifier_masked,
|
||||
session.lifecycle_state as session_state,
|
||||
session.last_seen_at
|
||||
from device_instances di
|
||||
left join lateral (
|
||||
select dri.identifier_kind, dri.identifier_masked
|
||||
from device_restricted_identifiers dri
|
||||
where dri.device_id = di.id
|
||||
and dri.lifecycle_state = 'active'
|
||||
order by dri.is_primary desc, dri.created_at
|
||||
limit 1
|
||||
) identifier on true
|
||||
left join lateral (
|
||||
select ds.lifecycle_state, ds.last_seen_at
|
||||
from device_sessions ds
|
||||
where ds.device_id = di.id
|
||||
and ds.lifecycle_state in ('connecting', 'online')
|
||||
order by ds.last_seen_at desc
|
||||
limit 1
|
||||
) session on true
|
||||
where di.project_id = $1
|
||||
order by di.display_name, di.id`,
|
||||
[projectId],
|
||||
);
|
||||
const collections = await client.query(
|
||||
`select dc.id, dc.collection_key, dc.name, dc.description,
|
||||
dc.lifecycle_state, dc.created_at, dc.updated_at,
|
||||
count(dcm.device_id)::bigint as member_count
|
||||
from device_collections dc
|
||||
left join device_collection_members dcm
|
||||
on dcm.collection_id = dc.id and dcm.project_id = dc.project_id
|
||||
where dc.project_id = $1
|
||||
group by dc.id
|
||||
order by dc.name, dc.id`,
|
||||
[projectId],
|
||||
);
|
||||
const discoveries = await client.query(
|
||||
`select dd.id, dd.identifier_kind, dd.identifier_masked,
|
||||
dd.model_profile_ref, dd.protocol, dd.lifecycle_state,
|
||||
dd.first_observed_at, dd.last_observed_at,
|
||||
dd.enrollment_intent_id, dd.claimed_device_id
|
||||
from device_discoveries dd
|
||||
where dd.project_id = $1
|
||||
order by dd.last_observed_at desc, dd.id`,
|
||||
[projectId],
|
||||
);
|
||||
const enrollments = await client.query(
|
||||
`select dei.id, dei.enrollment_key, dei.display_name,
|
||||
dei.model_profile_ref, dei.expected_identifier_kind,
|
||||
dei.expected_identifier_masked, dei.lifecycle_state,
|
||||
dei.observed_discovery_id, dei.claimed_device_id,
|
||||
dei.expires_at, dei.created_at, dei.updated_at
|
||||
from device_enrollment_intents dei
|
||||
where dei.project_id = $1
|
||||
order by dei.updated_at desc, dei.id`,
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const capabilities = new Set(access.capabilities);
|
||||
const mayManageRoutes = capabilities.has("route.manage");
|
||||
const catalogParameters = [projectId, mayManageRoutes];
|
||||
const adapterPackages = await client.query(
|
||||
`select ap.id, ap.package_key, ap.display_name, ap.publisher_ref,
|
||||
ap.lifecycle_state, ap.created_at, ap.updated_at
|
||||
from device_adapter_packages ap
|
||||
where $2::boolean
|
||||
or exists (
|
||||
select 1
|
||||
from device_adapter_versions av
|
||||
join device_model_profiles dmp on dmp.adapter_version_id = av.id
|
||||
where av.adapter_package_id = ap.id
|
||||
and (
|
||||
exists (
|
||||
select 1 from device_routes dr
|
||||
where dr.project_id = $1 and dr.model_profile_ref = dmp.profile_ref
|
||||
)
|
||||
or exists (
|
||||
select 1 from device_instances di
|
||||
where di.project_id = $1 and di.model_profile_ref = dmp.profile_ref
|
||||
)
|
||||
)
|
||||
)
|
||||
order by ap.display_name, ap.id`,
|
||||
catalogParameters,
|
||||
);
|
||||
const adapterVersions = await client.query(
|
||||
`select av.id, av.adapter_package_id, av.version,
|
||||
av.runtime_package_ref, av.content_digest, av.contract_version,
|
||||
av.capabilities, av.lifecycle_state, av.created_at, av.updated_at
|
||||
from device_adapter_versions av
|
||||
where $2::boolean
|
||||
or exists (
|
||||
select 1 from device_model_profiles dmp
|
||||
where dmp.adapter_version_id = av.id
|
||||
and (
|
||||
exists (
|
||||
select 1 from device_routes dr
|
||||
where dr.project_id = $1 and dr.model_profile_ref = dmp.profile_ref
|
||||
)
|
||||
or exists (
|
||||
select 1 from device_instances di
|
||||
where di.project_id = $1 and di.model_profile_ref = dmp.profile_ref
|
||||
)
|
||||
)
|
||||
)
|
||||
order by av.adapter_package_id, av.created_at, av.id`,
|
||||
catalogParameters,
|
||||
);
|
||||
const modelProfiles = await client.query(
|
||||
`select dmp.profile_ref, dmp.adapter_version_id, dmp.schema_version,
|
||||
dmp.vendor, dmp.model, dmp.device_type, dmp.protocol,
|
||||
dmp.schema_artifact_ref, dmp.profile_digest, dmp.capabilities,
|
||||
dmp.lifecycle_state, dmp.created_at, dmp.updated_at
|
||||
from device_model_profiles dmp
|
||||
where $2::boolean
|
||||
or exists (
|
||||
select 1 from device_routes dr
|
||||
where dr.project_id = $1 and dr.model_profile_ref = dmp.profile_ref
|
||||
)
|
||||
or exists (
|
||||
select 1 from device_instances di
|
||||
where di.project_id = $1 and di.model_profile_ref = dmp.profile_ref
|
||||
)
|
||||
order by dmp.vendor, dmp.model, dmp.profile_ref`,
|
||||
catalogParameters,
|
||||
);
|
||||
const edges = await client.query(
|
||||
`select de.id, de.edge_key, de.display_name, de.deployment_ref,
|
||||
de.lifecycle_state, de.created_at, de.updated_at
|
||||
from device_edges de
|
||||
where $2::boolean
|
||||
or exists (
|
||||
select 1 from device_routes dr
|
||||
where dr.project_id = $1 and dr.edge_id = de.id
|
||||
)
|
||||
order by de.display_name, de.id`,
|
||||
catalogParameters,
|
||||
);
|
||||
const routes = await client.query(
|
||||
`select dr.id, dr.route_key, dr.display_name, dr.edge_id,
|
||||
de.display_name as edge_name, dr.model_profile_ref,
|
||||
dmp.vendor as profile_vendor, dmp.model as profile_model,
|
||||
dr.listener_ref, dr.protocol, dr.direction, dr.lifecycle_state,
|
||||
dr.created_at, dr.updated_at,
|
||||
count(ds.id)::bigint as session_count,
|
||||
(count(ds.id) filter (
|
||||
where ds.lifecycle_state in ('connecting', 'online')
|
||||
))::bigint as active_session_count
|
||||
from device_routes dr
|
||||
join device_edges de on de.id = dr.edge_id
|
||||
join device_model_profiles dmp on dmp.profile_ref = dr.model_profile_ref
|
||||
left join device_sessions ds on ds.route_id = dr.id
|
||||
where dr.project_id = $1
|
||||
group by dr.id, de.display_name, dmp.vendor, dmp.model
|
||||
order by dr.display_name, dr.id`,
|
||||
[projectId],
|
||||
);
|
||||
const sessions = capabilities.has("telemetry.observe")
|
||||
? await client.query(
|
||||
`select ds.id, ds.route_id, dr.display_name as route_name,
|
||||
ds.device_id, di.display_name as device_name,
|
||||
ds.protocol, ds.lifecycle_state, ds.connected_at,
|
||||
ds.last_seen_at, ds.disconnected_at, ds.close_reason_code,
|
||||
ds.frame_count, ds.byte_count
|
||||
from device_sessions ds
|
||||
join device_routes dr on dr.id = ds.route_id
|
||||
left join device_instances di on di.id = ds.device_id
|
||||
where ds.project_id = $1
|
||||
order by ds.last_seen_at desc, ds.id
|
||||
limit 200`,
|
||||
[projectId],
|
||||
)
|
||||
: { rows: [] };
|
||||
const bindings = capabilities.has("binding.manage")
|
||||
? await client.query(
|
||||
`select drb.id, drb.binding_key, drb.display_name,
|
||||
drb.source_kind, drb.device_id, drb.collection_id,
|
||||
coalesce(di.display_name, dc.name) as source_name,
|
||||
drb.target_kind, drb.target_ref, drb.capabilities,
|
||||
drb.lifecycle_state, drb.source_approved_at,
|
||||
drb.created_at, drb.updated_at
|
||||
from device_resource_bindings drb
|
||||
left join device_instances di on di.id = drb.device_id
|
||||
left join device_collections dc on dc.id = drb.collection_id
|
||||
where drb.project_id = $1
|
||||
order by drb.updated_at desc, drb.id`,
|
||||
[projectId],
|
||||
)
|
||||
: { rows: [] };
|
||||
const configurationRevisions = capabilities.has("configuration.read")
|
||||
? await client.query(
|
||||
`select dcr.id, dcr.device_id, di.display_name as device_name,
|
||||
dcr.revision_number, dcr.model_profile_ref,
|
||||
dcr.schema_artifact_ref, dcr.configuration_digest,
|
||||
dcr.change_summary, dcr.created_at
|
||||
from device_configuration_revisions dcr
|
||||
join device_instances di on di.id = dcr.device_id
|
||||
where dcr.project_id = $1
|
||||
order by dcr.created_at desc, dcr.id
|
||||
limit 200`,
|
||||
[projectId],
|
||||
)
|
||||
: { rows: [] };
|
||||
const configurationStates = capabilities.has("configuration.read")
|
||||
? await client.query(
|
||||
`select dcs.device_id, di.display_name as device_name,
|
||||
dcs.desired_revision_id, dcs.applied_revision_id,
|
||||
dcs.applied_at, dcs.updated_at
|
||||
from device_configuration_state dcs
|
||||
join device_instances di on di.id = dcs.device_id
|
||||
where dcs.project_id = $1
|
||||
order by di.display_name, dcs.device_id`,
|
||||
[projectId],
|
||||
)
|
||||
: { rows: [] };
|
||||
const mayReadCommands = ["command.plan", "command.confirm", "command.dispatch"]
|
||||
.some((capability) => capabilities.has(capability));
|
||||
const commands = mayReadCommands
|
||||
? await client.query(
|
||||
`select dc.id, dc.device_id, di.display_name as device_name,
|
||||
dc.command_key, dc.command_catalog_ref, dc.command_type,
|
||||
dc.risk_class, dc.lifecycle_state, dc.planned_at, dc.expires_at,
|
||||
dc.confirmed_at, dc.dispatched_at, dc.acknowledged_at,
|
||||
dc.terminal_at, dc.terminal_reason_code,
|
||||
dc.created_at, dc.updated_at
|
||||
from device_commands dc
|
||||
join device_instances di on di.id = dc.device_id
|
||||
where dc.project_id = $1
|
||||
order by dc.updated_at desc, dc.id
|
||||
limit 200`,
|
||||
[projectId],
|
||||
)
|
||||
: { rows: [] };
|
||||
const auditEvents = capabilities.has("audit.read")
|
||||
? await client.query(
|
||||
`select dae.id, dae.event_type, dae.actor_ref,
|
||||
dae.device_id, dae.discovery_id, dae.occurred_at
|
||||
from device_audit_events dae
|
||||
where dae.project_id = $1
|
||||
order by dae.occurred_at desc, dae.id
|
||||
limit 300`,
|
||||
[projectId],
|
||||
)
|
||||
: { rows: [] };
|
||||
const projectGrants = capabilities.has("access.manage")
|
||||
? grantsResult
|
||||
: { rows: [] };
|
||||
|
||||
return {
|
||||
project: projectSummaryView(project, access),
|
||||
devices: devices.rows.map(deviceView),
|
||||
collections: collections.rows.map(collectionView),
|
||||
discoveries: discoveries.rows.map(discoveryView),
|
||||
enrollments: enrollments.rows.map(enrollmentView),
|
||||
adapterPackages: adapterPackages.rows.map(adapterPackageView),
|
||||
adapterVersions: adapterVersions.rows.map(adapterVersionView),
|
||||
modelProfiles: modelProfiles.rows.map(modelProfileView),
|
||||
edges: edges.rows.map(edgeView),
|
||||
routes: routes.rows.map(routeView),
|
||||
sessions: sessions.rows.map(sessionView),
|
||||
bindings: bindings.rows.map(bindingView),
|
||||
configurationRevisions: configurationRevisions.rows.map(
|
||||
configurationRevisionView,
|
||||
),
|
||||
configurationStates: configurationStates.rows.map(configurationStateView),
|
||||
commands: commands.rows.map(commandView),
|
||||
auditEvents: auditEvents.rows.map(auditEventView),
|
||||
grants: projectGrants.rows.map(storedGrantView),
|
||||
policies: {
|
||||
commandTransport,
|
||||
commandPlanningApi: commandTransport === "disabled" ? "disabled" : "enabled",
|
||||
identifierProjection: "masked-only",
|
||||
auditPayloadProjection: "metadata-only",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function projectSummaryView(row, access) {
|
||||
return {
|
||||
projectRef: toProjectRef(row.id),
|
||||
projectKey: row.project_key,
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
ownerScope: {
|
||||
ownerScopeRef: `owner-scope:${row.owner_scope_id}`,
|
||||
scopeKind: row.scope_kind,
|
||||
ownerRef: row.owner_ref,
|
||||
displayName: row.owner_display_name,
|
||||
},
|
||||
access: {
|
||||
projectRole: access.projectRole,
|
||||
capabilities: access.capabilities,
|
||||
},
|
||||
counts: {
|
||||
devices: numericCount(row.device_count),
|
||||
collections: numericCount(row.collection_count),
|
||||
discoveries: numericCount(row.discovery_count),
|
||||
},
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function deviceView(row) {
|
||||
return {
|
||||
deviceRef: `device:${row.id}`,
|
||||
deviceKey: row.device_key,
|
||||
displayName: row.display_name,
|
||||
integrationDeviceId: row.integration_device_id ?? null,
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
identifier: row.identifier_masked
|
||||
? { kind: row.identifier_kind, masked: row.identifier_masked }
|
||||
: null,
|
||||
session: row.session_state
|
||||
? { state: row.session_state, lastSeenAt: toIso(row.last_seen_at) }
|
||||
: null,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function collectionView(row) {
|
||||
return {
|
||||
collectionRef: `collection:${row.id}`,
|
||||
collectionKey: row.collection_key,
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
memberCount: numericCount(row.member_count),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function discoveryView(row) {
|
||||
return {
|
||||
discoveryRef: `discovery:${row.id}`,
|
||||
identifier: { kind: row.identifier_kind, masked: row.identifier_masked },
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
protocol: row.protocol,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
enrollmentIntentRef: row.enrollment_intent_id
|
||||
? `enrollment-intent:${row.enrollment_intent_id}`
|
||||
: null,
|
||||
claimedDeviceRef: row.claimed_device_id ? `device:${row.claimed_device_id}` : null,
|
||||
firstObservedAt: toIso(row.first_observed_at),
|
||||
lastObservedAt: toIso(row.last_observed_at),
|
||||
};
|
||||
}
|
||||
|
||||
function enrollmentView(row) {
|
||||
return {
|
||||
enrollmentIntentRef: `enrollment-intent:${row.id}`,
|
||||
enrollmentKey: row.enrollment_key,
|
||||
displayName: row.display_name,
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
expectedIdentifier: {
|
||||
kind: row.expected_identifier_kind,
|
||||
masked: row.expected_identifier_masked,
|
||||
},
|
||||
lifecycleState: row.lifecycle_state,
|
||||
observedDiscoveryRef: row.observed_discovery_id
|
||||
? `discovery:${row.observed_discovery_id}`
|
||||
: null,
|
||||
claimedDeviceRef: row.claimed_device_id ? `device:${row.claimed_device_id}` : null,
|
||||
expiresAt: toIso(row.expires_at),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function adapterPackageView(row) {
|
||||
return {
|
||||
adapterPackageRef: `adapter-package:${row.id}`,
|
||||
packageKey: row.package_key,
|
||||
displayName: row.display_name,
|
||||
publisherRef: row.publisher_ref,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function adapterVersionView(row) {
|
||||
return {
|
||||
adapterVersionRef: `adapter-version:${row.id}`,
|
||||
adapterPackageRef: `adapter-package:${row.adapter_package_id}`,
|
||||
version: row.version,
|
||||
runtimePackageRef: row.runtime_package_ref,
|
||||
contentDigest: row.content_digest,
|
||||
contractVersion: row.contract_version,
|
||||
capabilities: [...(row.capabilities ?? [])].sort(),
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function modelProfileView(row) {
|
||||
return {
|
||||
modelProfileRef: row.profile_ref,
|
||||
adapterVersionRef: row.adapter_version_id
|
||||
? `adapter-version:${row.adapter_version_id}`
|
||||
: null,
|
||||
schemaVersion: row.schema_version,
|
||||
vendor: row.vendor,
|
||||
model: row.model,
|
||||
deviceType: row.device_type,
|
||||
protocol: row.protocol,
|
||||
schemaArtifactRef: row.schema_artifact_ref ?? null,
|
||||
profileDigest: row.profile_digest ?? null,
|
||||
capabilities: [...(row.capabilities ?? [])].sort(),
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function edgeView(row) {
|
||||
return {
|
||||
edgeRef: `edge:${row.id}`,
|
||||
edgeKey: row.edge_key,
|
||||
displayName: row.display_name,
|
||||
deploymentRef: row.deployment_ref ?? null,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function routeView(row) {
|
||||
return {
|
||||
routeRef: `route:${row.id}`,
|
||||
routeKey: row.route_key,
|
||||
displayName: row.display_name,
|
||||
edgeRef: `edge:${row.edge_id}`,
|
||||
edgeName: row.edge_name,
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
profileName: `${row.profile_vendor} ${row.profile_model}`.trim(),
|
||||
listenerRef: row.listener_ref,
|
||||
protocol: row.protocol,
|
||||
direction: row.direction,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
sessionCount: numericCount(row.session_count),
|
||||
activeSessionCount: numericCount(row.active_session_count),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function sessionView(row) {
|
||||
return {
|
||||
sessionRef: `session:${row.id}`,
|
||||
routeRef: `route:${row.route_id}`,
|
||||
routeName: row.route_name,
|
||||
deviceRef: row.device_id ? `device:${row.device_id}` : null,
|
||||
deviceName: row.device_name ?? null,
|
||||
protocol: row.protocol,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
connectedAt: toIso(row.connected_at),
|
||||
lastSeenAt: toIso(row.last_seen_at),
|
||||
disconnectedAt: toIso(row.disconnected_at),
|
||||
closeReasonCode: row.close_reason_code ?? null,
|
||||
frameCount: numericCount(row.frame_count),
|
||||
byteCount: numericCount(row.byte_count),
|
||||
};
|
||||
}
|
||||
|
||||
function bindingView(row) {
|
||||
return {
|
||||
bindingRef: `binding:${row.id}`,
|
||||
bindingKey: row.binding_key,
|
||||
displayName: row.display_name,
|
||||
source: {
|
||||
kind: row.source_kind,
|
||||
ref: row.source_kind === "device"
|
||||
? `device:${row.device_id}`
|
||||
: `collection:${row.collection_id}`,
|
||||
displayName: row.source_name,
|
||||
},
|
||||
target: { kind: row.target_kind, ref: row.target_ref },
|
||||
capabilities: [...(row.capabilities ?? [])].sort(),
|
||||
lifecycleState: row.lifecycle_state,
|
||||
sourceApprovedAt: toIso(row.source_approved_at),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function configurationRevisionView(row) {
|
||||
return {
|
||||
configurationRevisionRef: `configuration-revision:${row.id}`,
|
||||
deviceRef: `device:${row.device_id}`,
|
||||
deviceName: row.device_name,
|
||||
revisionNumber: numericCount(row.revision_number),
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
schemaArtifactRef: row.schema_artifact_ref,
|
||||
configurationDigest: row.configuration_digest,
|
||||
changeSummary: row.change_summary ?? null,
|
||||
createdAt: toIso(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
function configurationStateView(row) {
|
||||
return {
|
||||
deviceRef: `device:${row.device_id}`,
|
||||
deviceName: row.device_name,
|
||||
desiredConfigurationRevisionRef: row.desired_revision_id
|
||||
? `configuration-revision:${row.desired_revision_id}`
|
||||
: null,
|
||||
appliedConfigurationRevisionRef: row.applied_revision_id
|
||||
? `configuration-revision:${row.applied_revision_id}`
|
||||
: null,
|
||||
appliedAt: toIso(row.applied_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function commandView(row) {
|
||||
return {
|
||||
commandRef: `command:${row.id}`,
|
||||
deviceRef: `device:${row.device_id}`,
|
||||
deviceName: row.device_name,
|
||||
commandKey: row.command_key,
|
||||
commandCatalogRef: row.command_catalog_ref,
|
||||
commandType: row.command_type,
|
||||
riskClass: row.risk_class,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
plannedAt: toIso(row.planned_at),
|
||||
expiresAt: toIso(row.expires_at),
|
||||
confirmedAt: toIso(row.confirmed_at),
|
||||
dispatchedAt: toIso(row.dispatched_at),
|
||||
acknowledgedAt: toIso(row.acknowledged_at),
|
||||
terminalAt: toIso(row.terminal_at),
|
||||
terminalReasonCode: row.terminal_reason_code ?? null,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function auditEventView(row) {
|
||||
return {
|
||||
auditEventRef: `audit-event:${row.id}`,
|
||||
eventType: row.event_type,
|
||||
actorRef: row.actor_ref,
|
||||
deviceRef: row.device_id ? `device:${row.device_id}` : null,
|
||||
discoveryRef: row.discovery_id ? `discovery:${row.discovery_id}` : null,
|
||||
occurredAt: toIso(row.occurred_at),
|
||||
};
|
||||
}
|
||||
|
||||
function grantView(row) {
|
||||
return storedGrantView({
|
||||
id: row.grant_id,
|
||||
principal_kind: row.principal_kind,
|
||||
principal_ref: row.principal_ref,
|
||||
project_role: row.project_role,
|
||||
capability_allow: row.capability_allow,
|
||||
capability_deny: row.capability_deny,
|
||||
lifecycle_state: row.grant_lifecycle_state,
|
||||
});
|
||||
}
|
||||
|
||||
function storedGrantView(row) {
|
||||
return {
|
||||
grantRef: `grant:${row.id}`,
|
||||
principalKind: row.principal_kind,
|
||||
principalRef: row.principal_ref,
|
||||
projectRole: row.project_role,
|
||||
capabilityAllow: row.capability_allow ?? [],
|
||||
capabilityDeny: row.capability_deny ?? [],
|
||||
lifecycleState: row.lifecycle_state,
|
||||
};
|
||||
}
|
||||
|
||||
function numericCount(value) {
|
||||
const count = Number(value ?? 0);
|
||||
return Number.isSafeInteger(count) && count >= 0 ? count : 0;
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
return value == null ? null : new Date(value).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
normalizeNdcCredentialReference,
|
||||
} from "./credential-reference.mjs";
|
||||
|
||||
export const DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS = Object.freeze([
|
||||
"device_credential_binding.upsert",
|
||||
"device_credential_binding.revoke",
|
||||
]);
|
||||
|
||||
const commandKindSet = new Set(DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS);
|
||||
const purposePattern = /^[a-z][a-z0-9._-]{1,63}$/;
|
||||
const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/;
|
||||
|
||||
export function isSensitiveReferenceManagementCommand(kind) {
|
||||
return commandKindSet.has(kind);
|
||||
}
|
||||
|
||||
export function normalizeSensitiveReferenceManagementCommand(kind, input) {
|
||||
if (!commandKindSet.has(kind)) {
|
||||
throw new TypeError("device_sensitive_reference_command_kind_invalid");
|
||||
}
|
||||
assertPlainObject(input);
|
||||
|
||||
if (kind === "device_credential_binding.upsert") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"deviceRef",
|
||||
"purpose",
|
||||
"credentialRef",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
purpose: normalizePattern(
|
||||
input.purpose,
|
||||
purposePattern,
|
||||
"device_credential_purpose_invalid",
|
||||
),
|
||||
credentialRef: normalizeNdcCredentialReference(input.credentialRef),
|
||||
});
|
||||
}
|
||||
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"deviceRef",
|
||||
"purpose",
|
||||
"resolutionCode",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
purpose: normalizePattern(
|
||||
input.purpose,
|
||||
purposePattern,
|
||||
"device_credential_purpose_invalid",
|
||||
),
|
||||
resolutionCode: normalizePattern(
|
||||
input.resolutionCode,
|
||||
resolutionPattern,
|
||||
"device_credential_resolution_code_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEntityRef(value, prefix) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
}
|
||||
const match = 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 new TypeError(`device_${prefix}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizePattern(value, pattern, code) {
|
||||
if (typeof value !== "string" || !pattern.test(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPlainObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_sensitive_reference_command_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedKeys(input, allowed) {
|
||||
const allowedSet = new Set(allowed);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedSet.has(key)) {
|
||||
throw new TypeError(`device_management_command_field_unexpected:${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
findProjectWithCapability,
|
||||
} from "./lifecycle-repository.mjs";
|
||||
import {
|
||||
isSensitiveReferenceManagementCommand,
|
||||
} from "./sensitive-reference-management.mjs";
|
||||
import { toProjectRef } from "./project-management.mjs";
|
||||
|
||||
export async function applySensitiveReferenceManagementCommand(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isSensitiveReferenceManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_sensitive_reference_command_kind_invalid");
|
||||
}
|
||||
if (commandKind === "device_credential_binding.upsert") {
|
||||
return upsertCredentialBinding(client, actor, command);
|
||||
}
|
||||
return revokeCredentialBinding(client, actor, command);
|
||||
}
|
||||
|
||||
export async function authorizeSensitiveReferenceManagementReplay(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isSensitiveReferenceManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_sensitive_reference_command_kind_invalid");
|
||||
}
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"credential.manage",
|
||||
);
|
||||
const current = await client.query(
|
||||
`select project_id
|
||||
from device_instances
|
||||
where id = $1`,
|
||||
[command.deviceId],
|
||||
);
|
||||
const currentProjectId = current.rows[0]?.project_id;
|
||||
if (!currentProjectId) throw domainError("device_not_found", 404);
|
||||
if (currentProjectId !== command.projectId) {
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
currentProjectId,
|
||||
"credential.manage",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertCredentialBinding(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"credential.manage",
|
||||
);
|
||||
const device = await findDirectDeviceForUpdate(client, command);
|
||||
const currentResult = await client.query(
|
||||
`select id, device_id, owner_scope_id, project_id, purpose,
|
||||
credential_owner, credential_ref, lifecycle_state,
|
||||
created_at, updated_at
|
||||
from device_credential_bindings
|
||||
where device_id = $1
|
||||
and purpose = $2
|
||||
and lifecycle_state = 'active'
|
||||
for update`,
|
||||
[device.id, command.purpose],
|
||||
);
|
||||
const current = currentResult.rows[0] ?? null;
|
||||
if (
|
||||
current
|
||||
&& current.credential_owner === command.credentialRef.owner
|
||||
&& current.credential_ref === command.credentialRef.reference
|
||||
) {
|
||||
return {
|
||||
created: false,
|
||||
rotated: false,
|
||||
credentialBinding: credentialBindingView(current),
|
||||
};
|
||||
}
|
||||
|
||||
if (current) {
|
||||
await client.query(
|
||||
`update device_credential_bindings
|
||||
set lifecycle_state = 'revoked',
|
||||
revoked_at = now(),
|
||||
revoked_by_ref = $2,
|
||||
revocation_code = 'credential_rotation',
|
||||
updated_at = now()
|
||||
where id = $1 and lifecycle_state = 'active'`,
|
||||
[current.id, actor.userRef],
|
||||
);
|
||||
}
|
||||
|
||||
const inserted = await client.query(
|
||||
`insert into device_credential_bindings (
|
||||
id,
|
||||
device_id,
|
||||
owner_scope_id,
|
||||
project_id,
|
||||
purpose,
|
||||
credential_owner,
|
||||
credential_ref,
|
||||
bound_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
returning id, device_id, owner_scope_id, project_id, purpose,
|
||||
credential_owner, lifecycle_state, created_at, updated_at`,
|
||||
[
|
||||
randomUUID(),
|
||||
device.id,
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
command.purpose,
|
||||
command.credentialRef.owner,
|
||||
command.credentialRef.reference,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const binding = inserted.rows[0];
|
||||
if (!binding) throw domainError("device_credential_binding_insert_failed", 409);
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: current
|
||||
? "device_credential_binding.rotated"
|
||||
: "device_credential_binding.created",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: device.id,
|
||||
payload: {
|
||||
deviceRef: `device:${device.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
credentialBindingRef: `credential-binding:${binding.id}`,
|
||||
...(current
|
||||
? { rotatedCredentialBindingRef: `credential-binding:${current.id}` }
|
||||
: {}),
|
||||
purpose: binding.purpose,
|
||||
credentialOwner: binding.credential_owner,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: true,
|
||||
rotated: Boolean(current),
|
||||
credentialBinding: credentialBindingView(binding),
|
||||
};
|
||||
}
|
||||
|
||||
async function revokeCredentialBinding(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"credential.manage",
|
||||
);
|
||||
const device = await findDirectDeviceForUpdate(client, command);
|
||||
const revoked = await client.query(
|
||||
`update device_credential_bindings
|
||||
set lifecycle_state = 'revoked',
|
||||
revoked_at = now(),
|
||||
revoked_by_ref = $4,
|
||||
revocation_code = $3,
|
||||
updated_at = now()
|
||||
where device_id = $1
|
||||
and purpose = $2
|
||||
and lifecycle_state = 'active'
|
||||
returning id, device_id, owner_scope_id, project_id, purpose,
|
||||
credential_owner, lifecycle_state, created_at, updated_at`,
|
||||
[device.id, command.purpose, command.resolutionCode, actor.userRef],
|
||||
);
|
||||
const binding = revoked.rows[0];
|
||||
if (!binding) throw domainError("device_credential_binding_not_found", 404);
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: "device_credential_binding.revoked",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: device.id,
|
||||
payload: {
|
||||
deviceRef: `device:${device.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
credentialBindingRef: `credential-binding:${binding.id}`,
|
||||
purpose: binding.purpose,
|
||||
credentialOwner: binding.credential_owner,
|
||||
resolutionCode: command.resolutionCode,
|
||||
},
|
||||
});
|
||||
return {
|
||||
revoked: true,
|
||||
credentialBinding: credentialBindingView(binding),
|
||||
resolutionCode: command.resolutionCode,
|
||||
};
|
||||
}
|
||||
|
||||
async function findDirectDeviceForUpdate(client, command) {
|
||||
const result = await client.query(
|
||||
`select id, owner_scope_id, project_id, lifecycle_state
|
||||
from device_instances
|
||||
where id = $1
|
||||
for update`,
|
||||
[command.deviceId],
|
||||
);
|
||||
const device = result.rows[0];
|
||||
if (!device) throw domainError("device_not_found", 404);
|
||||
if (
|
||||
!device.owner_scope_id
|
||||
|| !device.project_id
|
||||
|| device.project_id !== command.projectId
|
||||
) {
|
||||
throw domainError("device_credential_binding_project_mismatch", 409);
|
||||
}
|
||||
if (device.lifecycle_state === "retired") {
|
||||
throw domainError("device_credential_binding_lifecycle_blocked", 409);
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
async function addAudit(client, {
|
||||
eventType,
|
||||
actorRef,
|
||||
projectId,
|
||||
deviceId,
|
||||
payload,
|
||||
}) {
|
||||
await client.query(
|
||||
`insert into device_audit_events (
|
||||
id,
|
||||
event_type,
|
||||
actor_ref,
|
||||
project_id,
|
||||
device_id,
|
||||
payload
|
||||
) values ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||||
[
|
||||
randomUUID(),
|
||||
eventType,
|
||||
actorRef,
|
||||
projectId,
|
||||
deviceId,
|
||||
JSON.stringify(payload),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function credentialBindingView(row) {
|
||||
return {
|
||||
credentialBindingRef: `credential-binding:${row.id}`,
|
||||
deviceRef: `device:${row.device_id}`,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
purpose: row.purpose,
|
||||
credentialOwner: row.credential_owner,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
timingSafeEqual,
|
||||
X509Certificate,
|
||||
} from "node:crypto";
|
||||
import { lstat, readFile } from "node:fs/promises";
|
||||
|
||||
import { createControlCoreApp } from "./app.mjs";
|
||||
import { resolveDeviceDatabaseUrl } from "./database-config.mjs";
|
||||
import {
|
||||
createDeviceEdgeChannelSupervisor,
|
||||
} from "./edge-channel-supervisor.mjs";
|
||||
import { createDeviceGatewayIngest } from "./gateway-ingest.mjs";
|
||||
import { PostgresDeviceRepository } from "./postgres-repository.mjs";
|
||||
import { createTypedCommandRuntime } from "./typed-command-runtime.mjs";
|
||||
|
||||
const config = await readConfig();
|
||||
const repository = new PostgresDeviceRepository({
|
||||
databaseUrl: config.databaseUrl,
|
||||
poolSize: config.databasePoolSize,
|
||||
});
|
||||
|
||||
await repository.migrate();
|
||||
const typedCommandRuntime = config.managementApiEnabled && config.edgeChannelEnabled
|
||||
? createTypedCommandRuntime({ repository })
|
||||
: null;
|
||||
|
||||
const gatewayIngest = config.discoveryIngestEnabled || config.edgeChannelEnabled
|
||||
? createDeviceGatewayIngest({
|
||||
repository,
|
||||
identifierPepper: config.identifierPepper,
|
||||
})
|
||||
: null;
|
||||
const edgeChannelSupervisor = config.edgeChannelEnabled
|
||||
? createDeviceEdgeChannelSupervisor({
|
||||
repository,
|
||||
gatewayIngest,
|
||||
coreIdentity: config.edgeChannelCoreIdentity,
|
||||
trustRoot: config.edgeChannelTrustRoot,
|
||||
maxEdges: config.edgeChannelMaxEdges,
|
||||
reconcileIntervalMs: config.edgeChannelReconcileIntervalMs,
|
||||
typedCommandRuntime,
|
||||
})
|
||||
: null;
|
||||
await edgeChannelSupervisor?.start();
|
||||
|
||||
const server = createControlCoreApp({
|
||||
repository,
|
||||
gatewayToken: config.gatewayToken,
|
||||
identifierPepper: config.identifierPepper,
|
||||
discoveryIngestEnabled: config.discoveryIngestEnabled,
|
||||
managementApiEnabled: config.managementApiEnabled,
|
||||
managementToken: config.managementToken,
|
||||
gatewayIngest,
|
||||
edgeChannelStatusProvider: edgeChannelSupervisor
|
||||
? () => edgeChannelSupervisor.status()
|
||||
: null,
|
||||
typedCommandRuntime,
|
||||
});
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
console.log(JSON.stringify({
|
||||
event: "device_control_core_started",
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
discoveryIngest: config.discoveryIngestEnabled,
|
||||
managementApi: config.managementApiEnabled,
|
||||
edgeChannels: config.edgeChannelEnabled,
|
||||
commandTransport: typedCommandRuntime ? "typed-service-ping-v1" : "disabled",
|
||||
}));
|
||||
});
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
async function shutdown() {
|
||||
server.close(async () => {
|
||||
await edgeChannelSupervisor?.stop();
|
||||
await repository.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function readConfig() {
|
||||
const discoveryIngestEnabled = parseBoolean(
|
||||
process.env.DEVICE_DISCOVERY_INGEST_ENABLED,
|
||||
false,
|
||||
);
|
||||
const managementApiEnabled = parseBoolean(
|
||||
process.env.DEVICE_MANAGEMENT_API_ENABLED,
|
||||
false,
|
||||
);
|
||||
const edgeChannelEnabled = parseBoolean(
|
||||
process.env.DEVICE_EDGE_CHANNEL_ENABLED,
|
||||
false,
|
||||
);
|
||||
const edgeChannelCoreIdentity = edgeChannelEnabled
|
||||
? await readCoreIdentity(process.env)
|
||||
: null;
|
||||
return {
|
||||
host: String(process.env.HOST || "127.0.0.1").trim(),
|
||||
port: parsePort(process.env.PORT, 18120),
|
||||
databaseUrl: await resolveDeviceDatabaseUrl(process.env),
|
||||
databasePoolSize: parsePositiveInt(
|
||||
process.env.DEVICE_DATABASE_POOL_SIZE,
|
||||
10,
|
||||
),
|
||||
discoveryIngestEnabled,
|
||||
managementApiEnabled,
|
||||
edgeChannelEnabled,
|
||||
gatewayToken: discoveryIngestEnabled
|
||||
? await readRequiredSecretFile(
|
||||
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"device_gateway_core_token_file_required",
|
||||
)
|
||||
: "",
|
||||
identifierPepper:
|
||||
discoveryIngestEnabled || managementApiEnabled || edgeChannelEnabled
|
||||
? await readRequiredSecretFile(
|
||||
process.env.DEVICE_IDENTIFIER_PEPPER_FILE,
|
||||
"device_identifier_pepper_file_required",
|
||||
)
|
||||
: "",
|
||||
managementToken: managementApiEnabled
|
||||
? await readRequiredSecretFile(
|
||||
process.env.DEVICE_MANAGEMENT_CORE_TOKEN_FILE,
|
||||
"device_management_core_token_file_required",
|
||||
)
|
||||
: "",
|
||||
edgeChannelCoreIdentity,
|
||||
edgeChannelTrustRoot: edgeChannelEnabled
|
||||
? await readRequiredDirectory(
|
||||
process.env.DEVICE_EDGE_CHANNEL_TRUST_ROOT,
|
||||
"device_edge_channel_trust_root_required",
|
||||
)
|
||||
: "",
|
||||
edgeChannelMaxEdges: parseBoundedInt(
|
||||
process.env.DEVICE_EDGE_CHANNEL_MAX_EDGES,
|
||||
32,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
edgeChannelReconcileIntervalMs: parseBoundedInt(
|
||||
process.env.DEVICE_EDGE_CHANNEL_RECONCILE_INTERVAL_MS,
|
||||
15_000,
|
||||
1_000,
|
||||
300_000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function readCoreIdentity(environment) {
|
||||
const keyPath = requiredValue(
|
||||
environment.DEVICE_EDGE_CHANNEL_CORE_KEY_FILE,
|
||||
"device_edge_channel_core_key_file_required",
|
||||
);
|
||||
const certificatePath = requiredValue(
|
||||
environment.DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE,
|
||||
"device_edge_channel_core_certificate_file_required",
|
||||
);
|
||||
const [key, cert] = await Promise.all([
|
||||
readBoundedRegularFile(keyPath, 32 * 1024),
|
||||
readBoundedRegularFile(certificatePath, 32 * 1024),
|
||||
]);
|
||||
const privatePublic = createPublicKey(createPrivateKey(key)).export({
|
||||
type: "spki",
|
||||
format: "der",
|
||||
});
|
||||
const certificatePublic = new X509Certificate(cert).publicKey.export({
|
||||
type: "spki",
|
||||
format: "der",
|
||||
});
|
||||
if (
|
||||
privatePublic.length !== certificatePublic.length
|
||||
|| !timingSafeEqual(privatePublic, certificatePublic)
|
||||
) {
|
||||
throw new Error("device_edge_channel_core_identity_mismatch");
|
||||
}
|
||||
return Object.freeze({
|
||||
identityRef: "workload:device-control-core",
|
||||
key,
|
||||
cert,
|
||||
});
|
||||
}
|
||||
|
||||
async function readBoundedRegularFile(path, maximumBytes) {
|
||||
const state = await lstat(path);
|
||||
if (
|
||||
state.isSymbolicLink()
|
||||
|| !state.isFile()
|
||||
|| state.size < 1
|
||||
|| state.size > maximumBytes
|
||||
) {
|
||||
throw new Error("device_edge_channel_core_identity_file_invalid");
|
||||
}
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
async function readRequiredDirectory(path, errorCode) {
|
||||
const normalized = requiredValue(path, errorCode);
|
||||
const state = await lstat(normalized);
|
||||
if (state.isSymbolicLink() || !state.isDirectory()) throw new Error(errorCode);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function readRequiredSecretFile(path, errorCode) {
|
||||
const normalized = requiredValue(path, errorCode);
|
||||
const value = (await readFile(normalized, "utf8")).trim();
|
||||
if (value.length < 32) throw new Error(errorCode);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredValue(value, errorCode) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new Error(errorCode);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error("device_control_port_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error("device_positive_integer_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBoundedInt(value, fallback, minimum, maximum) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (
|
||||
!Number.isSafeInteger(parsed)
|
||||
|| parsed < minimum
|
||||
|| parsed > maximum
|
||||
) {
|
||||
throw new Error("device_bounded_integer_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||
throw new Error("device_boolean_invalid");
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { assertProjectCapability } from "./project-management.mjs";
|
||||
|
||||
const SERVICE_PING_CATALOG = "arusnavi.b2.internal.v1:service-ping";
|
||||
|
||||
export async function planTypedServicePing(client, {
|
||||
idempotencyKey,
|
||||
requestDigest,
|
||||
actor,
|
||||
projectId,
|
||||
deviceId,
|
||||
expiresAt,
|
||||
}) {
|
||||
const commandKey = `service-ping-${createHash("sha256")
|
||||
.update(`${actor.userRef}\0${idempotencyKey}`, "utf8")
|
||||
.digest("hex").slice(0, 32)}`;
|
||||
const project = await findProject(client, projectId);
|
||||
const grants = await projectGrants(client, projectId);
|
||||
assertProjectCapability(actor, grants, "command.plan");
|
||||
assertProjectCapability(actor, grants, "command.dispatch");
|
||||
const device = await findCommandableDevice(client, projectId, deviceId);
|
||||
|
||||
const existing = await client.query(
|
||||
`select dc.*, di.display_name as device_name
|
||||
from device_commands dc
|
||||
join device_instances di on di.id = dc.device_id
|
||||
where dc.project_id = $1 and dc.command_key = $2
|
||||
for update of dc`,
|
||||
[projectId, commandKey],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
const row = existing.rows[0];
|
||||
if (
|
||||
row.device_id !== deviceId
|
||||
|| row.command_catalog_ref !== SERVICE_PING_CATALOG
|
||||
|| row.command_type !== "service.ping"
|
||||
|| row.parameters_digest !== requestDigest
|
||||
) {
|
||||
throw domainError("device_command_idempotency_conflict", 409);
|
||||
}
|
||||
return { replayed: true, commandId: row.id, command: commandView(row) };
|
||||
}
|
||||
|
||||
const commandId = randomUUID();
|
||||
const plannedAt = new Date();
|
||||
await client.query(
|
||||
`insert into device_commands (
|
||||
id, owner_scope_id, project_id, device_id, command_key,
|
||||
command_catalog_ref, command_type, risk_class,
|
||||
parameters_digest, parameters_projection, lifecycle_state,
|
||||
planned_by_ref, planned_at, expires_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, 'service.ping', 'low',
|
||||
$7, $8::jsonb, 'queued', $9, $10, $11
|
||||
)`,
|
||||
[
|
||||
commandId,
|
||||
project.owner_scope_id,
|
||||
projectId,
|
||||
deviceId,
|
||||
commandKey,
|
||||
SERVICE_PING_CATALOG,
|
||||
requestDigest,
|
||||
JSON.stringify({ operation: "service.ping", profileRef: device.model_profile_ref }),
|
||||
actor.userRef,
|
||||
plannedAt,
|
||||
expiresAt,
|
||||
],
|
||||
);
|
||||
await insertEvent(client, commandId, deviceId, projectId, 1, null, "draft", actor.userRef, "operator_requested");
|
||||
await insertEvent(client, commandId, deviceId, projectId, 2, "draft", "planned", actor.userRef, "typed_policy_approved");
|
||||
await insertEvent(client, commandId, deviceId, projectId, 3, "planned", "queued", actor.userRef, "awaiting_tracker_session");
|
||||
await audit(client, {
|
||||
eventType: "command.queued",
|
||||
actorRef: actor.userRef,
|
||||
projectId,
|
||||
deviceId,
|
||||
payload: { commandRef: `command:${commandId}`, commandType: "service.ping", riskClass: "low" },
|
||||
});
|
||||
return {
|
||||
replayed: false,
|
||||
commandId,
|
||||
command: commandView({
|
||||
id: commandId,
|
||||
device_id: deviceId,
|
||||
device_name: device.display_name,
|
||||
command_key: commandKey,
|
||||
command_catalog_ref: SERVICE_PING_CATALOG,
|
||||
command_type: "service.ping",
|
||||
risk_class: "low",
|
||||
lifecycle_state: "queued",
|
||||
planned_at: plannedAt,
|
||||
expires_at: expiresAt,
|
||||
created_at: plannedAt,
|
||||
updated_at: plannedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function dispatchTypedCommand(client, {
|
||||
commandId,
|
||||
transportMessageRef,
|
||||
now,
|
||||
}) {
|
||||
const result = await client.query(
|
||||
`select dc.*, di.display_name as device_name
|
||||
from device_commands dc
|
||||
join device_instances di on di.id = dc.device_id
|
||||
where dc.id = $1
|
||||
for update of dc`,
|
||||
[commandId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw domainError("device_command_not_found", 404);
|
||||
if (row.lifecycle_state !== "queued") return null;
|
||||
if (new Date(row.expires_at).getTime() <= now.getTime()) {
|
||||
await client.query(
|
||||
`update device_commands set lifecycle_state = 'expired',
|
||||
terminal_at = $2, terminal_reason_code = 'ttl_elapsed', updated_at = $2
|
||||
where id = $1`,
|
||||
[commandId, now],
|
||||
);
|
||||
await insertEvent(client, commandId, row.device_id, row.project_id, 4, "queued", "expired", "workload:device-control-core", "ttl_elapsed");
|
||||
return null;
|
||||
}
|
||||
await client.query(
|
||||
`update device_commands set lifecycle_state = 'dispatched',
|
||||
dispatched_at = $2, transport_message_ref = $3, updated_at = $2
|
||||
where id = $1`,
|
||||
[commandId, now, transportMessageRef],
|
||||
);
|
||||
await insertEvent(client, commandId, row.device_id, row.project_id, 4, "queued", "dispatched", "workload:device-control-core", "tracker_session_allocated", transportMessageRef);
|
||||
await audit(client, {
|
||||
eventType: "command.dispatched",
|
||||
actorRef: "workload:device-control-core",
|
||||
projectId: row.project_id,
|
||||
deviceId: row.device_id,
|
||||
payload: { commandRef: `command:${commandId}`, commandType: row.command_type },
|
||||
});
|
||||
return commandView({ ...row, lifecycle_state: "dispatched", dispatched_at: now, transport_message_ref: transportMessageRef, updated_at: now });
|
||||
}
|
||||
|
||||
export async function recordTypedCommandStatus(client, {
|
||||
commandId,
|
||||
transportMessageRef,
|
||||
lifecycleState,
|
||||
resultCode,
|
||||
observedAt,
|
||||
}) {
|
||||
const result = await client.query(
|
||||
`select * from device_commands where id = $1 for update`,
|
||||
[commandId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw domainError("device_command_not_found", 404);
|
||||
if (row.transport_message_ref !== transportMessageRef) {
|
||||
throw domainError("device_command_transport_mismatch", 409);
|
||||
}
|
||||
if (["verified", "failed", "expired", "unknown"].includes(row.lifecycle_state)) {
|
||||
return;
|
||||
}
|
||||
if (lifecycleState === "acknowledged" && row.lifecycle_state === "dispatched") {
|
||||
await client.query(
|
||||
`update device_commands set lifecycle_state = 'acknowledged',
|
||||
acknowledged_at = $2, updated_at = $2 where id = $1`,
|
||||
[commandId, observedAt],
|
||||
);
|
||||
await insertEvent(client, commandId, row.device_id, row.project_id, 5, "dispatched", "acknowledged", "workload:device-edge", "protocol_reply_received", `result:${resultCode}`);
|
||||
await client.query(
|
||||
`update device_commands set lifecycle_state = 'verified',
|
||||
terminal_at = $2, terminal_reason_code = 'protocol_reply_serv_ok',
|
||||
updated_at = $2 where id = $1`,
|
||||
[commandId, observedAt],
|
||||
);
|
||||
await insertEvent(client, commandId, row.device_id, row.project_id, 6, "acknowledged", "verified", "workload:device-control-core", "protocol_reply_serv_ok", `result:${resultCode}`);
|
||||
await audit(client, {
|
||||
eventType: "command.verified",
|
||||
actorRef: "workload:device-control-core",
|
||||
projectId: row.project_id,
|
||||
deviceId: row.device_id,
|
||||
payload: { commandRef: `command:${commandId}`, commandType: row.command_type, resultCode },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (lifecycleState === "unknown" && row.lifecycle_state === "dispatched") {
|
||||
await client.query(
|
||||
`update device_commands set lifecycle_state = 'unknown', terminal_at = $2,
|
||||
terminal_reason_code = $3, updated_at = $2 where id = $1`,
|
||||
[commandId, observedAt, resultCode],
|
||||
);
|
||||
await insertEvent(client, commandId, row.device_id, row.project_id, 5, "dispatched", "unknown", "workload:device-edge", resultCode);
|
||||
}
|
||||
}
|
||||
|
||||
async function findProject(client, projectId) {
|
||||
const result = await client.query(
|
||||
`select id, owner_scope_id, lifecycle_state from device_projects where id = $1 for share`,
|
||||
[projectId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw domainError("device_project_not_found", 404);
|
||||
if (row.lifecycle_state !== "active") throw domainError("device_project_inactive", 409);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function findCommandableDevice(client, projectId, deviceId) {
|
||||
const result = await client.query(
|
||||
`select di.id, di.display_name, di.model_profile_ref, di.lifecycle_state
|
||||
from device_instances di
|
||||
where di.project_id = $1 and di.id = $2
|
||||
and exists (
|
||||
select 1 from device_routes dr
|
||||
where dr.project_id = di.project_id
|
||||
and dr.model_profile_ref = di.model_profile_ref
|
||||
and dr.direction = 'bidirectional'
|
||||
and dr.lifecycle_state = 'active'
|
||||
)
|
||||
for share`,
|
||||
[projectId, deviceId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw domainError("device_command_route_unavailable", 409);
|
||||
if (row.model_profile_ref !== "arusnavi.b2.internal.v1") {
|
||||
throw domainError("device_command_profile_unsupported", 409);
|
||||
}
|
||||
if (["suspended", "retired"].includes(row.lifecycle_state)) {
|
||||
throw domainError("device_command_device_inactive", 409);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async function projectGrants(client, projectId) {
|
||||
const result = await client.query(
|
||||
`select id, principal_kind, principal_ref, project_role,
|
||||
capability_allow, capability_deny, lifecycle_state
|
||||
from device_project_grants where project_id = $1 for share`,
|
||||
[projectId],
|
||||
);
|
||||
return result.rows.map((row) => ({
|
||||
grantRef: `grant:${row.id}`,
|
||||
principalKind: row.principal_kind,
|
||||
principalRef: row.principal_ref,
|
||||
projectRole: row.project_role,
|
||||
capabilityAllow: row.capability_allow ?? [],
|
||||
capabilityDeny: row.capability_deny ?? [],
|
||||
lifecycleState: row.lifecycle_state,
|
||||
}));
|
||||
}
|
||||
|
||||
async function insertEvent(client, commandId, deviceId, projectId, sequence, from, to, actor, reason, evidence = null) {
|
||||
await client.query(
|
||||
`insert into device_command_events (
|
||||
id, command_id, device_id, project_id, sequence_number,
|
||||
from_state, to_state, actor_ref, reason_code, evidence_ref
|
||||
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[randomUUID(), commandId, deviceId, projectId, sequence, from, to, actor, reason, evidence],
|
||||
);
|
||||
}
|
||||
|
||||
async function audit(client, { eventType, actorRef, projectId, deviceId, payload }) {
|
||||
await client.query(
|
||||
`insert into device_audit_events (
|
||||
id, event_type, actor_ref, project_id, device_id, payload
|
||||
) values ($1,$2,$3,$4,$5,$6::jsonb)`,
|
||||
[randomUUID(), eventType, actorRef, projectId, deviceId, JSON.stringify(payload)],
|
||||
);
|
||||
}
|
||||
|
||||
function commandView(row) {
|
||||
return Object.freeze({
|
||||
commandRef: `command:${row.id}`,
|
||||
deviceRef: `device:${row.device_id}`,
|
||||
deviceName: row.device_name,
|
||||
commandKey: row.command_key,
|
||||
commandCatalogRef: row.command_catalog_ref,
|
||||
commandType: row.command_type,
|
||||
riskClass: row.risk_class,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
plannedAt: iso(row.planned_at),
|
||||
expiresAt: iso(row.expires_at),
|
||||
dispatchedAt: iso(row.dispatched_at),
|
||||
acknowledgedAt: iso(row.acknowledged_at),
|
||||
terminalAt: iso(row.terminal_at),
|
||||
terminalReasonCode: row.terminal_reason_code ?? null,
|
||||
createdAt: iso(row.created_at),
|
||||
updatedAt: iso(row.updated_at),
|
||||
});
|
||||
}
|
||||
|
||||
function iso(value) {
|
||||
return value == null ? null : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export function createTypedCommandRuntime({ repository, now = () => new Date() } = {}) {
|
||||
if (
|
||||
!repository
|
||||
|| typeof repository.planTypedServicePing !== "function"
|
||||
|| typeof repository.dispatchTypedCommand !== "function"
|
||||
|| typeof repository.recordTypedCommandStatus !== "function"
|
||||
) {
|
||||
throw new TypeError("device_typed_command_repository_required");
|
||||
}
|
||||
const credentials = new Map();
|
||||
|
||||
return Object.freeze({
|
||||
async planServicePing({ idempotencyKey, actor, input }) {
|
||||
const normalized = normalizeInput(input);
|
||||
const expiresAt = new Date(now().getTime() + normalized.expiresInSeconds * 1000);
|
||||
const requestDigest = `sha256:${createHash("sha256").update(JSON.stringify({
|
||||
actorRef: actor.userRef,
|
||||
projectId: normalized.projectId,
|
||||
deviceId: normalized.deviceId,
|
||||
operation: "service.ping",
|
||||
expiresInSeconds: normalized.expiresInSeconds,
|
||||
}), "utf8").digest("hex")}`;
|
||||
const execution = await repository.planTypedServicePing({
|
||||
idempotencyKey,
|
||||
requestDigest,
|
||||
actor,
|
||||
projectId: normalized.projectId,
|
||||
deviceId: normalized.deviceId,
|
||||
expiresAt,
|
||||
});
|
||||
if (!execution.replayed && execution.command.lifecycleState === "queued") {
|
||||
credentials.set(execution.commandId, Object.freeze({
|
||||
deviceId: normalized.deviceId,
|
||||
accessCode: normalized.accessCode,
|
||||
expiresAt: new Date(execution.command.expiresAt).getTime(),
|
||||
}));
|
||||
}
|
||||
return { replayed: execution.replayed, command: execution.command };
|
||||
},
|
||||
|
||||
async offerForDevice(deviceRef) {
|
||||
const deviceId = entityId(deviceRef, "device");
|
||||
const at = now();
|
||||
for (const [commandId, secret] of credentials) {
|
||||
if (secret.expiresAt <= at.getTime()) {
|
||||
await repository.dispatchTypedCommand({
|
||||
commandId,
|
||||
transportMessageRef: `edge-command:${randomUUID()}`,
|
||||
now: at,
|
||||
});
|
||||
credentials.delete(commandId);
|
||||
continue;
|
||||
}
|
||||
if (secret.deviceId !== deviceId) continue;
|
||||
const transportMessageRef = `edge-command:${randomUUID()}`;
|
||||
const dispatched = await repository.dispatchTypedCommand({
|
||||
commandId,
|
||||
transportMessageRef,
|
||||
now: at,
|
||||
});
|
||||
if (!dispatched) {
|
||||
credentials.delete(commandId);
|
||||
continue;
|
||||
}
|
||||
return Object.freeze({
|
||||
commandRef: `command:${commandId}`,
|
||||
commandType: "service.ping",
|
||||
accessCode: secret.accessCode,
|
||||
transportMessageRef,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
async recordStatus(status) {
|
||||
const commandId = entityId(status?.commandRef, "command");
|
||||
const normalized = normalizeStatus(status);
|
||||
await repository.recordTypedCommandStatus({ commandId, ...normalized });
|
||||
credentials.delete(commandId);
|
||||
},
|
||||
|
||||
status() {
|
||||
return Object.freeze({
|
||||
commandTransport: "typed-service-ping-v1",
|
||||
transientAuthorizations: credentials.size,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeInput(input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw domainError("device_service_ping_input_invalid", 400);
|
||||
}
|
||||
const keys = Object.keys(input).sort().join(",");
|
||||
if (keys !== "accessCode,deviceRef,expiresInSeconds,projectRef") {
|
||||
throw domainError("device_service_ping_input_invalid", 400);
|
||||
}
|
||||
if (typeof input.accessCode !== "string" || !/^\d{6}$/.test(input.accessCode)) {
|
||||
throw domainError("device_service_ping_access_code_invalid", 400);
|
||||
}
|
||||
const expiresInSeconds = Number(input.expiresInSeconds);
|
||||
if (!Number.isSafeInteger(expiresInSeconds) || expiresInSeconds < 30 || expiresInSeconds > 1800) {
|
||||
throw domainError("device_service_ping_ttl_invalid", 400);
|
||||
}
|
||||
return {
|
||||
projectId: entityId(input.projectRef, "project"),
|
||||
deviceId: entityId(input.deviceRef, "device"),
|
||||
accessCode: input.accessCode,
|
||||
expiresInSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_command_status_invalid");
|
||||
}
|
||||
if (!['acknowledged', 'unknown'].includes(value.lifecycleState)) {
|
||||
throw new TypeError("device_command_status_invalid");
|
||||
}
|
||||
if (typeof value.transportMessageRef !== "string" || !/^edge-command:[0-9a-f-]{36}$/i.test(value.transportMessageRef)) {
|
||||
throw new TypeError("device_command_status_invalid");
|
||||
}
|
||||
if (typeof value.resultCode !== "string" || !/^[a-z][a-z0-9._-]{1,63}$/.test(value.resultCode)) {
|
||||
throw new TypeError("device_command_status_invalid");
|
||||
}
|
||||
const observedAt = new Date(value.observedAt);
|
||||
if (Number.isNaN(observedAt.getTime())) throw new TypeError("device_command_status_invalid");
|
||||
return {
|
||||
transportMessageRef: value.transportMessageRef.toLowerCase(),
|
||||
lifecycleState: value.lifecycleState,
|
||||
resultCode: value.resultCode,
|
||||
observedAt,
|
||||
};
|
||||
}
|
||||
|
||||
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 domainError(`device_${prefix}_ref_invalid`, 400);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
Reference in New Issue
Block a user