d6be46d313
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1978 lines
84 KiB
TypeScript
1978 lines
84 KiB
TypeScript
/*
|
|
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-20-p0-error-diagnostics; PJ2026-010401 Web工作台 draft-2026-06-20-p0-error-diagnostics; PJ2026-010405 云端控制台 draft-2026-07-13-p0-cloud-console; PJ2026-01060505 Workbench Performance draft-2026-06-20-p0-error-diagnostics
|
|
* 职责: Cloud API REST/SSE route dispatcher。Workbench 新资源路由应委托 read model / compat wrapper,不在 dispatcher 内写业务事实。
|
|
*/
|
|
import { createServer } from "node:http";
|
|
import { randomUUID } from "node:crypto";
|
|
import { copyFileSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
buildMetadataFromEnv,
|
|
normalizeIsoTimestamp
|
|
} from "../build-metadata.mjs";
|
|
import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES, SERVICE_IDS } from "../protocol/index.mjs";
|
|
import {
|
|
AUDIT_FIELD_NAMES,
|
|
CLOUD_API_SERVICE_ID,
|
|
createAuditRecord
|
|
} from "../audit/index.mjs";
|
|
import {
|
|
SUPPORTED_RPC_METHODS,
|
|
createErrorEnvelope,
|
|
createResultEnvelope,
|
|
handleJsonRpcRequest
|
|
} from "./json-rpc.ts";
|
|
import {
|
|
describeCodeAgentAvailability,
|
|
handleCodeAgentChat
|
|
} from "./code-agent-chat.ts";
|
|
import { createDurableCodeAgentTraceStore, defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
|
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.ts";
|
|
import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.ts";
|
|
import { createCodexStdioSessionManager } from "./codex-stdio-session.ts";
|
|
import { buildGateDiagnosticsRows } from "./gate-diagnostics.ts";
|
|
import {
|
|
applyRuntimeDbReadinessLayers,
|
|
buildDbRuntimeReadiness
|
|
} from "./db-contract.ts";
|
|
import { buildCloudApiReadiness } from "./health-contract.ts";
|
|
import {
|
|
M3_IO_CONTROL_ROUTE,
|
|
M3_STATUS_ROUTE,
|
|
describeM3IoControl,
|
|
describeM3IoControlLive,
|
|
describeM3StatusLive,
|
|
handleM3IoControl
|
|
} from "./m3-io-control.ts";
|
|
import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.ts";
|
|
import { createAccessController } from "./access-control.ts";
|
|
import { createGatewayDemoRegistry } from "./gateway-demo-registry.ts";
|
|
import { buildLiveBuildsPayload } from "./server-rest-payloads.ts";
|
|
import {
|
|
createWebPerformanceStore,
|
|
handleWebPerformanceIngestHttp,
|
|
handleWebPerformanceMetricsHttp,
|
|
handleWebPerformanceSummaryHttp
|
|
} from "./web-performance.ts";
|
|
import { createBackendPerformanceStore, withBackendPerformanceContext } from "./backend-performance.ts";
|
|
import {
|
|
createCodeAgentChatResultStore,
|
|
handleCodeAgentCancelHttp,
|
|
handleCodeAgentChatHttp,
|
|
handleCodeAgentChatResultHttp,
|
|
handleCodeAgentInspectHttp,
|
|
handleCodeAgentSessionsHttp,
|
|
handleWorkbenchTurnAdmissionHttp,
|
|
handleCodeAgentSteerHttp,
|
|
handleCodeAgentTurnHttp,
|
|
handleCodeAgentTraceHttp
|
|
} from "./server-code-agent-http.ts";
|
|
import { drainWorkbenchRealtimeConnections, handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-http.ts";
|
|
import { handleWorkbenchDebugFakeSseHttp } from "./workbench-debug-fake-sse.ts";
|
|
import { handleWorkbenchKafkaSseDebugHttp } from "./workbench-kafka-sse-debug.ts";
|
|
import { handleWorkbenchLaunchHttp } from "./server-workbench-launch-http.ts";
|
|
import { handleAgentObserverHttp } from "./server-agent-observer-http.ts";
|
|
import { handleAgentObserverRunsHttp } from "./server-agent-observer-runs-http.ts";
|
|
import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts";
|
|
import { startHwlabKafkaEventBridge } from "./kafka-event-bridge.ts";
|
|
import { handleM3IoControlHttp } from "./server-m3-http.ts";
|
|
import { handleSkillsHttp } from "./server-skills-http.ts";
|
|
import { emitHttpServerRequestSpan, httpRequestOtelTraceContext, newOtelSpanId } from "./otel-trace.ts";
|
|
import { decorateErrorPayloadForHttp, logErrorEnvelope } from "./error-envelope.ts";
|
|
import { handleProviderProfileCatalogHttp, handleProviderProfilesHttp } from "./provider-profile-management.ts";
|
|
import { handleAdminSecretsHttp } from "./admin-secrets-readmodel.ts";
|
|
import {
|
|
configureSkillRuntime,
|
|
ensureCodexSkillsAggregationSync
|
|
} from "./skills-store.ts";
|
|
import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.ts";
|
|
import {
|
|
handleHwlabNodeDownloadHttp,
|
|
handleHwlabNodeUpdateHttp,
|
|
handleHwpodNodeOpsHttp,
|
|
handleHwpodSpecDiscoveryHttp,
|
|
handleHwpodTopologyHttp
|
|
} from "./server-hwpod-http.ts";
|
|
import { HWPOD_TOPOLOGY_CONTRACT_VERSION } from "./hwpod-topology-read-model.ts";
|
|
import { handleCaseRunHttp } from "./server-caserun-http.ts";
|
|
import { handleProjectManagementProxyHttp } from "./project-management-proxy.ts";
|
|
import { handleTaskTreeProxyHttp } from "./tasktree-proxy.ts";
|
|
import { handleDashboardSummaryHttp } from "./server-dashboard-http.ts";
|
|
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
|
|
import { proxyWorkbenchCommandHttp, workbenchActivityDispatchRequested, workbenchTemporalProxyEnabled } from "./workbench-command-proxy.ts";
|
|
|
|
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
const HWLAB_WEB_SESSION_COOKIE = "hwlab_session";
|
|
|
|
function runtimeEnvironment(env = process.env) {
|
|
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
|
|
return typeof value === "string" && value.trim() ? value.trim() : ENVIRONMENT_DEV;
|
|
}
|
|
|
|
async function observeHttpRoutePhase(request, options, phase, operation, attributes = {}) {
|
|
const startedAtMs = Date.now();
|
|
try {
|
|
const value = await operation();
|
|
emitHttpRoutePhaseSpan(request, options, phase, startedAtMs, Date.now(), "ok", null, attributes);
|
|
return value;
|
|
} catch (error) {
|
|
emitHttpRoutePhaseSpan(request, options, phase, startedAtMs, Date.now(), "error", error, attributes);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function createHttpRoutePhaseObserver(request, options, namespace) {
|
|
return (phase, operation, attributes = {}) => observeHttpRoutePhase(request, options, `${namespace}.${phase}`, operation, attributes);
|
|
}
|
|
|
|
function emitHttpRoutePhaseSpan(request, options, phase, startTimeMs, endTimeMs, outcome, error, attributes = {}) {
|
|
const context = request?.hwlabHttpRequestContext;
|
|
if (!context?.traceId) return;
|
|
const errorMessage = error ? String(error?.message ?? error).slice(0, 300) : null;
|
|
void emitHttpServerRequestSpan(context, options?.env ?? process.env, {
|
|
name: `${String(context.method ?? "GET").toUpperCase()} ${String(context.route ?? "/")} ${phase}`,
|
|
method: context.method,
|
|
route: context.route,
|
|
parentSpanId: context.spanId,
|
|
spanId: newOtelSpanId(),
|
|
startTimeMs,
|
|
endTimeMs,
|
|
statusCode: outcome === "error" ? 500 : 200,
|
|
status: outcome === "error" ? "error" : "ok",
|
|
statusMessage: errorMessage,
|
|
errorCode: error ? phaseErrorCode(error) : null,
|
|
attributes: {
|
|
"hwlab.http.stage": "http.server.phase",
|
|
"hwlab.http.phase": phase,
|
|
"hwlab.http.phase.outcome": outcome,
|
|
...attributes
|
|
}
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
function phaseErrorCode(error) {
|
|
return String(error?.code ?? error?.name ?? "phase_error").slice(0, 120);
|
|
}
|
|
|
|
export function createCloudApiServer(options = {}) {
|
|
const env = options.env ?? process.env;
|
|
ensureCodeAgentRuntimeBase(env);
|
|
const sessionRegistry = options.sessionRegistry || createCodeAgentSessionRegistry();
|
|
const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env });
|
|
const traceStore = createDurableCodeAgentTraceStore({
|
|
traceStore: options.traceStore || defaultCodeAgentTraceStore,
|
|
traceEventStore: runtimeStore
|
|
});
|
|
const codexStdioManager = options.codexStdioManager || createCodexStdioSessionManager({ traceStore });
|
|
const codeAgentChatResults = options.codeAgentChatResults || createCodeAgentChatResultStore({
|
|
maxResults: parsePositiveInteger(env.HWLAB_CODE_AGENT_CHAT_RESULT_CACHE_SIZE, 256)
|
|
});
|
|
const webPerformanceStore = options.webPerformanceStore || createWebPerformanceStore({ env });
|
|
const backendPerformanceStore = options.backendPerformanceStore || createBackendPerformanceStore({ env });
|
|
const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({
|
|
staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000),
|
|
dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 120000)
|
|
});
|
|
const hwpodNodeWsRegistry = options.hwpodNodeWsRegistry || createHwpodNodeWsRegistry({ ...options, env, runtimeStore });
|
|
const accessController = options.accessController || createAccessController({ ...options, env, runtimeStore, gatewayRegistry, traceStore, codeAgentChatResults });
|
|
if (typeof accessController.configureCodeAgentWorkspaceContext === "function") {
|
|
accessController.configureCodeAgentWorkspaceContext({ env, fetchImpl: options.fetchImpl, traceStore, codeAgentChatResults });
|
|
}
|
|
const workbenchEmptySessionGc = startWorkbenchEmptySessionGc({ env, accessController, logger: options.logger ?? console });
|
|
const kafkaEventBridge = options.kafkaEventBridge ?? startHwlabKafkaEventBridge({ env, logger: options.logger ?? console, runtimeStore, kafkaFactory: options.kafkaFactory });
|
|
const server = createServer(async (request, response) => {
|
|
const requestContext = buildHttpRequestContext(request);
|
|
attachHttpRequestContext(request, response, requestContext);
|
|
const backendPerformance = backendPerformanceStore.beginHttpRequest(request, response);
|
|
let otelHttpSpanEmitted = false;
|
|
const emitHttpSpan = (closedBy) => {
|
|
if (otelHttpSpanEmitted) return;
|
|
otelHttpSpanEmitted = true;
|
|
const httpCtx = request.hwlabHttpRequestContext;
|
|
if (!httpCtx?.traceId) return;
|
|
if (isOtelNoisyProbeRoute(httpCtx.route)) return;
|
|
const lastError = httpCtx.lastHttpError;
|
|
void emitHttpServerRequestSpan(httpCtx, env, {
|
|
startTimeMs: httpCtx.startedAtMs,
|
|
endTimeMs: Date.now(),
|
|
statusCode: Number(response.statusCode ?? backendPerformance.statusCode ?? 0),
|
|
method: backendPerformance.method || httpCtx.method,
|
|
route: backendPerformance.route || httpCtx.route,
|
|
attributes: {
|
|
"http.closed_by": closedBy,
|
|
...(httpCtx.otelAttributes ?? {}),
|
|
"http.response.body.size": Number(backendPerformance.responseBytes ?? 0),
|
|
"hwlab.backend.phase_count": Array.isArray(backendPerformance.phases) ? backendPerformance.phases.length : 0
|
|
},
|
|
errorCode: lastError?.code,
|
|
errorCategory: lastError?.category,
|
|
errorLayer: lastError?.layer,
|
|
statusMessage: lastError?.code
|
|
}).catch(() => undefined);
|
|
};
|
|
response.once("finish", () => emitHttpSpan("finish"));
|
|
response.once("close", () => emitHttpSpan("close"));
|
|
await withBackendPerformanceContext(backendPerformance, async () => {
|
|
try {
|
|
await routeRequest(request, response, { ...options, env, runtimeStore, kafkaEventBridge, gatewayRegistry, hwpodNodeWsRegistry, accessController, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults, webPerformanceStore, backendPerformanceStore, backendPerformance, emitHttpRoutePhaseSpan });
|
|
} catch (error) {
|
|
if (error?.alreadySent || response.headersSent || response.writableEnded) return;
|
|
backendPerformance.recordPhase({ phase: "route_exception", durationMs: 0, outcome: "error" });
|
|
sendJson(response, 500, {
|
|
error: {
|
|
code: "internal_error",
|
|
message: "hwlab-cloud-api request handling failed",
|
|
reason: error.message
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
server.on("close", () => {
|
|
workbenchEmptySessionGc.stop();
|
|
void kafkaEventBridge.stop();
|
|
});
|
|
server.hwlabStartupReady = kafkaEventBridge?.ready ?? Promise.resolve();
|
|
server.hwlabAbortStartup = async () => { workbenchEmptySessionGc.stop(); await kafkaEventBridge.stop(); };
|
|
return server;
|
|
}
|
|
|
|
function buildHttpRequestContext(request) {
|
|
const otelContext = httpRequestOtelTraceContext({
|
|
traceparent: getHeader(request, "traceparent"),
|
|
traceId: getHeader(request, "x-hwlab-otel-trace-id")
|
|
});
|
|
return {
|
|
requestId: safeRequestId(getHeader(request, "x-request-id")) || `req_${randomUUID()}`,
|
|
startedAtMs: Date.now(),
|
|
traceId: otelContext.traceId,
|
|
parentSpanId: otelContext.parentSpanId,
|
|
spanId: otelContext.spanId,
|
|
traceparent: otelContext.traceparent,
|
|
method: String(request.method || "GET").toUpperCase(),
|
|
route: String(request.url || "/").split("?")[0] || "/",
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
function isOtelNoisyProbeRoute(route) {
|
|
const path = String(route ?? "/").split("?")[0] || "/";
|
|
return path === "/metrics" || path.startsWith("/health");
|
|
}
|
|
|
|
function attachHttpRequestContext(request, response, context) {
|
|
request.hwlabHttpRequestContext = context;
|
|
response.hwlabHttpRequestContext = context;
|
|
if (response.headersSent || response.writableEnded) return;
|
|
response.setHeader("traceparent", context.traceparent);
|
|
response.setHeader("x-hwlab-otel-trace-id", context.traceId);
|
|
response.setHeader("x-request-id", context.requestId);
|
|
}
|
|
|
|
export function ensureCodeAgentRuntimeBase(env = process.env) {
|
|
const codeAgentAdapter = String(env.HWLAB_CODE_AGENT_ADAPTER ?? env.HWLAB_CODE_AGENT_PROVIDER ?? "").trim().toLowerCase();
|
|
const useCodexStdioRuntime = !codeAgentAdapter || codeAgentAdapter === "codex-stdio";
|
|
const skillRuntime = configureSkillRuntime(env);
|
|
if (!useCodexStdioRuntime) return;
|
|
|
|
const workspace = env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab";
|
|
const codexHome = env.CODEX_HOME || env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home";
|
|
const appCodexCommand = "/app/node_modules/.bin/codex";
|
|
const localCodexCommand = path.join(process.cwd(), "node_modules", ".bin", "codex");
|
|
const codexCommand = existsSync(appCodexCommand)
|
|
? appCodexCommand
|
|
: existsSync(localCodexCommand)
|
|
? localCodexCommand
|
|
: appCodexCommand;
|
|
env.HWLAB_CODE_AGENT_WORKSPACE ||= workspace;
|
|
env.HWLAB_CODE_AGENT_CODEX_WORKSPACE ||= workspace;
|
|
env.HWLAB_CODE_AGENT_CODEX_SANDBOX ||= "danger-full-access";
|
|
env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED ||= "1";
|
|
env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR ||= "repo-owned";
|
|
env.CODEX_HOME ||= codexHome;
|
|
env.HWLAB_CODE_AGENT_CODEX_COMMAND ||= codexCommand;
|
|
|
|
for (const target of [path.dirname(workspace), codexHome]) {
|
|
try {
|
|
mkdirSync(target, { recursive: true });
|
|
} catch {
|
|
// Runtime health reports the concrete missing/writable blocker.
|
|
}
|
|
}
|
|
try {
|
|
if (!existsSync(workspace)) {
|
|
if (workspace === "/workspace/hwlab" && existsSync("/app")) {
|
|
symlinkSync("/app", workspace, "dir");
|
|
} else {
|
|
mkdirSync(workspace, { recursive: true });
|
|
}
|
|
}
|
|
lstatSync(workspace);
|
|
ensurePreinstalledHwpodSpec({ workspace });
|
|
ensureCodexSkillsAggregationSync(skillRuntime);
|
|
} catch {
|
|
// Runtime health reports the concrete missing/writable blocker.
|
|
}
|
|
}
|
|
|
|
function ensurePreinstalledHwpodSpec({ workspace }) {
|
|
const source = path.resolve(process.cwd(), ".hwlab/hwpod-spec.yaml");
|
|
const target = path.join(workspace, ".hwlab/hwpod-spec.yaml");
|
|
const sourceMeta = path.resolve(process.cwd(), ".hwlab/hwpod-spec.meta.json");
|
|
const targetMeta = path.join(workspace, ".hwlab/hwpod-spec.meta.json");
|
|
if (!existsSync(source)) return;
|
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
if (!existsSync(target)) copyFileSync(source, target);
|
|
if (existsSync(sourceMeta) && !existsSync(targetMeta)) copyFileSync(sourceMeta, targetMeta);
|
|
}
|
|
|
|
export async function buildHealthPayload(options = {}) {
|
|
const serviceId = CLOUD_API_SERVICE_ID;
|
|
const env = options.env ?? process.env;
|
|
const healthMode = options.healthMode === "live" ? "live" : "readiness";
|
|
const liveProbe = healthMode === "live";
|
|
ensureCodeAgentRuntimeBase(env);
|
|
const metadata = buildMetadataFromEnv(env, {
|
|
serviceId,
|
|
fallbackImageRepository: "ghcr.io/pikastech/hwlab-cloud-api"
|
|
});
|
|
const runtimeStore = options.runtimeStore ?? createConfiguredCloudRuntimeStore({ ...options, env });
|
|
const dbProbe = await buildDbRuntimeReadiness(env, liveProbe ? { ...(options.dbProbe ?? {}), probe: false } : options.dbProbe);
|
|
const codeAgent = await describeCodeAgentAvailability(env, options);
|
|
const runtime = liveProbe ? runtimeReadinessSnapshot(runtimeStore) : await runtimeReadiness(runtimeStore);
|
|
const kafkaProjector = await kafkaProjectorHealth(options.kafkaEventBridge, { liveProbe });
|
|
const db = applyRuntimeDbReadinessLayers(dbProbe, runtime);
|
|
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime, kafkaProjector });
|
|
|
|
return {
|
|
serviceId,
|
|
environment: runtimeEnvironment(env),
|
|
healthMode,
|
|
status: readiness.status,
|
|
ready: readiness.ready,
|
|
revision: metadata.revision,
|
|
readiness,
|
|
blockers: readiness.blockers,
|
|
blockerCodes: readiness.blockerCodes,
|
|
service: {
|
|
id: serviceId,
|
|
role: "cloud-api",
|
|
healthPath: "/health",
|
|
livePath: "/health/live"
|
|
},
|
|
commit: metadata.commit,
|
|
image: metadata.image,
|
|
build: metadata.build,
|
|
runtimeIdentity: metadata.runtime,
|
|
endpoint: env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT,
|
|
observedAt: new Date().toISOString(),
|
|
db,
|
|
codeAgent,
|
|
runtime,
|
|
kafkaProjector
|
|
};
|
|
}
|
|
|
|
async function kafkaProjectorHealth(projector, { liveProbe = false } = {}) {
|
|
if (!projector?.started) return { started: false, reason: projector?.reason ?? "disabled", valuesRedacted: true };
|
|
const identity = {
|
|
capabilities: projector.capabilities ?? null,
|
|
directPublishGroupId: projector.directPublishGroupId ?? null,
|
|
projectorGroupId: projector.projectorGroupId ?? null,
|
|
hwlabEventGroupId: projector.hwlabEventGroupId ?? null,
|
|
agentRunTopic: projector.agentRunTopic,
|
|
hwlabTopic: projector.hwlabTopic
|
|
};
|
|
if (liveProbe) return { started: true, ...identity, ...(projector.startupStatus?.() ?? { status: "initializing" }), statusSource: "startup-state", valuesRedacted: true };
|
|
try {
|
|
return { started: true, ...identity, ...(await projector.status()), valuesRedacted: true };
|
|
} catch (error) {
|
|
return { started: true, status: "blocked", errorCode: error?.code ?? "hwlab_kafka_projector_status_failed", message: error?.message ?? "Kafka projector status query failed.", valuesRedacted: true };
|
|
}
|
|
}
|
|
|
|
function runtimeReadinessSnapshot(runtimeStore) {
|
|
if (runtimeStore && typeof runtimeStore.summary === "function") {
|
|
try {
|
|
const summary = runtimeStore.summary();
|
|
return {
|
|
...summary,
|
|
readinessFresh: false,
|
|
readinessSource: "cached-summary"
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
adapter: "unknown",
|
|
durable: false,
|
|
ready: false,
|
|
status: "degraded",
|
|
blocker: "runtime_readiness_snapshot_failed",
|
|
reason: error instanceof Error ? error.message : "Runtime readiness snapshot failed",
|
|
readinessFresh: false,
|
|
readinessSource: "cached-summary",
|
|
valuesRedacted: true,
|
|
endpointRedacted: true
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
adapter: "unknown",
|
|
durable: false,
|
|
ready: false,
|
|
status: "degraded",
|
|
blocker: "runtime_readiness_snapshot_unavailable",
|
|
reason: "Runtime store does not expose a cached summary for /health/live",
|
|
readinessFresh: false,
|
|
readinessSource: "cached-summary",
|
|
valuesRedacted: true,
|
|
endpointRedacted: true
|
|
};
|
|
}
|
|
|
|
async function routeRequest(request, response, options) {
|
|
const url = new URL(request.url || "/", "http://hwlab-cloud-api.local");
|
|
|
|
if (request.method === "GET" && url.pathname === "/health/live") {
|
|
sendJson(response, 200, await buildHealthPayload({ ...options, healthMode: "live" }));
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
sendJson(response, 200, await buildHealthPayload({ ...options, healthMode: "readiness" }));
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/live") {
|
|
sendJson(response, 200, {
|
|
serviceId: CLOUD_API_SERVICE_ID,
|
|
status: "live"
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/auth" || url.pathname.startsWith("/auth/")) {
|
|
await options.accessController.handleAuthRoute(request, response, url);
|
|
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 runtime", {
|
|
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",
|
|
runtimeStore: options.runtimeStore,
|
|
env: options.env,
|
|
dbProbe: options.dbProbe,
|
|
m3IoRequestJson: options.m3IoRequestJson,
|
|
gatewayRegistry: options.gatewayRegistry
|
|
});
|
|
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
|
}
|
|
|
|
async function handleRestAdapter(request, response, url, options) {
|
|
const navId = navIdForRestPath(url.pathname, request.method || "GET");
|
|
if (navId && typeof options.accessController?.requireNavAccess === "function" && !(await options.accessController.requireNavAccess(request, response, navId))) return;
|
|
|
|
if (url.pathname === "/v1/web-performance" && request.method === "POST") {
|
|
await handleWebPerformanceIngestHttp(request, response, { store: options.webPerformanceStore, env: options.env });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/web-performance/metrics" && request.method === "GET") {
|
|
handleWebPerformanceMetricsHttp(request, response, { store: options.webPerformanceStore, extraMetricsText: () => options.backendPerformanceStore.metricsText() });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/web-performance/summary" && request.method === "GET") {
|
|
handleWebPerformanceSummaryHttp(request, response, { store: options.webPerformanceStore, backendPerformanceStore: options.backendPerformanceStore });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/dashboard/summary") {
|
|
await handleDashboardSummaryHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if ((url.pathname === "/v1/usage/summary" || url.pathname === "/v1/billing/summary") && request.method === "GET") {
|
|
await handleUsageSummaryHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/redeem" && request.method === "POST") {
|
|
await handleRedeemHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/subscription/summary" && request.method === "GET") {
|
|
await handleSubscriptionSummaryHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/payments/summary" && request.method === "GET") {
|
|
await handlePaymentSummaryHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/billing/summary" && request.method === "GET") {
|
|
await handleAdminBillingSummaryHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/billing/plans" && request.method === "GET") {
|
|
await handleAdminBillingPlansHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/billing/usage" && request.method === "GET") {
|
|
await handleAdminBillingUsageHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/billing/usage/export" && request.method === "GET") {
|
|
await handleAdminBillingUsageExportHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/billing/ledger" && request.method === "GET") {
|
|
await handleAdminBillingLedgerHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/billing/ledger/export" && request.method === "GET") {
|
|
await handleAdminBillingLedgerExportHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/billing/redeem-codes" && (request.method === "GET" || request.method === "POST")) {
|
|
await handleAdminBillingRedeemCodesHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
const adminBillingRedeemCodeMatch = url.pathname.match(/^\/v1\/admin\/billing\/redeem-codes\/([^/]+)$/u);
|
|
if (adminBillingRedeemCodeMatch && request.method === "PATCH") {
|
|
await handleAdminBillingRedeemCodeHttp(request, response, decodeURIComponent(adminBillingRedeemCodeMatch[1]), options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/billing/redeem-redemptions" && request.method === "GET") {
|
|
await handleAdminBillingRedeemRedemptionsHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/billing/users" && (request.method === "GET" || request.method === "POST")) {
|
|
await handleAdminBillingUsersHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
const adminBillingUserMatch = url.pathname.match(/^\/v1\/admin\/billing\/users\/([^/]+)$/u);
|
|
if (adminBillingUserMatch && (request.method === "GET" || request.method === "PATCH")) {
|
|
await handleAdminBillingUserHttp(request, response, url, decodeURIComponent(adminBillingUserMatch[1]), options);
|
|
return;
|
|
}
|
|
|
|
const adminBillingUserCreditsMatch = url.pathname.match(/^\/v1\/admin\/billing\/users\/([^/]+)\/credits\/adjust$/u);
|
|
if (adminBillingUserCreditsMatch && request.method === "POST") {
|
|
await handleAdminBillingCreditAdjustHttp(request, response, decodeURIComponent(adminBillingUserCreditsMatch[1]), options);
|
|
return;
|
|
}
|
|
|
|
const adminBillingUserStatusMatch = url.pathname.match(/^\/v1\/admin\/billing\/users\/([^/]+)\/status$/u);
|
|
if (adminBillingUserStatusMatch && request.method === "PATCH") {
|
|
await handleAdminBillingUserStatusHttp(request, response, decodeURIComponent(adminBillingUserStatusMatch[1]), options);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/v1") {
|
|
const dbProbeOptions = { ...(options.dbProbe ?? {}), timeoutMs: 500 };
|
|
const [dbProbe, codeAgent, runtime] = await Promise.all([
|
|
observeHttpRoutePhase(request, options, "v1.db_readiness", () => buildDbRuntimeReadiness(options.env ?? process.env, dbProbeOptions)),
|
|
observeHttpRoutePhase(request, options, "v1.code_agent_availability", () => describeCodeAgentAvailability(options.env ?? process.env, options)),
|
|
observeHttpRoutePhase(request, options, "v1.runtime_readiness", () => runtimeReadinessSnapshot(options.runtimeStore))
|
|
]);
|
|
const db = applyRuntimeDbReadinessLayers(dbProbe, runtime);
|
|
const kafkaProjector = await kafkaProjectorHealth(options.kafkaEventBridge, { liveProbe: false });
|
|
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime, kafkaProjector });
|
|
sendJson(response, 200, {
|
|
serviceId: CLOUD_API_SERVICE_ID,
|
|
adapter: "rest",
|
|
status: readiness.status,
|
|
ready: readiness.ready,
|
|
rpcBridge: "POST /v1/rpc/{method}",
|
|
codeAgent,
|
|
methods: SUPPORTED_RPC_METHODS,
|
|
auditFields: AUDIT_FIELD_NAMES,
|
|
db,
|
|
runtime,
|
|
readiness,
|
|
blockers: readiness.blockers,
|
|
blockerCodes: readiness.blockerCodes,
|
|
caserun: {
|
|
route: "/v1/caserun",
|
|
casesRoute: "/v1/caserun/cases",
|
|
runsRoute: "/v1/caserun/runs",
|
|
contractVersion: "hwlab-caserun-web-v1",
|
|
mode: "compile-only",
|
|
caseAuthority: "builtin-or-configured-case-repo"
|
|
},
|
|
hwpod: {
|
|
route: "/v1/hwpod-node-ops",
|
|
specDiscoveryRoute: "/v1/hwpod/specs",
|
|
topologyRoute: "/v1/hwpod/topology",
|
|
topologyContractVersion: HWPOD_TOPOLOGY_CONTRACT_VERSION,
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
specAuthority: "workspace-or-registry",
|
|
compiler: "hwpod-compiler-cli",
|
|
apiRole: "node-ops-forwarder",
|
|
nodeRole: "thin-hwpod-node-executor",
|
|
supportedOps: Array.from(HWPOD_NODE_OPS)
|
|
},
|
|
projectManagement: {
|
|
route: "/v1/project-management",
|
|
serviceId: "hwlab-project-management",
|
|
contractVersion: "project-management-v1",
|
|
sourceOfTruth: "mdtodo-markdown-files"
|
|
},
|
|
hwlabNode: {
|
|
updateRoute: "/v1/hwlab-node/update",
|
|
downloadRoute: "/v1/hwlab-node/download/hwlab-node.py",
|
|
diagnosticsOp: "node.diagnostics",
|
|
updateContractVersion: "hwlab-node-update-v1",
|
|
defaultUpdateIntervalSeconds: 300
|
|
},
|
|
m3IoControl: describeM3IoControl(options)
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/hwlab-node/update") {
|
|
handleHwlabNodeUpdateHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/hwlab-node/download/hwlab-node.py") {
|
|
handleHwlabNodeDownloadHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/auth/session" || url.pathname === "/v1/auth/login" || url.pathname === "/v1/auth/logout" || url.pathname === "/v1/auth/register") {
|
|
const authUrl = new URL(url.href);
|
|
authUrl.pathname = url.pathname.replace(/^\/v1\/auth/u, "/auth");
|
|
await options.accessController.handleAuthRoute(request, response, authUrl);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/skills" || url.pathname.startsWith("/v1/skills/")) {
|
|
await handleSkillsHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/users/me") {
|
|
await options.accessController.handleUserRoute(request, response, url);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/project-management" || url.pathname.startsWith("/v1/project-management/")) {
|
|
await handleProjectManagementProxyHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
if (url.pathname === "/v1/tasktree" || url.pathname.startsWith("/v1/tasktree/")) {
|
|
await handleTaskTreeProxyHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/users/me/profile" && request.method === "PATCH") {
|
|
await handleUserBillingProfileHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/users/me/password" && request.method === "POST") {
|
|
await handleUserBillingPasswordHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/api-keys" || url.pathname === "/v1/api-keys/default" || url.pathname.startsWith("/v1/api-keys/")) {
|
|
if (await maybeHandleUserBillingApiKeysHttp(request, response, url, options)) return;
|
|
await options.accessController.handleUserRoute(request, response, url);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/internal/workbench/drain") {
|
|
if (request.method !== "POST") {
|
|
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "POST required." } });
|
|
return;
|
|
}
|
|
if (!isAuthorizedWorkbenchDrainRequest(request, options.env ?? process.env)) {
|
|
sendJson(response, 403, { ok: false, error: { code: "workbench_drain_forbidden", message: "Workbench drain endpoint requires pod-local preStop authorization." } });
|
|
return;
|
|
}
|
|
const result = await drainWorkbenchRealtimeConnections({
|
|
reason: "k8s_prestop",
|
|
signal: "SIGTERM",
|
|
timeoutMs: parsePositiveInteger((options.env ?? process.env).HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS, 2500),
|
|
env: options.env ?? process.env
|
|
});
|
|
sendJson(response, 200, { ok: true, route: "/v1/internal/workbench/drain", result });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/workbench/events" || url.pathname === "/v1/workbench/projection-events") {
|
|
await handleWorkbenchRealtimeHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/agent-observer/events") {
|
|
await handleAgentObserverHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/agent-observer/runs") {
|
|
await handleAgentObserverRunsHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/workbench/launches") {
|
|
await handleWorkbenchLaunchHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/workbench/debug/fake-sse" || url.pathname.startsWith("/v1/workbench/debug/fake-sse/")) {
|
|
await handleWorkbenchDebugFakeSseHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/workbench/debug/kafka-sse" || url.pathname.startsWith("/v1/workbench/debug/kafka-sse/")) {
|
|
await handleWorkbenchKafkaSseDebugHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/workbench/sessions" || url.pathname.startsWith("/v1/workbench/sessions/") || url.pathname.startsWith("/v1/workbench/turns/") || url.pathname.startsWith("/v1/workbench/traces/")) {
|
|
await handleWorkbenchReadModelHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/access/status" || url.pathname === "/v1/setup/status") {
|
|
await options.accessController.handleAccessStatusRoute(request, response, url);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/setup/first-admin") {
|
|
await options.accessController.handleSetupRoute(request, response, url);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/provider-profiles" || url.pathname.startsWith("/v1/admin/provider-profiles/")) {
|
|
await handleProviderProfilesHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/admin/secrets" || url.pathname.startsWith("/v1/admin/secrets/")) {
|
|
await handleAdminSecretsHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/provider-profiles" && request.method === "GET") {
|
|
await handleProviderProfileCatalogHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith("/v1/admin/")) {
|
|
await options.accessController.handleAdminRoute(request, response, url);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/hwpod-node-ops") {
|
|
await handleHwpodNodeOpsHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/caserun" || url.pathname.startsWith("/v1/caserun/")) {
|
|
await handleCaseRunHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/v1/hwpod/specs") {
|
|
await handleHwpodSpecDiscoveryHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/hwpod/topology") {
|
|
await handleHwpodTopologyHttp(request, response, url, options);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/v1/hwpod-node/ws") {
|
|
sendJson(response, 426, { ok: false, status: "upgrade_required", route: "/v1/hwpod-node/ws", websocket: options.hwpodNodeWsRegistry.describe() });
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
|
sendJson(response, 200, await describeM3IoControlLive(options));
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === M3_STATUS_ROUTE) {
|
|
sendJson(response, 200, await describeM3StatusLive(options));
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/v1/diagnostics/gate") {
|
|
sendJson(response, 200, await buildGateDiagnosticsRows(options));
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/v1/live-builds") {
|
|
sendJson(response, 200, await buildLiveBuildsPayload({
|
|
...options,
|
|
observeLiveBuildPhase: createHttpRoutePhaseObserver(request, options, "live_builds")
|
|
}, { buildHealthPayload, runtimeEnvironment }));
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/v1/gateway/sessions") {
|
|
sendJson(response, 200, options.gatewayRegistry.describe());
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && url.pathname === "/v1/gateway/poll") {
|
|
await handleGatewayPollHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && url.pathname === "/v1/gateway/result") {
|
|
await handleGatewayResultHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
|
await handleM3IoControlHttp(request, response, options);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && url.pathname === "/v1/agent/chat") {
|
|
const nextOptions = await codeAgentOptions(request, response, options);
|
|
if (!nextOptions) return;
|
|
if (workbenchTemporalProxyEnabled(nextOptions.env) && !workbenchActivityDispatchRequested(request)) {
|
|
await proxyWorkbenchCommandHttp(request, response, nextOptions, url.pathname);
|
|
return;
|
|
}
|
|
await handleCodeAgentChatHttp(request, response, nextOptions);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && url.pathname === "/v1/internal/workbench/admit") {
|
|
if (!workbenchActivityDispatchRequested(request)) {
|
|
sendJson(response, 404, { ok: false, error: { code: "not_found", message: "REST route is not implemented" } });
|
|
return;
|
|
}
|
|
const nextOptions = await codeAgentOptions(request, response, options, { required: true });
|
|
if (!nextOptions) return;
|
|
await handleWorkbenchTurnAdmissionHttp(request, response, nextOptions);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/agent/sessions" || url.pathname.startsWith("/v1/agent/sessions/")) {
|
|
const nextOptions = await codeAgentOptions(request, response, options, { required: true });
|
|
if (!nextOptions) return;
|
|
if (request.method === "POST" && url.pathname === "/v1/agent/sessions" && workbenchTemporalProxyEnabled(nextOptions.env) && !workbenchActivityDispatchRequested(request)) {
|
|
await proxyWorkbenchCommandHttp(request, response, nextOptions, url.pathname);
|
|
return;
|
|
}
|
|
await handleCodeAgentSessionsHttp(request, response, url, nextOptions);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && url.pathname === "/v1/workbench/commands") {
|
|
const nextOptions = await codeAgentOptions(request, response, options, { required: true });
|
|
if (!nextOptions) return;
|
|
await proxyWorkbenchCommandHttp(request, response, nextOptions, url.pathname);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/v1/agent/chat/inspect") {
|
|
const nextOptions = await codeAgentOptions(request, response, options);
|
|
if (!nextOptions) return;
|
|
await handleCodeAgentInspectHttp(request, response, url, nextOptions);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/result/")) {
|
|
const nextOptions = await codeAgentOptions(request, response, options);
|
|
if (!nextOptions) return;
|
|
await handleCodeAgentChatResultHttp(request, response, url, nextOptions);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname.startsWith("/v1/agent/turns/")) {
|
|
const nextOptions = await codeAgentOptions(request, response, options);
|
|
if (!nextOptions) return;
|
|
await handleCodeAgentTurnHttp(request, response, url, nextOptions);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname.startsWith("/v1/agent/traces/")) {
|
|
const nextOptions = await codeAgentOptions(request, response, options);
|
|
if (!nextOptions) return;
|
|
await handleCodeAgentTraceHttp(request, response, url, nextOptions);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && url.pathname === "/v1/agent/chat/cancel") {
|
|
const nextOptions = await codeAgentOptions(request, response, options);
|
|
if (!nextOptions) return;
|
|
if (workbenchTemporalProxyEnabled(nextOptions.env) && !workbenchActivityDispatchRequested(request)) {
|
|
await proxyWorkbenchCommandHttp(request, response, nextOptions, url.pathname);
|
|
return;
|
|
}
|
|
await handleCodeAgentCancelHttp(request, response, nextOptions);
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && url.pathname === "/v1/agent/chat/steer") {
|
|
const nextOptions = await codeAgentOptions(request, response, options);
|
|
if (!nextOptions) return;
|
|
await handleCodeAgentSteerHttp(request, response, nextOptions);
|
|
return;
|
|
}
|
|
|
|
if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) {
|
|
sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 cloud-api runtime", {
|
|
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: runtimeEnvironment(options.env ?? process.env)
|
|
}
|
|
},
|
|
{
|
|
adapter: "rest",
|
|
runtimeStore: options.runtimeStore,
|
|
env: options.env,
|
|
dbProbe: options.dbProbe,
|
|
m3IoRequestJson: options.m3IoRequestJson,
|
|
gatewayRegistry: options.gatewayRegistry
|
|
}
|
|
);
|
|
|
|
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
|
}
|
|
|
|
function navIdForRestPath(pathname, method = "GET") {
|
|
const verb = String(method || "GET").toUpperCase();
|
|
if (pathname === "/v1/dashboard/summary") return "user.dashboard";
|
|
if (pathname === "/v1/usage/summary") return "user.usage";
|
|
if (pathname === "/v1/billing/summary" || pathname === "/v1/redeem" || pathname === "/v1/subscription/summary" || pathname === "/v1/payments/summary") return "user.billing";
|
|
if (pathname.startsWith("/v1/admin/billing/")) return "admin.billing";
|
|
if (pathname === "/v1/skills" || pathname.startsWith("/v1/skills/")) return "system.skills";
|
|
if (pathname === "/v1/project-management" || pathname.startsWith("/v1/project-management/")) return "project.mdtodo";
|
|
if (pathname === "/v1/tasktree" || pathname.startsWith("/v1/tasktree/")) return "project.tasktree";
|
|
if (pathname === "/v1/api-keys" || pathname === "/v1/api-keys/default" || pathname.startsWith("/v1/api-keys/")) return "user.apiKeys";
|
|
if (pathname === "/v1/users/me/profile" || pathname === "/v1/users/me/password") return "system.settings";
|
|
if (pathname === "/v1/workbench/debug/fake-sse" || pathname.startsWith("/v1/workbench/debug/fake-sse/") || pathname === "/v1/workbench/debug/kafka-sse" || pathname.startsWith("/v1/workbench/debug/kafka-sse/")) return "workbench.debug";
|
|
if (pathname === "/v1/workbench/commands" || pathname === "/v1/workbench/events" || pathname === "/v1/workbench/projection-events" || pathname === "/v1/workbench/launches" || pathname === "/v1/workbench/sessions" || pathname.startsWith("/v1/workbench/sessions/") || pathname.startsWith("/v1/workbench/turns/") || pathname.startsWith("/v1/workbench/traces/")) return "workbench.code";
|
|
if (pathname === "/v1/agent-observer/events" || pathname === "/v1/agent-observer/runs") return "workbench.code";
|
|
if (pathname === "/v1/agent/chat" || pathname === "/v1/agent/sessions" || pathname.startsWith("/v1/agent/sessions/") || pathname === "/v1/agent/chat/inspect" || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/traces/") || pathname === "/v1/agent/chat/cancel" || pathname === "/v1/agent/chat/steer") return "workbench.code";
|
|
if (pathname === "/v1/admin/provider-profiles" || pathname.startsWith("/v1/admin/provider-profiles/")) return "admin.providerProfiles";
|
|
if (pathname === "/v1/admin/secrets" || pathname.startsWith("/v1/admin/secrets/")) return "admin.secrets";
|
|
if (pathname === "/v1/provider-profiles" && verb === "GET") return "workbench.code";
|
|
if (pathname.startsWith("/v1/admin/access")) return "admin.access";
|
|
if (pathname.startsWith("/v1/admin/users")) return "admin.users";
|
|
if (pathname.startsWith("/v1/admin/")) return "admin.access";
|
|
if (pathname === "/v1/hwpod-node-ops" || pathname === "/v1/caserun" || pathname.startsWith("/v1/caserun/") || pathname === "/v1/hwpod/specs" || pathname === "/v1/hwpod/topology") return "admin.hwpodGroups";
|
|
if (pathname === "/v1/web-performance/metrics" || pathname === "/v1/web-performance/summary" || pathname === "/v1/live-builds") return "system.performance";
|
|
if (pathname === "/v1/diagnostics/gate") return "system.gate";
|
|
return "";
|
|
}
|
|
|
|
async function codeAgentOptions(request, response, options, authOptions = {}) {
|
|
if (workbenchActivityDispatchRequested(request)) {
|
|
const expected = String(options.env?.WORKBENCH_CLOUD_API_AUTHORIZATION ?? "").trim();
|
|
const actual = String(request.headers?.authorization ?? "").trim();
|
|
const actorId = String(request.headers?.["x-hwlab-actor-id"] ?? "").trim();
|
|
if (!expected || actual !== expected || !actorId) {
|
|
sendJson(response, 401, { ok: false, error: { code: "workbench_internal_auth_invalid", message: "Workbench internal activity authentication failed" } });
|
|
return null;
|
|
}
|
|
return {
|
|
...options,
|
|
actor: { id: actorId, role: String(request.headers?.["x-hwlab-actor-role"] ?? "user").trim() || "user" },
|
|
authSession: null,
|
|
authApiKey: null,
|
|
actorApiKeySecret: null,
|
|
userBillingAuth: { active: true, source: "workbench-internal", valuesRedacted: true },
|
|
userBillingToken: null,
|
|
userBillingClient: options.userBillingClient ?? options.accessController?.userBilling ?? null
|
|
};
|
|
}
|
|
const auth = await options.accessController.authenticate(request, { required: authOptions.required ?? options.accessController.required });
|
|
if (!auth.ok) {
|
|
sendJson(response, auth.status, auth);
|
|
return null;
|
|
}
|
|
return {
|
|
...options,
|
|
actor: auth.actor ?? null,
|
|
authSession: auth.session ?? null,
|
|
authApiKey: auth.apiKey ?? null,
|
|
actorApiKeySecret: auth.apiKey?.displaySecret ?? null,
|
|
userBillingAuth: auth.userBilling ?? null,
|
|
userBillingToken: userBillingTokenFromAuth(auth),
|
|
userBillingClient: options.userBillingClient ?? options.accessController?.userBilling ?? null
|
|
};
|
|
}
|
|
|
|
async function handleUsageSummaryHttp(request, response, url, options) {
|
|
const route = url.pathname === "/v1/billing/summary" ? "/v1/billing/summary" : "/v1/usage/summary";
|
|
const nextOptions = await codeAgentOptions(request, response, options, { required: true });
|
|
if (!nextOptions) return;
|
|
const client = nextOptions.userBillingClient;
|
|
const token = nextOptions.userBillingToken;
|
|
if (nextOptions.userBillingAuth?.active !== true || !token) {
|
|
sendRestError(request, response, 403, "user_billing_auth_required", "usage summary requires a user-billing session or API key", {
|
|
operation: `GET ${route}`,
|
|
target: { type: "route", id: route },
|
|
result: "rejected"
|
|
});
|
|
return;
|
|
}
|
|
if (!client?.configured || typeof client.billingSummary !== "function") {
|
|
sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing summary is not configured", {
|
|
operation: `GET ${route}`,
|
|
target: { type: "service", id: "hwlab-user-billing" }
|
|
});
|
|
return;
|
|
}
|
|
const limit = parsePositiveInteger(url.searchParams.get("limit"), 20);
|
|
const result = await client.billingSummary(token, { limit: Math.min(limit, 100) });
|
|
if (!result.ok) {
|
|
sendRestError(request, response, result.status ?? 502, result.error?.code ?? "user_billing_summary_failed", result.error?.message ?? "user-billing summary request failed", {
|
|
operation: `GET ${route}`,
|
|
target: { type: "service", id: "hwlab-user-billing" },
|
|
reason: result.error?.code,
|
|
result: "failed"
|
|
});
|
|
return;
|
|
}
|
|
sendJson(response, 200, {
|
|
...(result.body ?? {}),
|
|
proxy: {
|
|
serviceId: CLOUD_API_SERVICE_ID,
|
|
route,
|
|
source: "hwlab-user-billing",
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
}
|
|
|
|
async function handleAdminBillingSummaryHttp(request, response, url, options) {
|
|
const auth = await options.accessController.authenticate(request, { required: true });
|
|
if (!auth.ok) {
|
|
sendJson(response, auth.status, auth);
|
|
return;
|
|
}
|
|
if (auth.actor?.role !== "admin") {
|
|
sendRestError(request, response, 403, "admin_required", "Only admin users can read billing summaries", {
|
|
operation: "GET /v1/admin/billing/summary",
|
|
target: { type: "route", id: "/v1/admin/billing/summary" },
|
|
result: "rejected"
|
|
});
|
|
return;
|
|
}
|
|
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
|
if (!client?.configured || typeof client.adminBillingSummary !== "function") {
|
|
sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin summary is not configured", {
|
|
operation: "GET /v1/admin/billing/summary",
|
|
target: { type: "service", id: "hwlab-user-billing" }
|
|
});
|
|
return;
|
|
}
|
|
const limit = parsePositiveInteger(url.searchParams.get("limit"), 50);
|
|
const result = await client.adminBillingSummary({ limit: Math.min(limit, 100) });
|
|
if (!result.ok) {
|
|
sendRestError(request, response, result.status ?? 502, result.error?.code ?? "user_billing_admin_summary_failed", result.error?.message ?? "user-billing admin summary request failed", {
|
|
operation: "GET /v1/admin/billing/summary",
|
|
target: { type: "service", id: "hwlab-user-billing" },
|
|
reason: result.error?.code,
|
|
result: "failed"
|
|
});
|
|
return;
|
|
}
|
|
sendJson(response, 200, {
|
|
...(result.body ?? {}),
|
|
actor: adminActorSummary(auth.actor),
|
|
proxy: {
|
|
serviceId: CLOUD_API_SERVICE_ID,
|
|
route: "/v1/admin/billing/summary",
|
|
source: "hwlab-user-billing",
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
}
|
|
|
|
async function requireAdminBillingClient(request, response, options, operation) {
|
|
const auth = await options.accessController.authenticate(request, { required: true });
|
|
if (!auth.ok) {
|
|
sendJson(response, auth.status, auth);
|
|
return null;
|
|
}
|
|
if (auth.actor?.role !== "admin") {
|
|
sendRestError(request, response, 403, "admin_required", "Only admin users can call user billing admin APIs", {
|
|
operation,
|
|
target: { type: "route", id: operation },
|
|
result: "rejected"
|
|
});
|
|
return null;
|
|
}
|
|
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
|
if (!client?.configured) {
|
|
sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin API is not configured", {
|
|
operation,
|
|
target: { type: "service", id: "hwlab-user-billing" }
|
|
});
|
|
return null;
|
|
}
|
|
return { auth, client };
|
|
}
|
|
|
|
async function requireUserBillingClient(request, response, options, operation) {
|
|
const nextOptions = await codeAgentOptions(request, response, options, { required: true });
|
|
if (!nextOptions) return null;
|
|
const client = nextOptions.userBillingClient;
|
|
const token = nextOptions.userBillingToken;
|
|
if (nextOptions.userBillingAuth?.active !== true || !token) {
|
|
sendRestError(request, response, 403, "user_billing_auth_required", "user-billing session or API key is required", {
|
|
operation,
|
|
target: { type: "route", id: operation },
|
|
result: "rejected"
|
|
});
|
|
return null;
|
|
}
|
|
if (!client?.configured) {
|
|
sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing API is not configured", {
|
|
operation,
|
|
target: { type: "service", id: "hwlab-user-billing" }
|
|
});
|
|
return null;
|
|
}
|
|
return { auth: nextOptions, client, token };
|
|
}
|
|
|
|
async function handleRedeemHttp(request, response, options) {
|
|
const context = await requireUserBillingClient(request, response, options, "POST /v1/redeem");
|
|
if (!context) return;
|
|
const { auth, client, token } = context;
|
|
if (typeof client.redeem !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing redeem API is not configured", { operation: "POST /v1/redeem", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) return sendJson(response, 400, parsed.error);
|
|
const result = await client.redeem(token, parsed.value);
|
|
sendUserBillingProxyResult({ request, response, result, operation: "POST /v1/redeem", route: "/v1/redeem", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleSubscriptionSummaryHttp(request, response, options) {
|
|
const context = await requireUserBillingClient(request, response, options, "GET /v1/subscription/summary");
|
|
if (!context) return;
|
|
const { auth, client, token } = context;
|
|
if (typeof client.subscriptionSummary !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing subscription API is not configured", { operation: "GET /v1/subscription/summary", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const result = await client.subscriptionSummary(token);
|
|
sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/subscription/summary", route: "/v1/subscription/summary", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handlePaymentSummaryHttp(request, response, options) {
|
|
const context = await requireUserBillingClient(request, response, options, "GET /v1/payments/summary");
|
|
if (!context) return;
|
|
const { auth, client, token } = context;
|
|
if (typeof client.paymentSummary !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing payment API is not configured", { operation: "GET /v1/payments/summary", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const result = await client.paymentSummary(token);
|
|
sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/payments/summary", route: "/v1/payments/summary", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleAdminBillingUsersHttp(request, response, url, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, `${request.method} /v1/admin/billing/users`);
|
|
if (!context) return;
|
|
const { auth, client } = context;
|
|
let result;
|
|
if (request.method === "GET") {
|
|
if (typeof client.adminUsers !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin users API is not configured", { operation: "GET /v1/admin/billing/users", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
result = await client.adminUsers({
|
|
page: parsePositiveInteger(url.searchParams.get("page"), 1),
|
|
pageSize: Math.min(parsePositiveInteger(url.searchParams.get("pageSize") || url.searchParams.get("limit"), 50), 100),
|
|
search: url.searchParams.get("search") ?? "",
|
|
status: url.searchParams.get("status") ?? "",
|
|
role: url.searchParams.get("role") ?? ""
|
|
});
|
|
} else {
|
|
if (typeof client.createAdminUser !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin user create API is not configured", { operation: "POST /v1/admin/billing/users", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) return sendJson(response, 400, parsed.error);
|
|
result = await client.createAdminUser(parsed.value);
|
|
}
|
|
sendUserBillingProxyResult({ request, response, result, operation: `${request.method} /v1/admin/billing/users`, route: "/v1/admin/billing/users", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleAdminBillingPlansHttp(request, response, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/plans");
|
|
if (!context) return;
|
|
const { auth, client } = context;
|
|
if (typeof client.adminBillingPlans !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin plans API is not configured", { operation: "GET /v1/admin/billing/plans", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const result = await client.adminBillingPlans();
|
|
sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/admin/billing/plans", route: "/v1/admin/billing/plans", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleAdminBillingUsageHttp(request, response, url, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/usage");
|
|
if (!context) return;
|
|
const { auth, client } = context;
|
|
if (typeof client.adminUsage !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin usage API is not configured", { operation: "GET /v1/admin/billing/usage", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const result = await client.adminUsage(adminAuditParams(url));
|
|
sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/admin/billing/usage", route: "/v1/admin/billing/usage", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleAdminBillingUsageExportHttp(request, response, url, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/usage/export");
|
|
if (!context) return;
|
|
const { client } = context;
|
|
if (typeof client.adminUsageExport !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin usage export API is not configured", { operation: "GET /v1/admin/billing/usage/export", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const result = await client.adminUsageExport(adminAuditParams(url));
|
|
sendUserBillingCsvProxyResult({ request, response, result, operation: "GET /v1/admin/billing/usage/export", filename: "hwlab-admin-usage.csv" });
|
|
}
|
|
|
|
async function handleAdminBillingLedgerHttp(request, response, url, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/ledger");
|
|
if (!context) return;
|
|
const { auth, client } = context;
|
|
if (typeof client.adminLedger !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin ledger API is not configured", { operation: "GET /v1/admin/billing/ledger", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const result = await client.adminLedger(adminAuditParams(url));
|
|
sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/admin/billing/ledger", route: "/v1/admin/billing/ledger", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleAdminBillingLedgerExportHttp(request, response, url, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/ledger/export");
|
|
if (!context) return;
|
|
const { client } = context;
|
|
if (typeof client.adminLedgerExport !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin ledger export API is not configured", { operation: "GET /v1/admin/billing/ledger/export", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const result = await client.adminLedgerExport(adminAuditParams(url));
|
|
sendUserBillingCsvProxyResult({ request, response, result, operation: "GET /v1/admin/billing/ledger/export", filename: "hwlab-admin-ledger.csv" });
|
|
}
|
|
|
|
async function handleAdminBillingRedeemCodesHttp(request, response, url, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, `${request.method} /v1/admin/billing/redeem-codes`);
|
|
if (!context) return;
|
|
const { auth, client } = context;
|
|
let result;
|
|
if (request.method === "GET") {
|
|
if (typeof client.adminRedeemCodes !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin redeem code API is not configured", { operation: "GET /v1/admin/billing/redeem-codes", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
result = await client.adminRedeemCodes(adminRedeemParams(url));
|
|
} else {
|
|
if (typeof client.createAdminRedeemCode !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin redeem code create API is not configured", { operation: "POST /v1/admin/billing/redeem-codes", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) return sendJson(response, 400, parsed.error);
|
|
result = await client.createAdminRedeemCode({
|
|
...parsed.value,
|
|
createdByUserId: String(auth.actor?.id ?? ""),
|
|
createdByUsername: String(auth.actor?.username ?? ""),
|
|
metadata: { ...(parsed.value?.metadata && typeof parsed.value.metadata === "object" && !Array.isArray(parsed.value.metadata) ? parsed.value.metadata : {}), operator: adminActorSummary(auth.actor) }
|
|
});
|
|
}
|
|
sendUserBillingProxyResult({ request, response, result, operation: `${request.method} /v1/admin/billing/redeem-codes`, route: "/v1/admin/billing/redeem-codes", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleAdminBillingRedeemCodeHttp(request, response, codeId, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, "PATCH /v1/admin/billing/redeem-codes/{codeId}");
|
|
if (!context) return;
|
|
const { auth, client } = context;
|
|
if (typeof client.updateAdminRedeemCode !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin redeem code update API is not configured", { operation: "PATCH /v1/admin/billing/redeem-codes/{codeId}", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) return sendJson(response, 400, parsed.error);
|
|
const result = await client.updateAdminRedeemCode(codeId, parsed.value);
|
|
sendUserBillingProxyResult({ request, response, result, operation: "PATCH /v1/admin/billing/redeem-codes/{codeId}", route: "/v1/admin/billing/redeem-codes/{codeId}", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleAdminBillingRedeemRedemptionsHttp(request, response, url, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/redeem-redemptions");
|
|
if (!context) return;
|
|
const { auth, client } = context;
|
|
if (typeof client.adminRedeemRedemptions !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin redeem redemptions API is not configured", { operation: "GET /v1/admin/billing/redeem-redemptions", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const result = await client.adminRedeemRedemptions(adminRedeemParams(url));
|
|
sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/admin/billing/redeem-redemptions", route: "/v1/admin/billing/redeem-redemptions", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleAdminBillingUserHttp(request, response, url, userId, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, `${request.method} /v1/admin/billing/users/{userId}`);
|
|
if (!context) return;
|
|
const { auth, client } = context;
|
|
let result;
|
|
if (request.method === "GET") {
|
|
if (typeof client.adminUser !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin user detail API is not configured", { operation: "GET /v1/admin/billing/users/{userId}", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
result = await client.adminUser(userId);
|
|
} else {
|
|
if (typeof client.updateAdminUser !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin user update API is not configured", { operation: "PATCH /v1/admin/billing/users/{userId}", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) return sendJson(response, 400, parsed.error);
|
|
result = await client.updateAdminUser(userId, parsed.value);
|
|
}
|
|
sendUserBillingProxyResult({ request, response, result, operation: `${request.method} /v1/admin/billing/users/{userId}`, route: "/v1/admin/billing/users/{userId}", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleAdminBillingCreditAdjustHttp(request, response, userId, options) {
|
|
const context = await requireAdminBillingClient(request, response, options, "POST /v1/admin/billing/users/{userId}/credits/adjust");
|
|
if (!context) return;
|
|
const { auth, client } = context;
|
|
if (typeof client.adjustAdminCredits !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin credit adjust API is not configured", { operation: "POST /v1/admin/billing/users/{userId}/credits/adjust", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) return sendJson(response, 400, parsed.error);
|
|
const result = await client.adjustAdminCredits({
|
|
...parsed.value,
|
|
userId,
|
|
metadata: { ...(parsed.value?.metadata && typeof parsed.value.metadata === "object" && !Array.isArray(parsed.value.metadata) ? parsed.value.metadata : {}), operator: adminActorSummary(auth.actor) }
|
|
});
|
|
sendUserBillingProxyResult({ request, response, result, operation: "POST /v1/admin/billing/users/{userId}/credits/adjust", route: "/v1/admin/billing/users/{userId}/credits/adjust", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleAdminBillingUserStatusHttp(request, response, userId, options) {
|
|
const auth = await options.accessController.authenticate(request, { required: true });
|
|
if (!auth.ok) {
|
|
sendJson(response, auth.status, auth);
|
|
return;
|
|
}
|
|
if (auth.actor?.role !== "admin") {
|
|
sendRestError(request, response, 403, "admin_required", "Only admin users can update user status", {
|
|
operation: "PATCH /v1/admin/billing/users/{userId}/status",
|
|
target: { type: "route", id: "/v1/admin/billing/users/{userId}/status" },
|
|
result: "rejected"
|
|
});
|
|
return;
|
|
}
|
|
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
|
if (!client?.configured || typeof client.updateAdminUserStatus !== "function") {
|
|
sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin user status API is not configured", {
|
|
operation: "PATCH /v1/admin/billing/users/{userId}/status",
|
|
target: { type: "service", id: "hwlab-user-billing" }
|
|
});
|
|
return;
|
|
}
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) {
|
|
sendJson(response, 400, parsed.error);
|
|
return;
|
|
}
|
|
const status = String(parsed.value?.status ?? "").trim().toLowerCase();
|
|
if (!["active", "disabled", "pending"].includes(status)) {
|
|
sendRestError(request, response, 400, "invalid_status", "status must be active, disabled or pending", {
|
|
operation: "PATCH /v1/admin/billing/users/{userId}/status",
|
|
target: { type: "user", id: userId },
|
|
result: "rejected"
|
|
});
|
|
return;
|
|
}
|
|
const result = await client.updateAdminUserStatus(userId, status);
|
|
if (!result.ok) {
|
|
sendRestError(request, response, result.status ?? 502, result.error?.code ?? "user_billing_admin_user_status_failed", result.error?.message ?? "user-billing admin user status request failed", {
|
|
operation: "PATCH /v1/admin/billing/users/{userId}/status",
|
|
target: { type: "service", id: "hwlab-user-billing" },
|
|
reason: result.error?.code,
|
|
result: "failed"
|
|
});
|
|
return;
|
|
}
|
|
sendJson(response, 200, {
|
|
...(result.body ?? {}),
|
|
actor: adminActorSummary(auth.actor),
|
|
proxy: {
|
|
serviceId: CLOUD_API_SERVICE_ID,
|
|
route: "/v1/admin/billing/users/{userId}/status",
|
|
source: "hwlab-user-billing",
|
|
valuesRedacted: true
|
|
},
|
|
valuesRedacted: true
|
|
});
|
|
}
|
|
|
|
async function maybeHandleUserBillingApiKeysHttp(request, response, url, options) {
|
|
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
|
if (!client?.configured) return false;
|
|
const auth = await options.accessController.authenticate(request, { required: true });
|
|
if (!auth.ok || !String(auth.authMethod ?? "").startsWith("user-billing")) return false;
|
|
const token = userBillingTokenFromAuth(auth);
|
|
if (!token) return false;
|
|
if (url.pathname === "/v1/api-keys/default") {
|
|
sendRestError(request, response, 404, "user_billing_default_key_unsupported", "user-billing API keys do not support legacy default-key regeneration", {
|
|
operation: `${request.method} /v1/api-keys/default`,
|
|
target: { type: "route", id: "/v1/api-keys/default" },
|
|
result: "rejected"
|
|
});
|
|
return true;
|
|
}
|
|
let result = null;
|
|
const path = url.pathname;
|
|
if (request.method === "GET" && path === "/v1/api-keys" && typeof client.listApiKeys === "function") {
|
|
result = await client.listApiKeys(token);
|
|
} else if (request.method === "POST" && path === "/v1/api-keys" && typeof client.createApiKey === "function") {
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) {
|
|
sendJson(response, 400, parsed.error);
|
|
return true;
|
|
}
|
|
result = await client.createApiKey(token, parsed.value);
|
|
} else {
|
|
const match = path.match(/^\/v1\/api-keys\/([^/]+)$/u);
|
|
if (match && request.method === "PATCH" && typeof client.updateApiKey === "function") {
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) {
|
|
sendJson(response, 400, parsed.error);
|
|
return true;
|
|
}
|
|
result = await client.updateApiKey(token, decodeURIComponent(match[1]), parsed.value);
|
|
} else if (match && request.method === "DELETE" && typeof client.revokeApiKey === "function") {
|
|
result = await client.revokeApiKey(token, decodeURIComponent(match[1]));
|
|
}
|
|
}
|
|
if (!result) return false;
|
|
sendUserBillingProxyResult({ request, response, result, operation: `${request.method} /v1/api-keys`, route: path, actor: adminActorSummary(auth.actor) });
|
|
return true;
|
|
}
|
|
|
|
async function handleUserBillingProfileHttp(request, response, options) {
|
|
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
|
const auth = await options.accessController.authenticate(request, { required: true });
|
|
if (!auth.ok) return sendJson(response, auth.status, auth);
|
|
const token = userBillingTokenFromAuth(auth);
|
|
if (!String(auth.authMethod ?? "").startsWith("user-billing")) {
|
|
return sendRestError(request, response, 403, "user_billing_auth_required", "profile update requires a user-billing bearer token", { operation: "PATCH /v1/users/me/profile", target: { type: "route", id: "/v1/users/me/profile" } });
|
|
}
|
|
if (!token || !client?.configured || typeof client.updateProfile !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing profile API is not configured", { operation: "PATCH /v1/users/me/profile", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) return sendJson(response, 400, parsed.error);
|
|
const result = await client.updateProfile(token, parsed.value);
|
|
sendUserBillingProxyResult({ request, response, result, operation: "PATCH /v1/users/me/profile", route: "/v1/users/me/profile", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
async function handleUserBillingPasswordHttp(request, response, options) {
|
|
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
|
const auth = await options.accessController.authenticate(request, { required: true });
|
|
if (!auth.ok) return sendJson(response, auth.status, auth);
|
|
const token = userBillingTokenFromAuth(auth);
|
|
if (auth.authMethod !== "user-billing-session") {
|
|
return sendRestError(request, response, 403, "user_billing_session_required", "password update requires a user-billing session token", { operation: "POST /v1/users/me/password", target: { type: "route", id: "/v1/users/me/password" } });
|
|
}
|
|
if (!token || !client?.configured || typeof client.changePassword !== "function") {
|
|
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing password API is not configured", { operation: "POST /v1/users/me/password", target: { type: "service", id: "hwlab-user-billing" } });
|
|
}
|
|
const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) return sendJson(response, 400, parsed.error);
|
|
const result = await client.changePassword(token, parsed.value);
|
|
sendUserBillingProxyResult({ request, response, result, operation: "POST /v1/users/me/password", route: "/v1/users/me/password", actor: adminActorSummary(auth.actor) });
|
|
}
|
|
|
|
function sendUserBillingProxyResult({ request, response, result, operation, route, actor = null }) {
|
|
if (!result?.ok) {
|
|
sendRestError(request, response, result?.status ?? 502, result?.error?.code ?? "user_billing_request_failed", result?.error?.message ?? "user-billing request failed", {
|
|
operation,
|
|
target: { type: "service", id: "hwlab-user-billing" },
|
|
reason: result?.error?.code,
|
|
result: "failed"
|
|
});
|
|
return;
|
|
}
|
|
sendJson(response, result.status ?? 200, {
|
|
...(result.body ?? {}),
|
|
actor,
|
|
proxy: {
|
|
serviceId: CLOUD_API_SERVICE_ID,
|
|
route,
|
|
source: "hwlab-user-billing",
|
|
valuesRedacted: true
|
|
},
|
|
valuesRedacted: true
|
|
});
|
|
}
|
|
|
|
function sendUserBillingCsvProxyResult({ request, response, result, operation, filename }) {
|
|
if (!result?.ok) {
|
|
sendRestError(request, response, result?.status ?? 502, result?.error?.code ?? "user_billing_request_failed", result?.error?.message ?? "user-billing export request failed", {
|
|
operation,
|
|
target: { type: "service", id: "hwlab-user-billing" },
|
|
reason: result?.error?.code,
|
|
result: "failed"
|
|
});
|
|
return;
|
|
}
|
|
response.writeHead(result.status ?? 200, {
|
|
"content-type": "text/csv; charset=utf-8",
|
|
"cache-control": "no-store",
|
|
"content-disposition": `attachment; filename="${filename}"`
|
|
});
|
|
response.end(String(result.body ?? ""));
|
|
}
|
|
|
|
function adminAuditParams(url) {
|
|
const params = {};
|
|
for (const key of ["page", "pageSize", "limit", "userId", "search", "serviceId", "resourceType", "apiKeyId", "status", "kind", "source", "idempotencyKey", "from", "to"]) {
|
|
const value = String(url.searchParams.get(key) ?? "").trim();
|
|
if (value) params[key] = value;
|
|
}
|
|
return params;
|
|
}
|
|
|
|
function adminRedeemParams(url) {
|
|
const params = {};
|
|
for (const key of ["limit", "status", "userId", "codeId"]) {
|
|
const value = String(url.searchParams.get(key) ?? "").trim();
|
|
if (value) params[key] = value;
|
|
}
|
|
return params;
|
|
}
|
|
|
|
function adminActorSummary(actor) {
|
|
return actor ? {
|
|
id: String(actor.id ?? ""),
|
|
username: String(actor.username ?? ""),
|
|
displayName: String(actor.displayName ?? ""),
|
|
role: String(actor.role ?? ""),
|
|
status: String(actor.status ?? ""),
|
|
valuesRedacted: true
|
|
} : null;
|
|
}
|
|
|
|
function bearerTokenFromRequest(request) {
|
|
const header = String(getHeader(request, "authorization") ?? "").trim();
|
|
return header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
|
|
}
|
|
|
|
function userBillingTokenFromRequest(request) {
|
|
const bearer = bearerTokenFromRequest(request);
|
|
if (bearer) return bearer;
|
|
const cookieToken = cookieValueFromRequest(request, HWLAB_WEB_SESSION_COOKIE);
|
|
return cookieToken.startsWith("hws_") ? cookieToken : "";
|
|
}
|
|
|
|
function userBillingTokenFromAuth(auth) {
|
|
const token = String(auth?.userBilling?.token ?? "").trim();
|
|
return token ? token : "";
|
|
}
|
|
|
|
function cookieValueFromRequest(request, name) {
|
|
const target = String(name ?? "");
|
|
if (!target) return "";
|
|
for (const part of String(getHeader(request, "cookie") ?? "").split(";")) {
|
|
const trimmed = part.trim();
|
|
const index = trimmed.indexOf("=");
|
|
if (index <= 0) continue;
|
|
if (trimmed.slice(0, index) !== target) continue;
|
|
try {
|
|
return decodeURIComponent(trimmed.slice(index + 1));
|
|
} catch {
|
|
return trimmed.slice(index + 1);
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function readJsonObject(request, limitBytes) {
|
|
const body = await readBody(request, limitBytes);
|
|
try {
|
|
const value = body ? JSON.parse(body) : {};
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return { ok: false, error: { accepted: false, error: { code: "invalid_params", message: "body must be a JSON object" } } };
|
|
}
|
|
return { ok: true, value };
|
|
} catch (error) {
|
|
return { ok: false, error: { accepted: false, error: { code: "parse_error", message: "Invalid JSON body", reason: error.message } } };
|
|
}
|
|
}
|
|
|
|
async function handleGatewayPollHttp(request, response, options) {
|
|
const body = await readBody(request, options.bodyLimitBytes);
|
|
let params = {};
|
|
|
|
try {
|
|
params = body ? JSON.parse(body) : {};
|
|
} catch (error) {
|
|
sendJson(response, 400, {
|
|
accepted: false,
|
|
error: {
|
|
code: "parse_error",
|
|
message: "Invalid JSON body",
|
|
reason: error.message
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
sendJson(response, 400, {
|
|
accepted: false,
|
|
error: {
|
|
code: "invalid_params",
|
|
message: "gateway poll body must be a JSON object"
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
const session = options.gatewayRegistry.updateSession(params);
|
|
await persistGatewayDemoSession({
|
|
runtimeStore: options.runtimeStore,
|
|
session,
|
|
request,
|
|
env: options.env
|
|
});
|
|
const outbound = options.gatewayRegistry.nextRequest(session.gatewaySessionId);
|
|
sendJson(response, 200, {
|
|
accepted: true,
|
|
serviceId: CLOUD_API_SERVICE_ID,
|
|
mode: "gateway-outbound-poll-demo",
|
|
gatewayId: session.gatewayId,
|
|
gatewaySessionId: session.gatewaySessionId,
|
|
queueDepth: session.queue.length,
|
|
request: outbound,
|
|
type: outbound ? "request" : "noop",
|
|
observedAt: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
async function handleGatewayResultHttp(request, response, options) {
|
|
const body = await readBody(request, options.bodyLimitBytes);
|
|
let params = {};
|
|
|
|
try {
|
|
params = body ? JSON.parse(body) : {};
|
|
} catch (error) {
|
|
sendJson(response, 400, {
|
|
accepted: false,
|
|
error: {
|
|
code: "parse_error",
|
|
message: "Invalid JSON body",
|
|
reason: error.message
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
const result = options.gatewayRegistry.complete(params);
|
|
sendJson(response, result.accepted ? 200 : 404, {
|
|
...result,
|
|
serviceId: CLOUD_API_SERVICE_ID,
|
|
mode: "gateway-outbound-poll-demo",
|
|
observedAt: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
async function persistGatewayDemoSession({ runtimeStore, session, request, env = process.env }) {
|
|
if (!runtimeStore) return;
|
|
const refreshMs = parsePositiveInteger(env.HWLAB_GATEWAY_REGISTRATION_REFRESH_MS, 60000);
|
|
if (session.runtimeRegisteredEpochMs && Date.now() - session.runtimeRegisteredEpochMs < refreshMs) {
|
|
return;
|
|
}
|
|
const meta = {
|
|
traceId: getHeader(request, "x-trace-id") || `trc_gateway_poll_${randomUUID()}`,
|
|
actorId: `svc_${session.serviceId}`,
|
|
serviceId: CLOUD_API_SERVICE_ID,
|
|
environment: runtimeEnvironment(env)
|
|
};
|
|
|
|
try {
|
|
await runtimeStore.registerGatewaySession?.({
|
|
projectId: session.projectId,
|
|
gatewaySessionId: session.gatewaySessionId,
|
|
serviceId: "hwlab-gateway",
|
|
gatewayId: session.gatewayId,
|
|
endpoint: session.endpoint,
|
|
status: "connected",
|
|
labels: {
|
|
demoOutboundPoll: "true",
|
|
os: String(session.system?.platform ?? "unknown")
|
|
}
|
|
}, meta);
|
|
await runtimeStore.registerBoxResource?.({
|
|
projectId: session.projectId,
|
|
gatewaySessionId: session.gatewaySessionId,
|
|
resourceId: session.resourceId,
|
|
boxId: session.boxId,
|
|
resourceType: "simulator_endpoint",
|
|
state: "available",
|
|
metadata: {
|
|
serviceId: "hwlab-gateway",
|
|
demoShellHost: true
|
|
}
|
|
}, meta);
|
|
if (session.capabilities.length > 0) {
|
|
await runtimeStore.reportBoxCapabilities?.({
|
|
capabilities: session.capabilities.map((capability) => ({
|
|
...capability,
|
|
resourceId: capability.resourceId ?? session.resourceId,
|
|
projectId: capability.projectId ?? session.projectId
|
|
}))
|
|
}, meta);
|
|
}
|
|
session.runtimeRegisteredAt = new Date().toISOString();
|
|
session.runtimeRegisteredEpochMs = Date.now();
|
|
} catch {
|
|
// Demo polling must keep the gateway online even when durable runtime writes are not ready.
|
|
}
|
|
}
|
|
|
|
function sendRestError(request, response, statusCode, code, message, context = {}) {
|
|
const requestId = request.hwlabHttpRequestContext?.requestId || getHeader(request, "x-request-id") || "req_unassigned";
|
|
const environment = context.environment || runtimeEnvironment(context.env ?? process.env);
|
|
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,
|
|
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) {
|
|
if (response.headersSent || response.writableEnded) return false;
|
|
const payload = decorateErrorPayloadForHttp(body, { response, statusCode });
|
|
logErrorEnvelope(payload, { statusCode });
|
|
response.writeHead(statusCode, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"cache-control": "no-store"
|
|
});
|
|
response.end(`${JSON.stringify(payload)}\n`);
|
|
return true;
|
|
}
|
|
|
|
function getHeader(request, name) {
|
|
const value = request.headers[name.toLowerCase()];
|
|
if (Array.isArray(value)) {
|
|
return value[0];
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function safeRequestId(value) {
|
|
const text = String(value ?? "").trim();
|
|
return /^[A-Za-z0-9_.:-]{3,180}$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function firstHeaderValue(request, name) {
|
|
return getHeader(request, name);
|
|
}
|
|
|
|
function truthyFlag(value) {
|
|
return /^(?:1|true|yes|on|enabled)$/iu.test(String(value ?? "").trim());
|
|
}
|
|
|
|
function safeTraceId(value) {
|
|
const text = String(value ?? "").trim();
|
|
return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function safeSessionId(value) {
|
|
const text = String(value ?? "").trim();
|
|
return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function safeOpaqueId(value) {
|
|
const text = String(value ?? "").trim();
|
|
return /^[A-Za-z0-9_.:-]{3,180}$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function isAuthorizedWorkbenchDrainRequest(request, env = process.env) {
|
|
const expected = String(env.HOSTNAME ?? "").trim();
|
|
if (!expected) return false;
|
|
return getHeader(request, "x-hwlab-drain-hostname") === expected;
|
|
}
|
|
|
|
function parsePositiveInteger(value, fallback) {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function positiveInteger(value, fallback) {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
async function runtimeReadiness(runtimeStore) {
|
|
if (typeof runtimeStore?.readiness === "function") {
|
|
return runtimeStore.readiness();
|
|
}
|
|
return runtimeStore.summary();
|
|
}
|