497 lines
12 KiB
JavaScript
497 lines
12 KiB
JavaScript
export const ENVIRONMENT_DEV = "dev";
|
|
export const DEV_ENDPOINT = "http://74.48.78.17:6667";
|
|
export const JSON_RPC_VERSION = "2.0";
|
|
|
|
export const SERVICE_IDS = Object.freeze([
|
|
"hwlab-cloud-api",
|
|
"hwlab-cloud-web",
|
|
"hwlab-agent-mgr",
|
|
"hwlab-agent-worker",
|
|
"hwlab-gateway",
|
|
"hwlab-gateway-simu",
|
|
"hwlab-box-simu",
|
|
"hwlab-patch-panel",
|
|
"hwlab-router",
|
|
"hwlab-tunnel-client",
|
|
"hwlab-edge-proxy",
|
|
"hwlab-cli",
|
|
"hwlab-agent-skills"
|
|
]);
|
|
|
|
export const TABLES = Object.freeze([
|
|
"projects",
|
|
"gateway_sessions",
|
|
"box_resources",
|
|
"box_capabilities",
|
|
"wiring_configs",
|
|
"patch_panel_status",
|
|
"hardware_operations",
|
|
"audit_events",
|
|
"agent_sessions",
|
|
"worker_sessions",
|
|
"agent_trace_events",
|
|
"evidence_records"
|
|
]);
|
|
|
|
export const ERROR_CODES = Object.freeze({
|
|
parseError: -32700,
|
|
invalidRequest: -32600,
|
|
methodNotFound: -32601,
|
|
invalidParams: -32602,
|
|
internalError: -32603,
|
|
hwlabUnknown: -32000,
|
|
projectNotFound: -32010,
|
|
sessionNotFound: -32011,
|
|
capabilityUnavailable: -32020,
|
|
resourceLocked: -32021,
|
|
wiringInvalid: -32022,
|
|
operationRejected: -32023,
|
|
evidenceMissing: -32030,
|
|
auditRequired: -32040
|
|
});
|
|
|
|
function freezeRecordShape({ required, allowed }) {
|
|
return Object.freeze({
|
|
required: Object.freeze([...required]),
|
|
allowed: Object.freeze([...allowed])
|
|
});
|
|
}
|
|
|
|
export const PROTOCOL_RECORD_SHAPES = Object.freeze({
|
|
gatewaySession: freezeRecordShape({
|
|
required: [
|
|
"gatewaySessionId",
|
|
"projectId",
|
|
"serviceId",
|
|
"gatewayId",
|
|
"status",
|
|
"environment",
|
|
"startedAt"
|
|
],
|
|
allowed: [
|
|
"gatewaySessionId",
|
|
"projectId",
|
|
"serviceId",
|
|
"gatewayId",
|
|
"endpoint",
|
|
"status",
|
|
"environment",
|
|
"startedAt",
|
|
"lastSeenAt",
|
|
"stoppedAt",
|
|
"labels"
|
|
]
|
|
}),
|
|
boxResource: freezeRecordShape({
|
|
required: [
|
|
"resourceId",
|
|
"projectId",
|
|
"gatewaySessionId",
|
|
"boxId",
|
|
"resourceType",
|
|
"state",
|
|
"environment",
|
|
"createdAt",
|
|
"updatedAt"
|
|
],
|
|
allowed: [
|
|
"resourceId",
|
|
"projectId",
|
|
"gatewaySessionId",
|
|
"boxId",
|
|
"resourceType",
|
|
"name",
|
|
"state",
|
|
"environment",
|
|
"metadata",
|
|
"createdAt",
|
|
"updatedAt"
|
|
]
|
|
}),
|
|
boxCapability: freezeRecordShape({
|
|
required: [
|
|
"capabilityId",
|
|
"resourceId",
|
|
"projectId",
|
|
"name",
|
|
"direction",
|
|
"valueType",
|
|
"mutatesState",
|
|
"createdAt",
|
|
"updatedAt"
|
|
],
|
|
allowed: [
|
|
"capabilityId",
|
|
"resourceId",
|
|
"projectId",
|
|
"name",
|
|
"description",
|
|
"direction",
|
|
"valueType",
|
|
"unit",
|
|
"constraints",
|
|
"mutatesState",
|
|
"createdAt",
|
|
"updatedAt"
|
|
]
|
|
}),
|
|
hardwareOperation: freezeRecordShape({
|
|
required: [
|
|
"operationId",
|
|
"projectId",
|
|
"gatewaySessionId",
|
|
"resourceId",
|
|
"capabilityId",
|
|
"requestedBy",
|
|
"status",
|
|
"environment",
|
|
"requestedAt",
|
|
"updatedAt"
|
|
],
|
|
allowed: [
|
|
"operationId",
|
|
"projectId",
|
|
"gatewaySessionId",
|
|
"agentSessionId",
|
|
"workerSessionId",
|
|
"resourceId",
|
|
"capabilityId",
|
|
"requestedBy",
|
|
"input",
|
|
"output",
|
|
"status",
|
|
"environment",
|
|
"requestedAt",
|
|
"startedAt",
|
|
"completedAt",
|
|
"updatedAt",
|
|
"error"
|
|
]
|
|
}),
|
|
auditEvent: freezeRecordShape({
|
|
required: [
|
|
"auditId",
|
|
"traceId",
|
|
"actorType",
|
|
"actorId",
|
|
"action",
|
|
"targetType",
|
|
"targetId",
|
|
"serviceId",
|
|
"environment",
|
|
"occurredAt"
|
|
],
|
|
allowed: [
|
|
"auditId",
|
|
"traceId",
|
|
"actorType",
|
|
"actorId",
|
|
"action",
|
|
"targetType",
|
|
"targetId",
|
|
"projectId",
|
|
"gatewaySessionId",
|
|
"workerSessionId",
|
|
"operationId",
|
|
"serviceId",
|
|
"environment",
|
|
"outcome",
|
|
"reason",
|
|
"metadata",
|
|
"occurredAt"
|
|
]
|
|
}),
|
|
evidenceRecord: freezeRecordShape({
|
|
required: [
|
|
"evidenceId",
|
|
"projectId",
|
|
"operationId",
|
|
"kind",
|
|
"uri",
|
|
"sha256",
|
|
"serviceId",
|
|
"environment",
|
|
"createdAt"
|
|
],
|
|
allowed: [
|
|
"evidenceId",
|
|
"projectId",
|
|
"operationId",
|
|
"agentSessionId",
|
|
"workerSessionId",
|
|
"kind",
|
|
"uri",
|
|
"mimeType",
|
|
"sha256",
|
|
"sizeBytes",
|
|
"serviceId",
|
|
"environment",
|
|
"metadata",
|
|
"createdAt"
|
|
]
|
|
})
|
|
});
|
|
|
|
export class HwlabProtocolError extends Error {
|
|
constructor(message, { code = ERROR_CODES.hwlabUnknown, data = {}, context = {} } = {}) {
|
|
super(message);
|
|
this.name = "HwlabProtocolError";
|
|
this.code = code;
|
|
this.data = data;
|
|
this.context = context;
|
|
}
|
|
}
|
|
|
|
const methodPattern = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/;
|
|
const protocolIdPattern = /^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/;
|
|
const actionPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
|
|
const timestampFieldNames = new Set([
|
|
"startedAt",
|
|
"lastSeenAt",
|
|
"stoppedAt",
|
|
"createdAt",
|
|
"updatedAt",
|
|
"requestedAt",
|
|
"completedAt",
|
|
"occurredAt"
|
|
]);
|
|
const idFieldNames = new Set([
|
|
"gatewaySessionId",
|
|
"projectId",
|
|
"gatewayId",
|
|
"resourceId",
|
|
"boxId",
|
|
"capabilityId",
|
|
"operationId",
|
|
"agentSessionId",
|
|
"workerSessionId",
|
|
"requestedBy",
|
|
"auditId",
|
|
"traceId",
|
|
"actorId",
|
|
"targetId",
|
|
"evidenceId"
|
|
]);
|
|
const actorTypes = new Set(["user", "service", "agent", "worker", "system"]);
|
|
const auditOutcomes = new Set(["accepted", "succeeded", "failed", "rejected", "canceled"]);
|
|
const evidenceKinds = new Set(["log", "screenshot", "measurement", "artifact", "report", "trace"]);
|
|
const gatewayStatuses = new Set(["starting", "connected", "degraded", "disconnected", "stopped"]);
|
|
const resourceStates = new Set(["available", "reserved", "running", "maintenance", "offline"]);
|
|
const operationStatuses = new Set([
|
|
"requested",
|
|
"accepted",
|
|
"queued",
|
|
"running",
|
|
"succeeded",
|
|
"failed",
|
|
"rejected",
|
|
"canceled",
|
|
"timed_out"
|
|
]);
|
|
const resourceTypes = new Set([
|
|
"board",
|
|
"power_supply",
|
|
"serial_port",
|
|
"network_port",
|
|
"relay",
|
|
"sensor",
|
|
"simulator_endpoint"
|
|
]);
|
|
const capabilityDirections = new Set(["input", "output", "bidirectional"]);
|
|
const capabilityValueTypes = new Set(["boolean", "integer", "number", "string", "object", "binary"]);
|
|
|
|
export function isFrozenServiceId(serviceId) {
|
|
return SERVICE_IDS.includes(serviceId);
|
|
}
|
|
|
|
export function isProtocolId(value) {
|
|
return typeof value === "string" && protocolIdPattern.test(value);
|
|
}
|
|
|
|
export function validateMeta(meta) {
|
|
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
|
throw new Error("meta must be an object");
|
|
}
|
|
if (!meta.traceId) {
|
|
throw new Error("meta.traceId is required");
|
|
}
|
|
if (!isFrozenServiceId(meta.serviceId)) {
|
|
throw new Error(`unknown serviceId ${JSON.stringify(meta.serviceId)}`);
|
|
}
|
|
if (meta.environment !== ENVIRONMENT_DEV) {
|
|
throw new Error(`unsupported environment ${JSON.stringify(meta.environment)}`);
|
|
}
|
|
}
|
|
|
|
export function validateRequest(envelope) {
|
|
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
throw new Error("request must be an object");
|
|
}
|
|
if (envelope.jsonrpc !== JSON_RPC_VERSION) {
|
|
throw new Error(`jsonrpc must be ${JSON_RPC_VERSION}`);
|
|
}
|
|
if (envelope.id === undefined || envelope.id === null) {
|
|
throw new Error("id is required");
|
|
}
|
|
if (!methodPattern.test(envelope.method)) {
|
|
throw new Error(`invalid method ${JSON.stringify(envelope.method)}`);
|
|
}
|
|
if (
|
|
envelope.params !== undefined &&
|
|
(!envelope.params || typeof envelope.params !== "object" || Array.isArray(envelope.params))
|
|
) {
|
|
throw new Error("params must be an object when present");
|
|
}
|
|
validateMeta(envelope.meta);
|
|
}
|
|
|
|
export function validateResponse(envelope) {
|
|
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
throw new Error("response must be an object");
|
|
}
|
|
if (envelope.jsonrpc !== JSON_RPC_VERSION) {
|
|
throw new Error(`jsonrpc must be ${JSON_RPC_VERSION}`);
|
|
}
|
|
if (envelope.id === undefined || envelope.id === null) {
|
|
throw new Error("id is required");
|
|
}
|
|
const hasResult = Object.hasOwn(envelope, "result");
|
|
const hasError = Object.hasOwn(envelope, "error");
|
|
if (hasResult === hasError) {
|
|
throw new Error("response must contain exactly one of result or error");
|
|
}
|
|
if (hasError) {
|
|
validateRpcError(envelope.error);
|
|
}
|
|
validateMeta(envelope.meta);
|
|
}
|
|
|
|
export function validateRpcError(error) {
|
|
if (!error || typeof error !== "object" || Array.isArray(error)) {
|
|
throw new Error("error must be an object");
|
|
}
|
|
if (!Number.isInteger(error.code)) {
|
|
throw new Error("error.code must be an integer");
|
|
}
|
|
if (!error.message || typeof error.message !== "string") {
|
|
throw new Error("error.message is required");
|
|
}
|
|
}
|
|
|
|
export function errorNameForCode(code) {
|
|
for (const [name, value] of Object.entries(ERROR_CODES)) {
|
|
if (value === code) {
|
|
return name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
}
|
|
}
|
|
|
|
return "hwlab_unknown";
|
|
}
|
|
|
|
export function createRpcErrorBody({ code = ERROR_CODES.hwlabUnknown, message = "HWLAB runtime error", data = {} }) {
|
|
const error = {
|
|
code,
|
|
name: errorNameForCode(code),
|
|
message,
|
|
data
|
|
};
|
|
|
|
validateRpcError(error);
|
|
return {
|
|
error
|
|
};
|
|
}
|
|
|
|
export function assertProtocolRecord(kind, record) {
|
|
const shape = PROTOCOL_RECORD_SHAPES[kind];
|
|
if (!shape) {
|
|
throw new Error(`unknown protocol record kind ${JSON.stringify(kind)}`);
|
|
}
|
|
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
throw new Error(`${kind} must be an object`);
|
|
}
|
|
|
|
for (const field of shape.required) {
|
|
if (!Object.hasOwn(record, field)) {
|
|
throw new Error(`${kind}.${field} is required`);
|
|
}
|
|
}
|
|
|
|
const allowed = new Set(shape.allowed);
|
|
for (const [field, value] of Object.entries(record)) {
|
|
if (!allowed.has(field)) {
|
|
throw new Error(`${kind}.${field} is not part of the frozen protocol schema`);
|
|
}
|
|
assertProtocolField(kind, field, value);
|
|
}
|
|
}
|
|
|
|
export function assertProtocolRecords(kind, records) {
|
|
if (!Array.isArray(records)) {
|
|
throw new Error(`${kind} records must be an array`);
|
|
}
|
|
for (const record of records) {
|
|
assertProtocolRecord(kind, record);
|
|
}
|
|
}
|
|
|
|
function assertProtocolField(kind, field, value) {
|
|
if (idFieldNames.has(field) && !isProtocolId(value)) {
|
|
throw new Error(`${kind}.${field} must be a protocol id`);
|
|
}
|
|
if (timestampFieldNames.has(field) && Number.isNaN(Date.parse(value))) {
|
|
throw new Error(`${kind}.${field} must be an RFC 3339 timestamp`);
|
|
}
|
|
if (field === "environment" && value !== ENVIRONMENT_DEV) {
|
|
throw new Error(`${kind}.environment must be ${ENVIRONMENT_DEV}`);
|
|
}
|
|
if (field === "serviceId" && !isFrozenServiceId(value)) {
|
|
throw new Error(`${kind}.serviceId is not a frozen service id`);
|
|
}
|
|
if ((field === "action" || field === "name") && !actionPattern.test(value)) {
|
|
throw new Error(`${kind}.${field} must be a dotted lower-case action name`);
|
|
}
|
|
if (field === "actorType" && !actorTypes.has(value)) {
|
|
throw new Error(`${kind}.actorType is invalid`);
|
|
}
|
|
if (field === "outcome" && !auditOutcomes.has(value)) {
|
|
throw new Error(`${kind}.outcome is invalid`);
|
|
}
|
|
if (field === "kind" && !evidenceKinds.has(value)) {
|
|
throw new Error(`${kind}.kind is invalid`);
|
|
}
|
|
if (field === "status") {
|
|
assertStatusField(kind, value);
|
|
}
|
|
if (field === "state" && !resourceStates.has(value)) {
|
|
throw new Error(`${kind}.state is invalid`);
|
|
}
|
|
if (field === "resourceType" && !resourceTypes.has(value)) {
|
|
throw new Error(`${kind}.resourceType is invalid`);
|
|
}
|
|
if (field === "direction" && !capabilityDirections.has(value)) {
|
|
throw new Error(`${kind}.direction is invalid`);
|
|
}
|
|
if (field === "valueType" && !capabilityValueTypes.has(value)) {
|
|
throw new Error(`${kind}.valueType is invalid`);
|
|
}
|
|
if (field === "mutatesState" && typeof value !== "boolean") {
|
|
throw new Error(`${kind}.mutatesState must be boolean`);
|
|
}
|
|
if (field === "sha256" && !/^[a-f0-9]{64}$/.test(value)) {
|
|
throw new Error(`${kind}.sha256 must be lowercase sha256 hex`);
|
|
}
|
|
}
|
|
|
|
function assertStatusField(kind, value) {
|
|
if (kind === "gatewaySession" && !gatewayStatuses.has(value)) {
|
|
throw new Error(`${kind}.status is invalid`);
|
|
}
|
|
if (kind === "boxResource" && !resourceStates.has(value)) {
|
|
throw new Error(`${kind}.status is invalid`);
|
|
}
|
|
if (kind === "hardwareOperation" && !operationStatuses.has(value)) {
|
|
throw new Error(`${kind}.status is invalid`);
|
|
}
|
|
}
|