feat(device-core): add idempotent project management
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import {
|
||||
@@ -7,12 +7,25 @@ import {
|
||||
normalizeDiscoverySignal,
|
||||
toSafeDiscoveryView,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import {
|
||||
normalizeManagementActor,
|
||||
normalizeManagementCommand,
|
||||
} from "./project-management.mjs";
|
||||
|
||||
const managementRoutes = new Map([
|
||||
["/internal/v1/management/owner-scopes:ensure", "owner_scope.ensure"],
|
||||
["/internal/v1/management/projects:ensure", "project.ensure"],
|
||||
["/internal/v1/management/collections:ensure", "collection.ensure"],
|
||||
["/internal/v1/management/project-grants:upsert", "project_grant.upsert"],
|
||||
]);
|
||||
|
||||
export function createControlCoreApp({
|
||||
repository,
|
||||
gatewayToken = "",
|
||||
identifierPepper = "",
|
||||
discoveryIngestEnabled = false,
|
||||
managementApiEnabled = false,
|
||||
managementToken = "",
|
||||
} = {}) {
|
||||
if (!repository || typeof repository.health !== "function") {
|
||||
throw new TypeError("device_repository_required");
|
||||
@@ -28,6 +41,14 @@ export function createControlCoreApp({
|
||||
throw new TypeError("device_identifier_pepper_invalid");
|
||||
}
|
||||
}
|
||||
if (managementApiEnabled) {
|
||||
if (typeof repository.executeManagementCommand !== "function") {
|
||||
throw new TypeError("device_management_repository_required");
|
||||
}
|
||||
if (typeof managementToken !== "string" || managementToken.length < 32) {
|
||||
throw new TypeError("device_management_token_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
@@ -47,10 +68,56 @@ export function createControlCoreApp({
|
||||
service: "nodedc-device-control-core",
|
||||
database,
|
||||
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
|
||||
managementApi: managementApiEnabled ? "enabled" : "disabled",
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const managementCommandKind = managementRoutes.get(requestUrl.pathname);
|
||||
if (request.method === "POST" && managementCommandKind) {
|
||||
if (!managementApiEnabled) {
|
||||
return writeJson(response, 404, {
|
||||
ok: false,
|
||||
error: "device_management_api_disabled",
|
||||
});
|
||||
}
|
||||
if (!matchesBearer(request.headers.authorization, managementToken)) {
|
||||
return writeJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_management_auth_required",
|
||||
});
|
||||
}
|
||||
|
||||
const idempotencyKey = normalizeIdempotencyKey(
|
||||
request.headers["idempotency-key"],
|
||||
);
|
||||
const actor = managementActorFromHeaders(request.headers);
|
||||
const input = await readJsonBody(request, 64 * 1024);
|
||||
const command = normalizeManagementCommand(managementCommandKind, input);
|
||||
const requestDigest = managementRequestDigest({
|
||||
actor,
|
||||
commandKind: managementCommandKind,
|
||||
command,
|
||||
});
|
||||
const execution = await repository.executeManagementCommand({
|
||||
idempotencyKey,
|
||||
commandKind: managementCommandKind,
|
||||
requestDigest,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
response.setHeader("Idempotency-Key", idempotencyKey);
|
||||
response.setHeader(
|
||||
"Idempotency-Replayed",
|
||||
execution.replayed ? "true" : "false",
|
||||
);
|
||||
return writeJson(response, 200, {
|
||||
ok: true,
|
||||
replayed: execution.replayed,
|
||||
result: execution.result,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST"
|
||||
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
||||
@@ -108,6 +175,62 @@ export function createControlCoreApp({
|
||||
return server;
|
||||
}
|
||||
|
||||
function managementActorFromHeaders(headers) {
|
||||
return normalizeManagementActor({
|
||||
userRef: singleHeader(headers["x-nodedc-user-ref"]),
|
||||
hubRole: singleHeader(headers["x-nodedc-hub-role"]),
|
||||
groupRefs: commaSeparatedHeader(headers["x-nodedc-group-refs"]),
|
||||
ownerScopes: ownerScopeHeader(headers["x-nodedc-owner-scopes"]),
|
||||
});
|
||||
}
|
||||
|
||||
function ownerScopeHeader(value) {
|
||||
return commaSeparatedHeader(value).map((claim) => {
|
||||
const separatorIndex = claim.indexOf("=");
|
||||
if (separatorIndex < 1 || separatorIndex === claim.length - 1) {
|
||||
throw new TypeError("device_actor_owner_scopes_invalid");
|
||||
}
|
||||
return {
|
||||
scopeKind: claim.slice(0, separatorIndex),
|
||||
ownerRef: claim.slice(separatorIndex + 1),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function commaSeparatedHeader(value) {
|
||||
const header = singleHeader(value, true);
|
||||
if (!header) return [];
|
||||
return header.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function singleHeader(value, optional = false) {
|
||||
if (Array.isArray(value)) throw new TypeError("device_management_header_invalid");
|
||||
if (value == null || value === "") {
|
||||
if (optional) return "";
|
||||
throw new TypeError("device_management_header_required");
|
||||
}
|
||||
if (typeof value !== "string" || value.length > 4096) {
|
||||
throw new TypeError("device_management_header_invalid");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeIdempotencyKey(value) {
|
||||
const key = singleHeader(value);
|
||||
if (!/^[\x21-\x7e]{8,256}$/.test(key)) {
|
||||
const error = new Error("device_idempotency_key_invalid");
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function managementRequestDigest(value) {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(JSON.stringify(value), "utf8")
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
function matchesBearer(header, expected) {
|
||||
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
|
||||
const actual = Buffer.from(header.slice("Bearer ".length), "utf8");
|
||||
|
||||
Reference in New Issue
Block a user