481 lines
13 KiB
JavaScript
481 lines
13 KiB
JavaScript
import { createHash, randomUUID } from "node:crypto";
|
|
|
|
import { createRpcErrorBody, ENVIRONMENT_DEV, ERROR_CODES, isProtocolEnvironment } from "../protocol/index.mjs";
|
|
import { getPortDefinition } from "./model.mjs";
|
|
|
|
export const AUDIT_SHAPE_FIELDS = Object.freeze([
|
|
"auditId",
|
|
"traceId",
|
|
"actorType",
|
|
"actorId",
|
|
"action",
|
|
"targetType",
|
|
"targetId",
|
|
"serviceId",
|
|
"environment",
|
|
"occurredAt"
|
|
]);
|
|
|
|
export const EVIDENCE_SHAPE_FIELDS = Object.freeze([
|
|
"evidenceId",
|
|
"projectId",
|
|
"operationId",
|
|
"kind",
|
|
"uri",
|
|
"sha256",
|
|
"serviceId",
|
|
"environment",
|
|
"createdAt"
|
|
]);
|
|
|
|
export function isoNow() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
export function makeId(prefix, value = randomUUID()) {
|
|
const normalized = String(value)
|
|
.trim()
|
|
.replace(/[^A-Za-z0-9._:-]+/g, "_")
|
|
.replace(/^_+|_+$/g, "");
|
|
|
|
return `${prefix}_${normalized || randomUUID()}`;
|
|
}
|
|
|
|
export function operationContext(input = {}, { serviceId, actorId = `svc_${serviceId}`, now = isoNow() } = {}) {
|
|
const traceId = input.traceId ?? makeId("trc", randomUUID());
|
|
const operationId = input.operationId ?? makeId("op", randomUUID());
|
|
const requestId = input.requestId ?? makeId("req", randomUUID());
|
|
|
|
return {
|
|
traceId,
|
|
operationId,
|
|
requestId,
|
|
actorType: input.actorType ?? "service",
|
|
actorId: normalizeActorId(input.actorId ?? actorId),
|
|
requestedBy: normalizeActorId(input.requestedBy ?? input.actorId ?? actorId),
|
|
observedAt: input.observedAt ?? now
|
|
};
|
|
}
|
|
|
|
function normalizeActorId(value) {
|
|
const text = String(value ?? "");
|
|
return /^[a-z][a-z0-9]*_/.test(text) ? text : makeId("svc", text || "hwlab-sim");
|
|
}
|
|
|
|
export function createAuditEvent({
|
|
serviceId,
|
|
action,
|
|
targetType,
|
|
targetId,
|
|
projectId,
|
|
gatewaySessionId,
|
|
operationId,
|
|
traceId,
|
|
actorType = "service",
|
|
actorId = `svc_${serviceId}`,
|
|
outcome,
|
|
reason,
|
|
metadata = {},
|
|
environment = ENVIRONMENT_DEV,
|
|
occurredAt = isoNow()
|
|
}) {
|
|
return {
|
|
auditId: makeId("aud", `${serviceId}_${operationId ?? traceId ?? randomUUID()}_${action}`),
|
|
traceId,
|
|
actorType,
|
|
actorId,
|
|
action,
|
|
targetType,
|
|
targetId,
|
|
...(projectId ? { projectId } : {}),
|
|
...(gatewaySessionId ? { gatewaySessionId } : {}),
|
|
...(operationId ? { operationId } : {}),
|
|
serviceId,
|
|
environment: normalizeEnvironment(environment),
|
|
...(outcome ? { outcome } : {}),
|
|
...(reason ? { reason } : {}),
|
|
metadata,
|
|
occurredAt
|
|
};
|
|
}
|
|
|
|
export function createEvidenceRecord({
|
|
serviceId,
|
|
projectId,
|
|
operationId,
|
|
traceId,
|
|
kind = "measurement",
|
|
payload = {},
|
|
metadata = {},
|
|
environment = ENVIRONMENT_DEV,
|
|
createdAt = isoNow()
|
|
}) {
|
|
const normalizedEnvironment = normalizeEnvironment(environment);
|
|
const serialized = JSON.stringify({
|
|
serviceId,
|
|
projectId,
|
|
operationId,
|
|
traceId,
|
|
kind,
|
|
environment: normalizedEnvironment,
|
|
payload,
|
|
metadata,
|
|
createdAt
|
|
});
|
|
const sha256 = createHash("sha256").update(serialized).digest("hex");
|
|
|
|
return {
|
|
evidenceId: makeId("evd", `${serviceId}_${operationId}_${kind}`),
|
|
projectId,
|
|
operationId,
|
|
kind,
|
|
uri: `memory://${serviceId}/${operationId}/${kind}.json`,
|
|
mimeType: "application/json",
|
|
sha256,
|
|
sizeBytes: Buffer.byteLength(serialized),
|
|
serviceId,
|
|
environment: normalizedEnvironment,
|
|
metadata: {
|
|
traceId,
|
|
...metadata
|
|
},
|
|
createdAt
|
|
};
|
|
}
|
|
|
|
function normalizeEnvironment(environment) {
|
|
return isProtocolEnvironment(environment) ? environment : ENVIRONMENT_DEV;
|
|
}
|
|
|
|
export function normalizePortValue(value, definition) {
|
|
if (!definition) {
|
|
return value;
|
|
}
|
|
|
|
if (definition.valueType === "boolean") {
|
|
if (typeof value === "boolean") {
|
|
return value;
|
|
}
|
|
if (value === "true") {
|
|
return true;
|
|
}
|
|
if (value === "false") {
|
|
return false;
|
|
}
|
|
throw createRuntimeError({
|
|
code: ERROR_CODES.invalidParams,
|
|
message: `${definition.port} requires a boolean value`,
|
|
data: {
|
|
port: definition.port,
|
|
valueType: definition.valueType
|
|
}
|
|
});
|
|
}
|
|
|
|
if (definition.valueType === "number") {
|
|
const number = typeof value === "number" ? value : Number(value);
|
|
if (Number.isFinite(number)) {
|
|
return number;
|
|
}
|
|
throw createRuntimeError({
|
|
code: ERROR_CODES.invalidParams,
|
|
message: `${definition.port} requires a finite number value`,
|
|
data: {
|
|
port: definition.port,
|
|
valueType: definition.valueType
|
|
}
|
|
});
|
|
}
|
|
|
|
if (definition.valueType === "string" && value !== null && value !== undefined) {
|
|
return String(value);
|
|
}
|
|
|
|
if (definition.valueType === "object" && value !== null && value !== undefined) {
|
|
if (typeof value === "object" && !Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
throw createRuntimeError({
|
|
code: ERROR_CODES.invalidParams,
|
|
message: `${definition.port} requires an object value`,
|
|
data: {
|
|
port: definition.port,
|
|
valueType: definition.valueType
|
|
}
|
|
});
|
|
}
|
|
|
|
return value ?? null;
|
|
}
|
|
|
|
export function findCapability(state, capabilityId) {
|
|
if (!capabilityId) {
|
|
return null;
|
|
}
|
|
return state.capabilities.find((capability) => capability.capabilityId === capabilityId) ?? null;
|
|
}
|
|
|
|
export function resolvePortForRead(state, { port, capabilityId }) {
|
|
if (capabilityId) {
|
|
const capability = findCapability(state, capabilityId);
|
|
if (!capability?.constraints?.port || !state.ports[capability.constraints.port]) {
|
|
throw createRuntimeError({
|
|
code: ERROR_CODES.capabilityUnavailable,
|
|
message: "Requested hardware capability is unavailable",
|
|
data: {
|
|
capabilityId,
|
|
port: port ?? null,
|
|
resourceId: state.resource.resourceId,
|
|
reason: "capability_unavailable"
|
|
}
|
|
});
|
|
}
|
|
if (port && port !== capability.constraints.port) {
|
|
throw createRuntimeError({
|
|
code: ERROR_CODES.invalidParams,
|
|
message: "Port does not match requested capability",
|
|
data: {
|
|
capabilityId,
|
|
capabilityPort: capability.constraints.port,
|
|
port,
|
|
resourceId: state.resource.resourceId,
|
|
reason: "capability_port_mismatch"
|
|
}
|
|
});
|
|
}
|
|
|
|
return capability.constraints.port;
|
|
}
|
|
|
|
if (port && state.ports[port]) {
|
|
return port;
|
|
}
|
|
|
|
throw createRuntimeError({
|
|
code: ERROR_CODES.capabilityUnavailable,
|
|
message: "Requested hardware capability is unavailable",
|
|
data: {
|
|
capabilityId: capabilityId ?? null,
|
|
port: port ?? null,
|
|
resourceId: state.resource.resourceId,
|
|
reason: "capability_unavailable"
|
|
}
|
|
});
|
|
}
|
|
|
|
export function resolvePortForWrite(state, { port, capabilityId }) {
|
|
const resolvedPort = resolvePortForRead(state, { port, capabilityId });
|
|
const capability = capabilityId ? findCapability(state, capabilityId) : null;
|
|
if (capabilityId && !capability?.mutatesState) {
|
|
throw createRuntimeError({
|
|
code: ERROR_CODES.operationRejected,
|
|
message: "Read-only capability cannot be used for a write operation",
|
|
data: {
|
|
capabilityId,
|
|
port: resolvedPort,
|
|
reason: "read_only_capability"
|
|
}
|
|
});
|
|
}
|
|
|
|
return resolvedPort;
|
|
}
|
|
|
|
export function readBoxPort({ state, port, capabilityId, context = operationContext({}) }) {
|
|
const resolvedPort = resolvePortForRead(state, { port, capabilityId });
|
|
const current = state.ports[resolvedPort];
|
|
const capability = capabilityId ? findCapability(state, capabilityId) : null;
|
|
const audit = createAuditEvent({
|
|
serviceId: "hwlab-box-simu",
|
|
action: "hardware.operation.succeeded",
|
|
targetType: "box_resource",
|
|
targetId: state.resource.resourceId,
|
|
projectId: state.projectId,
|
|
gatewaySessionId: state.gatewaySessionId,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
actorType: context.actorType,
|
|
actorId: context.actorId,
|
|
outcome: "succeeded",
|
|
metadata: {
|
|
boxId: state.boxId,
|
|
port: resolvedPort,
|
|
direction: current.direction,
|
|
capabilityId: capability?.capabilityId ?? null,
|
|
operation: "port.read"
|
|
},
|
|
occurredAt: context.observedAt
|
|
});
|
|
const evidence = createEvidenceRecord({
|
|
serviceId: "hwlab-box-simu",
|
|
projectId: state.projectId,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
payload: current,
|
|
metadata: {
|
|
boxId: state.boxId,
|
|
resourceId: state.resource.resourceId,
|
|
capabilityId: capability?.capabilityId ?? null,
|
|
port: resolvedPort
|
|
},
|
|
createdAt: context.observedAt
|
|
});
|
|
|
|
return {
|
|
accepted: true,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
boxId: state.boxId,
|
|
resourceId: state.resource.resourceId,
|
|
capabilityId: capability?.capabilityId ?? null,
|
|
port: resolvedPort,
|
|
value: current.value,
|
|
state: current,
|
|
auditId: audit.auditId,
|
|
evidenceId: evidence.evidenceId,
|
|
audit,
|
|
evidence
|
|
};
|
|
}
|
|
|
|
export function writeBoxPort({
|
|
state,
|
|
port,
|
|
capabilityId,
|
|
value,
|
|
source = "local",
|
|
sourceResourceId,
|
|
sourcePort,
|
|
propagatedBy,
|
|
internal = false,
|
|
context = operationContext({})
|
|
}) {
|
|
const resolvedPort = resolvePortForWrite(state, { port, capabilityId });
|
|
const definition = getPortDefinition(resolvedPort);
|
|
const current = state.ports[resolvedPort];
|
|
const normalizedValue = normalizePortValue(value, definition);
|
|
const now = context.observedAt;
|
|
const capability = capabilityId ? findCapability(state, capabilityId) : null;
|
|
|
|
state.ports[resolvedPort] = {
|
|
...current,
|
|
value: normalizedValue,
|
|
source,
|
|
...(sourceResourceId ? { sourceResourceId } : {}),
|
|
...(sourcePort ? { sourcePort } : {}),
|
|
...(propagatedBy ? { propagatedBy } : {}),
|
|
updatedAt: now
|
|
};
|
|
state.updatedAt = now;
|
|
|
|
const audit = createAuditEvent({
|
|
serviceId: "hwlab-box-simu",
|
|
action: internal ? "hardware.internal_port.written" : "hardware.operation.succeeded",
|
|
targetType: "box_resource",
|
|
targetId: state.resource.resourceId,
|
|
projectId: state.projectId,
|
|
gatewaySessionId: state.gatewaySessionId,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
actorType: context.actorType,
|
|
actorId: context.actorId,
|
|
outcome: "succeeded",
|
|
metadata: {
|
|
boxId: state.boxId,
|
|
port: resolvedPort,
|
|
direction: current.direction,
|
|
capabilityId: capability?.capabilityId ?? null,
|
|
internalPortApi: internal,
|
|
patchPanelOnlyPropagation: true,
|
|
localLoopbackEnabled: false
|
|
},
|
|
occurredAt: now
|
|
});
|
|
const evidence = createEvidenceRecord({
|
|
serviceId: "hwlab-box-simu",
|
|
projectId: state.projectId,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
payload: state.ports[resolvedPort],
|
|
metadata: {
|
|
boxId: state.boxId,
|
|
resourceId: state.resource.resourceId,
|
|
capabilityId: capability?.capabilityId ?? null,
|
|
port: resolvedPort,
|
|
internalPortApi: internal,
|
|
producerServiceId: "hwlab-box-simu"
|
|
},
|
|
createdAt: now
|
|
});
|
|
|
|
return {
|
|
accepted: true,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
boxId: state.boxId,
|
|
resourceId: state.resource.resourceId,
|
|
capabilityId: capability?.capabilityId ?? null,
|
|
port: resolvedPort,
|
|
value: normalizedValue,
|
|
state: state.ports[resolvedPort],
|
|
crossDevicePropagation: "patch-panel-only",
|
|
localLoopbackEnabled: false,
|
|
auditId: audit.auditId,
|
|
evidenceId: evidence.evidenceId,
|
|
audit,
|
|
evidence
|
|
};
|
|
}
|
|
|
|
export function handleBoxInvoke({ state, body, context = operationContext(body ?? {}) }) {
|
|
const operation = body.operation ?? body.method;
|
|
if (operation === "port.read" || operation === "hardware.port.read") {
|
|
return readBoxPort({
|
|
state,
|
|
port: body.port,
|
|
capabilityId: body.capabilityId,
|
|
context
|
|
});
|
|
}
|
|
|
|
if (operation === "port.write" || operation === "hardware.port.write") {
|
|
return writeBoxPort({
|
|
state,
|
|
port: body.port,
|
|
capabilityId: body.capabilityId,
|
|
value: body.value,
|
|
source: body.source ?? "gateway",
|
|
context
|
|
});
|
|
}
|
|
|
|
throw createRuntimeError({
|
|
code: ERROR_CODES.methodNotFound,
|
|
message: "Box simulator invoke method is not implemented",
|
|
data: {
|
|
method: operation ?? null,
|
|
resourceId: state.resource.resourceId
|
|
}
|
|
});
|
|
}
|
|
|
|
export function createRuntimeError({ code, message, data = {}, statusCode }) {
|
|
const error = new Error(message);
|
|
error.statusCode = statusCode ?? httpStatusForErrorCode(code);
|
|
error.body = createRpcErrorBody({ code, message, data });
|
|
return error;
|
|
}
|
|
|
|
export function httpStatusForErrorCode(code) {
|
|
if (code === ERROR_CODES.invalidRequest || code === ERROR_CODES.invalidParams) {
|
|
return 400;
|
|
}
|
|
if (code === ERROR_CODES.methodNotFound || code === ERROR_CODES.capabilityUnavailable) {
|
|
return 404;
|
|
}
|
|
if (code === ERROR_CODES.resourceLocked || code === ERROR_CODES.operationRejected) {
|
|
return 409;
|
|
}
|
|
return 500;
|
|
}
|