feat: add cloud api skeleton
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
import { createCloudApiServer } from "../../internal/cloud/server.mjs";
|
||||
|
||||
const host = process.env.HWLAB_CLOUD_API_HOST || "0.0.0.0";
|
||||
const port = Number.parseInt(process.env.HWLAB_CLOUD_API_PORT || process.env.PORT || "6667", 10);
|
||||
|
||||
const server = createCloudApiServer();
|
||||
|
||||
server.listen(port, host, () => {
|
||||
const address = server.address();
|
||||
const resolvedPort = typeof address === "object" && address ? address.port : port;
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
serviceId: "hwlab-cloud-api",
|
||||
status: "listening",
|
||||
host,
|
||||
port: resolvedPort
|
||||
})}\n`
|
||||
);
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
|
||||
|
||||
export const CLOUD_API_SERVICE_ID = "hwlab-cloud-api";
|
||||
|
||||
export const AUDIT_FIELD_NAMES = Object.freeze([
|
||||
"requestId",
|
||||
"actor",
|
||||
"source",
|
||||
"operation",
|
||||
"target",
|
||||
"result",
|
||||
"timestamp"
|
||||
]);
|
||||
|
||||
export function createAuditRecord(input = {}) {
|
||||
const record = {
|
||||
requestId: asString(input.requestId, "req_unassigned"),
|
||||
actor: normalizeActor(input.actor),
|
||||
source: normalizeSource(input.source, input.traceId),
|
||||
operation: asString(input.operation, "cloud_api.request"),
|
||||
target: normalizeTarget(input.target),
|
||||
result: asString(input.result, "unknown"),
|
||||
timestamp: asString(input.timestamp, new Date().toISOString())
|
||||
};
|
||||
|
||||
assertAuditFields(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export function deriveActorFromMeta(meta = {}) {
|
||||
return {
|
||||
type: meta.actorType || "system",
|
||||
id: meta.actorId || `system_${CLOUD_API_SERVICE_ID}`
|
||||
};
|
||||
}
|
||||
|
||||
export function assertAuditFields(record) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
||||
throw new Error("audit record must be an object");
|
||||
}
|
||||
|
||||
for (const field of AUDIT_FIELD_NAMES) {
|
||||
if (!Object.hasOwn(record, field)) {
|
||||
throw new Error(`audit.${field} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isNaN(Date.parse(record.timestamp))) {
|
||||
throw new Error("audit.timestamp must be an RFC 3339 timestamp");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeActor(actor) {
|
||||
if (!actor) {
|
||||
return {
|
||||
type: "system",
|
||||
id: `system_${CLOUD_API_SERVICE_ID}`
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof actor === "string") {
|
||||
return {
|
||||
type: "user",
|
||||
id: actor
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof actor === "object" && !Array.isArray(actor)) {
|
||||
return {
|
||||
type: asString(actor.type, "user"),
|
||||
id: asString(actor.id, `system_${CLOUD_API_SERVICE_ID}`)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "system",
|
||||
id: `system_${CLOUD_API_SERVICE_ID}`
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSource(source, traceId) {
|
||||
const base = {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV
|
||||
};
|
||||
|
||||
if (traceId) {
|
||||
base.traceId = traceId;
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (typeof source === "string") {
|
||||
return {
|
||||
...base,
|
||||
serviceId: source
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof source === "object" && !Array.isArray(source)) {
|
||||
return {
|
||||
...base,
|
||||
...source
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function normalizeTarget(target) {
|
||||
if (!target) {
|
||||
return {
|
||||
type: "cloud_api",
|
||||
id: CLOUD_API_SERVICE_ID
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof target === "string") {
|
||||
return {
|
||||
type: "resource",
|
||||
id: target
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof target === "object" && !Array.isArray(target)) {
|
||||
return {
|
||||
type: asString(target.type, "resource"),
|
||||
id: asString(target.id, "target_unspecified")
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "cloud_api",
|
||||
id: CLOUD_API_SERVICE_ID
|
||||
};
|
||||
}
|
||||
|
||||
function asString(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return fallback;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { AUDIT_FIELD_NAMES, assertAuditFields, createAuditRecord } from "./index.mjs";
|
||||
|
||||
test("createAuditRecord exposes the L1 audit field names", () => {
|
||||
const record = createAuditRecord({
|
||||
requestId: "req_01J00000000000000000000000",
|
||||
actor: {
|
||||
type: "user",
|
||||
id: "usr_01J00000000000000000000000"
|
||||
},
|
||||
source: {
|
||||
serviceId: "hwlab-cloud-api",
|
||||
adapter: "json-rpc"
|
||||
},
|
||||
operation: "hardware.operation.request",
|
||||
target: {
|
||||
type: "project",
|
||||
id: "prj_01J00000000000000000000000"
|
||||
},
|
||||
result: "rejected",
|
||||
timestamp: "2026-05-21T00:00:00.000Z"
|
||||
});
|
||||
|
||||
for (const field of AUDIT_FIELD_NAMES) {
|
||||
assert.ok(Object.hasOwn(record, field), `expected audit.${field}`);
|
||||
}
|
||||
|
||||
assert.equal(record.requestId, "req_01J00000000000000000000000");
|
||||
assert.equal(record.actor.id, "usr_01J00000000000000000000000");
|
||||
assert.equal(record.source.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(record.operation, "hardware.operation.request");
|
||||
assert.equal(record.target.type, "project");
|
||||
assert.equal(record.result, "rejected");
|
||||
});
|
||||
|
||||
test("assertAuditFields rejects missing audit fields", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertAuditFields({
|
||||
requestId: "req_01J00000000000000000000000",
|
||||
actor: {},
|
||||
source: {},
|
||||
operation: "hardware.operation.request",
|
||||
target: {},
|
||||
result: "rejected"
|
||||
}),
|
||||
/audit.timestamp is required/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
ENVIRONMENT_DEV,
|
||||
ERROR_CODES,
|
||||
JSON_RPC_VERSION,
|
||||
validateRequest,
|
||||
validateResponse
|
||||
} from "../protocol/index.mjs";
|
||||
import {
|
||||
AUDIT_FIELD_NAMES,
|
||||
CLOUD_API_SERVICE_ID,
|
||||
createAuditRecord,
|
||||
deriveActorFromMeta
|
||||
} from "../audit/index.mjs";
|
||||
|
||||
export const SUPPORTED_RPC_METHODS = Object.freeze([
|
||||
"system.health",
|
||||
"cloud.adapter.describe",
|
||||
"hardware.operation.request"
|
||||
]);
|
||||
|
||||
const rpcHandlers = new Map([
|
||||
["system.health", handleSystemHealth],
|
||||
["cloud.adapter.describe", handleAdapterDescribe],
|
||||
["hardware.operation.request", handleHardwareOperationRequest]
|
||||
]);
|
||||
|
||||
export async function handleJsonRpcRequest(envelope, context = {}) {
|
||||
const requestMeta = extractMeta(envelope);
|
||||
const requestId = envelope?.id ?? "req_unassigned";
|
||||
const method = typeof envelope?.method === "string" ? envelope.method : "method_unspecified";
|
||||
|
||||
try {
|
||||
validateRequest(envelope);
|
||||
} catch (error) {
|
||||
return createErrorEnvelope({
|
||||
id: requestId,
|
||||
code: ERROR_CODES.invalidRequest,
|
||||
message: "Invalid JSON-RPC request",
|
||||
data: {
|
||||
reason: error.message
|
||||
},
|
||||
requestMeta,
|
||||
context: {
|
||||
...context,
|
||||
operation: "json_rpc.validate",
|
||||
target: {
|
||||
type: "rpc_method",
|
||||
id: method
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handler = rpcHandlers.get(envelope.method);
|
||||
if (!handler) {
|
||||
return createErrorEnvelope({
|
||||
id: envelope.id,
|
||||
code: ERROR_CODES.methodNotFound,
|
||||
message: `Method ${envelope.method} is not implemented by hwlab-cloud-api`,
|
||||
data: {
|
||||
method: envelope.method
|
||||
},
|
||||
requestMeta: envelope.meta,
|
||||
context: {
|
||||
...context,
|
||||
operation: envelope.method,
|
||||
target: {
|
||||
type: "rpc_method",
|
||||
id: envelope.method
|
||||
},
|
||||
result: "rejected"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(envelope.params ?? {}, envelope, context);
|
||||
return createResultEnvelope({
|
||||
id: envelope.id,
|
||||
result,
|
||||
requestMeta: envelope.meta
|
||||
});
|
||||
} catch (error) {
|
||||
return createErrorEnvelope({
|
||||
id: envelope.id,
|
||||
code: ERROR_CODES.internalError,
|
||||
message: "hwlab-cloud-api failed to process the request",
|
||||
data: {
|
||||
reason: error.message
|
||||
},
|
||||
requestMeta: envelope.meta,
|
||||
context: {
|
||||
...context,
|
||||
operation: envelope.method,
|
||||
target: {
|
||||
type: "rpc_method",
|
||||
id: envelope.method
|
||||
},
|
||||
result: "failed"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createResultEnvelope({ id, result, requestMeta = {} }) {
|
||||
const envelope = {
|
||||
jsonrpc: JSON_RPC_VERSION,
|
||||
id,
|
||||
result,
|
||||
meta: createResponseMeta(requestMeta)
|
||||
};
|
||||
|
||||
validateResponse(envelope);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
export function createErrorEnvelope({
|
||||
id = "req_unassigned",
|
||||
code = ERROR_CODES.hwlabUnknown,
|
||||
message = "HWLAB runtime error",
|
||||
data = {},
|
||||
requestMeta = {},
|
||||
context = {}
|
||||
} = {}) {
|
||||
const responseId = id ?? "req_unassigned";
|
||||
const meta = createResponseMeta(requestMeta);
|
||||
const audit = createAuditRecord({
|
||||
requestId: responseId,
|
||||
actor: deriveActorFromMeta(requestMeta),
|
||||
source: {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
traceId: meta.traceId,
|
||||
adapter: context.adapter || "json-rpc"
|
||||
},
|
||||
operation: context.operation || "json_rpc.error",
|
||||
target: context.target || {
|
||||
type: "cloud_api",
|
||||
id: CLOUD_API_SERVICE_ID
|
||||
},
|
||||
result: context.result || "failed",
|
||||
timestamp: context.timestamp
|
||||
});
|
||||
|
||||
const envelope = {
|
||||
jsonrpc: JSON_RPC_VERSION,
|
||||
id: responseId,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
data: {
|
||||
...normalizeErrorData(data),
|
||||
audit
|
||||
}
|
||||
},
|
||||
meta
|
||||
};
|
||||
|
||||
validateResponse(envelope);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
export function createResponseMeta(requestMeta = {}) {
|
||||
const meta = {
|
||||
traceId: requestMeta.traceId || `trc_${randomUUID()}`,
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV
|
||||
};
|
||||
|
||||
if (requestMeta.actorId) {
|
||||
meta.actorId = requestMeta.actorId;
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
function handleSystemHealth() {
|
||||
return {
|
||||
status: "ok",
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
db: {
|
||||
connected: false,
|
||||
mode: "not_configured"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleAdapterDescribe() {
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
rpcEndpoint: "POST /rpc",
|
||||
restEndpoint: "POST /v1/rpc/{method}",
|
||||
methods: SUPPORTED_RPC_METHODS,
|
||||
auditFields: AUDIT_FIELD_NAMES,
|
||||
db: {
|
||||
connected: false,
|
||||
mode: "migration_skeleton_only"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleHardwareOperationRequest(params, envelope, context) {
|
||||
const projectId = params.projectId || "project_unspecified";
|
||||
|
||||
return {
|
||||
accepted: false,
|
||||
status: "skeleton",
|
||||
reason: "cloud core L1 does not dispatch hardware operations yet",
|
||||
audit: createAuditRecord({
|
||||
requestId: envelope.id,
|
||||
actor: deriveActorFromMeta(envelope.meta),
|
||||
source: {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
traceId: envelope.meta.traceId,
|
||||
adapter: context.adapter || "json-rpc"
|
||||
},
|
||||
operation: "hardware.operation.request",
|
||||
target: {
|
||||
type: "project",
|
||||
id: projectId
|
||||
},
|
||||
result: "rejected"
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function extractMeta(envelope) {
|
||||
if (envelope && typeof envelope === "object" && !Array.isArray(envelope) && envelope.meta) {
|
||||
return envelope.meta;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeErrorData(data) {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
return {};
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { AUDIT_FIELD_NAMES } from "../audit/index.mjs";
|
||||
import { ERROR_CODES, validateResponse } from "../protocol/index.mjs";
|
||||
import { handleJsonRpcRequest } from "./json-rpc.mjs";
|
||||
|
||||
test("unknown JSON-RPC method returns an error envelope with audit fields", async () => {
|
||||
const response = await handleJsonRpcRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: "req_01J00000000000000000000000",
|
||||
method: "hardware.operation.cancel",
|
||||
params: {
|
||||
operationId: "op_01J00000000000000000000000"
|
||||
},
|
||||
meta: {
|
||||
traceId: "trc_01J00000000000000000000000",
|
||||
actorId: "usr_01J00000000000000000000000",
|
||||
serviceId: "hwlab-cloud-web",
|
||||
environment: "dev"
|
||||
}
|
||||
});
|
||||
|
||||
validateResponse(response);
|
||||
assert.equal(response.error.code, ERROR_CODES.methodNotFound);
|
||||
assert.equal(Object.hasOwn(response, "result"), false);
|
||||
|
||||
const audit = response.error.data.audit;
|
||||
for (const field of AUDIT_FIELD_NAMES) {
|
||||
assert.ok(Object.hasOwn(audit, field), `expected audit.${field}`);
|
||||
}
|
||||
assert.equal(audit.requestId, "req_01J00000000000000000000000");
|
||||
assert.equal(audit.actor.id, "usr_01J00000000000000000000000");
|
||||
assert.equal(audit.operation, "hardware.operation.cancel");
|
||||
assert.equal(audit.target.type, "rpc_method");
|
||||
assert.equal(audit.result, "rejected");
|
||||
});
|
||||
|
||||
test("hardware.operation.request is a non-dispatching skeleton with visible audit fields", async () => {
|
||||
const response = await handleJsonRpcRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: "req_01J00000000000000000000001",
|
||||
method: "hardware.operation.request",
|
||||
params: {
|
||||
projectId: "prj_01J00000000000000000000000"
|
||||
},
|
||||
meta: {
|
||||
traceId: "trc_01J00000000000000000000001",
|
||||
actorId: "usr_01J00000000000000000000000",
|
||||
serviceId: "hwlab-cloud-web",
|
||||
environment: "dev"
|
||||
}
|
||||
});
|
||||
|
||||
validateResponse(response);
|
||||
assert.equal(response.result.accepted, false);
|
||||
assert.equal(response.result.status, "skeleton");
|
||||
|
||||
for (const field of AUDIT_FIELD_NAMES) {
|
||||
assert.ok(Object.hasOwn(response.result.audit, field), `expected audit.${field}`);
|
||||
}
|
||||
assert.equal(response.result.audit.operation, "hardware.operation.request");
|
||||
assert.equal(response.result.audit.target.id, "prj_01J00000000000000000000000");
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { ENVIRONMENT_DEV, ERROR_CODES } from "../protocol/index.mjs";
|
||||
import {
|
||||
AUDIT_FIELD_NAMES,
|
||||
CLOUD_API_SERVICE_ID,
|
||||
createAuditRecord
|
||||
} from "../audit/index.mjs";
|
||||
import {
|
||||
SUPPORTED_RPC_METHODS,
|
||||
createErrorEnvelope,
|
||||
handleJsonRpcRequest
|
||||
} from "./json-rpc.mjs";
|
||||
|
||||
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
||||
|
||||
export function createCloudApiServer(options = {}) {
|
||||
return createServer(async (request, response) => {
|
||||
try {
|
||||
await routeRequest(request, response, options);
|
||||
} catch (error) {
|
||||
sendJson(response, 500, {
|
||||
error: {
|
||||
code: "internal_error",
|
||||
message: "hwlab-cloud-api request handling failed",
|
||||
reason: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function buildHealthPayload() {
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
status: "ok",
|
||||
db: {
|
||||
connected: false,
|
||||
mode: "not_configured"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function routeRequest(request, response, options) {
|
||||
const url = new URL(request.url || "/", "http://hwlab-cloud-api.local");
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
sendJson(response, 200, buildHealthPayload());
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/live") {
|
||||
sendJson(response, 200, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
status: "live"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && (url.pathname === "/rpc" || url.pathname === "/json-rpc")) {
|
||||
await handleRpcHttpRequest(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) {
|
||||
await handleRestAdapter(request, response, url, options);
|
||||
return;
|
||||
}
|
||||
|
||||
sendRestError(request, response, 404, "not_found", "Route is not part of the L1 cloud-api skeleton", {
|
||||
operation: `${request.method || "GET"} ${url.pathname}`,
|
||||
target: {
|
||||
type: "route",
|
||||
id: url.pathname
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRpcHttpRequest(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let envelope;
|
||||
|
||||
try {
|
||||
envelope = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendJson(
|
||||
response,
|
||||
400,
|
||||
createErrorEnvelope({
|
||||
id: "req_unassigned",
|
||||
code: ERROR_CODES.parseError,
|
||||
message: "Invalid JSON body",
|
||||
data: {
|
||||
reason: error.message
|
||||
},
|
||||
context: {
|
||||
operation: "json_rpc.parse",
|
||||
target: {
|
||||
type: "http_route",
|
||||
id: request.url || "/rpc"
|
||||
},
|
||||
result: "rejected"
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rpcResponse = await handleJsonRpcRequest(envelope, {
|
||||
adapter: "json-rpc"
|
||||
});
|
||||
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
||||
}
|
||||
|
||||
async function handleRestAdapter(request, response, url, options) {
|
||||
if (request.method === "GET" && url.pathname === "/v1") {
|
||||
sendJson(response, 200, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
adapter: "rest",
|
||||
status: "skeleton",
|
||||
rpcBridge: "POST /v1/rpc/{method}",
|
||||
methods: SUPPORTED_RPC_METHODS,
|
||||
auditFields: AUDIT_FIELD_NAMES,
|
||||
db: {
|
||||
connected: false,
|
||||
mode: "not_configured"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) {
|
||||
sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 skeleton", {
|
||||
operation: `${request.method || "GET"} ${url.pathname}`,
|
||||
target: {
|
||||
type: "route",
|
||||
id: url.pathname
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const method = decodeURIComponent(url.pathname.slice("/v1/rpc/".length));
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
params = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendRestError(request, response, 400, "parse_error", "Invalid JSON body", {
|
||||
operation: method,
|
||||
target: {
|
||||
type: "rpc_method",
|
||||
id: method
|
||||
},
|
||||
result: "rejected",
|
||||
reason: error.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
sendRestError(request, response, 400, "invalid_params", "REST RPC bridge body must be a JSON object", {
|
||||
operation: method,
|
||||
target: {
|
||||
type: "rpc_method",
|
||||
id: method
|
||||
},
|
||||
result: "rejected"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rpcResponse = await handleJsonRpcRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: getHeader(request, "x-request-id") || `req_${randomUUID()}`,
|
||||
method,
|
||||
params,
|
||||
meta: {
|
||||
traceId: getHeader(request, "x-trace-id") || `trc_${randomUUID()}`,
|
||||
actorId: getHeader(request, "x-actor-id"),
|
||||
serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV
|
||||
}
|
||||
},
|
||||
{
|
||||
adapter: "rest"
|
||||
}
|
||||
);
|
||||
|
||||
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
||||
}
|
||||
|
||||
function sendRestError(request, response, statusCode, code, message, context = {}) {
|
||||
const requestId = getHeader(request, "x-request-id") || "req_unassigned";
|
||||
sendJson(response, statusCode, {
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
reason: context.reason,
|
||||
audit: createAuditRecord({
|
||||
requestId,
|
||||
actor: {
|
||||
type: "user",
|
||||
id: getHeader(request, "x-actor-id") || `system_${CLOUD_API_SERVICE_ID}`
|
||||
},
|
||||
source: {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
traceId: getHeader(request, "x-trace-id"),
|
||||
adapter: "rest"
|
||||
},
|
||||
operation: context.operation || `${request.method || "GET"} ${request.url || "/"}`,
|
||||
target: context.target || {
|
||||
type: "route",
|
||||
id: request.url || "/"
|
||||
},
|
||||
result: context.result || "failed"
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function statusForRpcResponse(rpcResponse) {
|
||||
if (!rpcResponse.error) {
|
||||
return 200;
|
||||
}
|
||||
|
||||
if (rpcResponse.error.code === ERROR_CODES.invalidRequest || rpcResponse.error.code === ERROR_CODES.parseError) {
|
||||
return 400;
|
||||
}
|
||||
|
||||
if (rpcResponse.error.code === ERROR_CODES.methodNotFound) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
return 200;
|
||||
}
|
||||
|
||||
function readBody(request, limitBytes = DEFAULT_BODY_LIMIT_BYTES) {
|
||||
const limit = Number.isInteger(limitBytes) && limitBytes > 0 ? limitBytes : DEFAULT_BODY_LIMIT_BYTES;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
request.setEncoding("utf8");
|
||||
|
||||
request.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
if (Buffer.byteLength(body, "utf8") > limit) {
|
||||
request.destroy(new Error(`request body exceeds ${limit} bytes`));
|
||||
}
|
||||
});
|
||||
|
||||
request.on("end", () => resolve(body));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, body) {
|
||||
response.writeHead(statusCode, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store"
|
||||
});
|
||||
response.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
function getHeader(request, name) {
|
||||
const value = request.headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createCloudApiServer } from "./server.mjs";
|
||||
|
||||
test("cloud api exposes /health and /live probes", async () => {
|
||||
const server = createCloudApiServer();
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
assert.equal(health.status, 200);
|
||||
assert.equal((await health.json()).serviceId, "hwlab-cloud-api");
|
||||
|
||||
const live = await fetch(`http://127.0.0.1:${port}/live`);
|
||||
assert.equal(live.status, 200);
|
||||
assert.equal((await live.json()).status, "live");
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
-- HWLAB L1 Cloud Core schema skeleton.
|
||||
-- This file is a contract placeholder only; it does not connect to or write a
|
||||
-- live database.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
gateway_service_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
started_at TEXT,
|
||||
ended_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS box_resources (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
gateway_session_id TEXT,
|
||||
resource_state TEXT NOT NULL DEFAULT 'available',
|
||||
labels_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS box_capabilities (
|
||||
id TEXT PRIMARY KEY,
|
||||
box_resource_id TEXT NOT NULL,
|
||||
capability_type TEXT NOT NULL,
|
||||
capability_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wiring_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
wiring_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS patch_panel_status (
|
||||
id TEXT PRIMARY KEY,
|
||||
gateway_session_id TEXT,
|
||||
wiring_config_id TEXT,
|
||||
status_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hardware_operations (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
requested_by TEXT,
|
||||
operation_type TEXT NOT NULL,
|
||||
operation_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'requested',
|
||||
requested_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
request_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
event_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
agent_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
started_at TEXT,
|
||||
ended_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_session_id TEXT,
|
||||
worker_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
started_at TEXT,
|
||||
ended_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_trace_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
trace_id TEXT NOT NULL,
|
||||
agent_session_id TEXT,
|
||||
worker_session_id TEXT,
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
event_json TEXT NOT NULL DEFAULT '{}',
|
||||
occurred_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidence_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
operation_id TEXT,
|
||||
evidence_type TEXT NOT NULL,
|
||||
uri TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TABLES } from "../protocol/index.mjs";
|
||||
|
||||
export const CLOUD_CORE_MIGRATION_ID = "0001_cloud_core_skeleton";
|
||||
|
||||
export const FROZEN_CLOUD_CORE_TABLES = Object.freeze([...TABLES]);
|
||||
|
||||
export const CLOUD_CORE_MIGRATIONS = Object.freeze([
|
||||
{
|
||||
id: CLOUD_CORE_MIGRATION_ID,
|
||||
path: "internal/db/migrations/0001_cloud_core_skeleton.sql",
|
||||
tables: FROZEN_CLOUD_CORE_TABLES,
|
||||
connectsToDatabase: false
|
||||
}
|
||||
]);
|
||||
|
||||
export function assertFrozenCloudCoreTables(tables = FROZEN_CLOUD_CORE_TABLES) {
|
||||
const missing = TABLES.filter((table) => !tables.includes(table));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`cloud core schema is missing frozen tables: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { TABLES } from "../protocol/index.mjs";
|
||||
import {
|
||||
CLOUD_CORE_MIGRATIONS,
|
||||
FROZEN_CLOUD_CORE_TABLES,
|
||||
assertFrozenCloudCoreTables
|
||||
} from "./schema.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
test("cloud core schema keeps the frozen L0 table names", () => {
|
||||
assert.deepEqual(FROZEN_CLOUD_CORE_TABLES, TABLES);
|
||||
assertFrozenCloudCoreTables();
|
||||
assert.equal(CLOUD_CORE_MIGRATIONS[0].connectsToDatabase, false);
|
||||
});
|
||||
|
||||
test("initial migration skeleton declares every frozen table", async () => {
|
||||
const sql = await readFile(path.join(repoRoot, "internal/db/migrations/0001_cloud_core_skeleton.sql"), "utf8");
|
||||
|
||||
for (const table of TABLES) {
|
||||
assert.match(sql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`), `missing ${table}`);
|
||||
}
|
||||
|
||||
assert.match(sql, /\brequest_id\b/);
|
||||
assert.match(sql, /\bactor\b/);
|
||||
assert.match(sql, /\bsource\b/);
|
||||
assert.match(sql, /\boperation\b/);
|
||||
assert.match(sql, /\btarget\b/);
|
||||
assert.match(sql, /\bresult\b/);
|
||||
assert.match(sql, /\btimestamp\b/);
|
||||
});
|
||||
Reference in New Issue
Block a user