diff --git a/cmd/hwlab-cloud-api/main.mjs b/cmd/hwlab-cloud-api/main.mjs new file mode 100644 index 00000000..b82ae35c --- /dev/null +++ b/cmd/hwlab-cloud-api/main.mjs @@ -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); + }); + }); +} diff --git a/internal/audit/index.mjs b/internal/audit/index.mjs new file mode 100644 index 00000000..55670acb --- /dev/null +++ b/internal/audit/index.mjs @@ -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); +} diff --git a/internal/audit/index.test.mjs b/internal/audit/index.test.mjs new file mode 100644 index 00000000..ccd8ca0d --- /dev/null +++ b/internal/audit/index.test.mjs @@ -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/ + ); +}); diff --git a/internal/cloud/json-rpc.mjs b/internal/cloud/json-rpc.mjs new file mode 100644 index 00000000..7629d9a4 --- /dev/null +++ b/internal/cloud/json-rpc.mjs @@ -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; +} diff --git a/internal/cloud/json-rpc.test.mjs b/internal/cloud/json-rpc.test.mjs new file mode 100644 index 00000000..3a7eaf12 --- /dev/null +++ b/internal/cloud/json-rpc.test.mjs @@ -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"); +}); diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs new file mode 100644 index 00000000..73aade84 --- /dev/null +++ b/internal/cloud/server.mjs @@ -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; +} diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs new file mode 100644 index 00000000..a2746e9c --- /dev/null +++ b/internal/cloud/server.test.mjs @@ -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())); + }); + } +}); diff --git a/internal/db/migrations/0001_cloud_core_skeleton.sql b/internal/db/migrations/0001_cloud_core_skeleton.sql new file mode 100644 index 00000000..b1039f9d --- /dev/null +++ b/internal/db/migrations/0001_cloud_core_skeleton.sql @@ -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 +); diff --git a/internal/db/schema.mjs b/internal/db/schema.mjs new file mode 100644 index 00000000..833b0732 --- /dev/null +++ b/internal/db/schema.mjs @@ -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(", ")}`); + } +} diff --git a/internal/db/schema.test.mjs b/internal/db/schema.test.mjs new file mode 100644 index 00000000..3b5306b5 --- /dev/null +++ b/internal/db/schema.test.mjs @@ -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/); +});