1877 lines
76 KiB
TypeScript
1877 lines
76 KiB
TypeScript
/*
|
|
* SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
|
|
* 职责: 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,
|
|
handleCodeAgentSteerHttp,
|
|
handleCodeAgentTurnHttp,
|
|
handleCodeAgentTraceHttp
|
|
} from "./server-code-agent-http.ts";
|
|
import { handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-http.ts";
|
|
import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts";
|
|
import { handleM3IoControlHttp } from "./server-m3-http.ts";
|
|
import { handleSkillsHttp } from "./server-skills-http.ts";
|
|
import { handleProviderProfileCatalogHttp, handleProviderProfilesHttp } from "./provider-profile-management.ts";
|
|
import {
|
|
configureSkillRuntime,
|
|
ensureCodexSkillsAggregationSync
|
|
} from "./skills-store.ts";
|
|
import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.ts";
|
|
import {
|
|
discoverHwpodSpecs,
|
|
hwpodSpecDiscoveryPayload,
|
|
hwpodSpecWorkspaceProbePlan
|
|
} from "./hwpod-spec-discovery.ts";
|
|
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.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;
|
|
}
|
|
|
|
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 });
|
|
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: console });
|
|
const server = createServer(async (request, response) => {
|
|
const backendPerformance = backendPerformanceStore.beginHttpRequest(request, response);
|
|
await withBackendPerformanceContext(backendPerformance, async () => {
|
|
try {
|
|
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, hwpodNodeWsRegistry, accessController, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults, webPerformanceStore, backendPerformanceStore, backendPerformance });
|
|
} catch (error) {
|
|
if (error?.alreadySent) 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());
|
|
return server;
|
|
}
|
|
|
|
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;
|
|
ensureCodeAgentRuntimeBase(env);
|
|
const metadata = buildMetadataFromEnv(env, {
|
|
serviceId,
|
|
fallbackImageRepository: "ghcr.io/pikastech/hwlab-cloud-api"
|
|
});
|
|
const dbProbe = await buildDbRuntimeReadiness(env, options.dbProbe);
|
|
const codeAgent = await describeCodeAgentAvailability(env, options);
|
|
const runtime = await runtimeReadiness(options.runtimeStore ?? createConfiguredCloudRuntimeStore({ ...options, env }));
|
|
const db = applyRuntimeDbReadinessLayers(dbProbe, runtime);
|
|
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
|
|
|
|
return {
|
|
serviceId,
|
|
environment: runtimeEnvironment(env),
|
|
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
|
|
};
|
|
}
|
|
|
|
async function routeRequest(request, response, options) {
|
|
const url = new URL(request.url || "/", "http://hwlab-cloud-api.local");
|
|
|
|
if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
|
|
sendJson(response, 200, await buildHealthPayload(options));
|
|
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) {
|
|
if (url.pathname === "/v1/web-performance" && request.method === "POST") {
|
|
await handleWebPerformanceIngestHttp(request, response, { store: options.webPerformanceStore });
|
|
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 });
|
|
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 dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe);
|
|
const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options);
|
|
const runtime = await runtimeReadiness(options.runtimeStore);
|
|
const db = applyRuntimeDbReadinessLayers(dbProbe, runtime);
|
|
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
|
|
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,
|
|
hwpod: {
|
|
route: "/v1/hwpod-node-ops",
|
|
specDiscoveryRoute: "/v1/hwpod/specs",
|
|
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)
|
|
},
|
|
m3IoControl: describeM3IoControl(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/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/workbench/events") {
|
|
await handleWorkbenchRealtimeHttp(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/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 (request.method === "GET" && url.pathname === "/v1/hwpod/specs") {
|
|
await handleHwpodSpecDiscoveryHttp(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, { 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;
|
|
await handleCodeAgentChatHttp(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;
|
|
await handleCodeAgentSessionsHttp(request, response, url, nextOptions);
|
|
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;
|
|
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);
|
|
}
|
|
|
|
async function handleHwpodNodeOpsHttp(request, response, options) {
|
|
if (request.method === "GET") {
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
status: "ready",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
route: "/v1/hwpod-node-ops",
|
|
specAuthority: "code-agent-workspace",
|
|
compiler: "hwpod-compiler-cli",
|
|
apiRole: "node-ops-forwarder",
|
|
nodeRole: "thin-hwpod-node-executor",
|
|
supportedOps: Array.from(HWPOD_NODE_OPS),
|
|
websocket: options.hwpodNodeWsRegistry.describe()
|
|
});
|
|
return;
|
|
}
|
|
if (request.method !== "POST") {
|
|
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "hwpod-node-ops only supports GET and POST" } });
|
|
return;
|
|
}
|
|
|
|
const body = await readJsonObject(request, options.bodyLimitBytes);
|
|
if (!body.ok) {
|
|
sendJson(response, 400, body.error);
|
|
return;
|
|
}
|
|
const validation = validateHwpodNodeOpsPlan(body.value);
|
|
if (!validation.ok) {
|
|
sendJson(response, 400, {
|
|
ok: false,
|
|
status: "rejected",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
error: validation.error
|
|
});
|
|
return;
|
|
}
|
|
|
|
const plan = validation.plan;
|
|
const requestMeta = {
|
|
requestId: getHeader(request, "x-request-id") || `req_hwpod_${randomUUID()}`,
|
|
traceId: getHeader(request, "x-trace-id") || `trc_hwpod_${randomUUID()}`,
|
|
serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID,
|
|
environment: runtimeEnvironment(options.env ?? process.env)
|
|
};
|
|
try {
|
|
const handled = await dispatchHwpodNodeOpsPlan(plan, requestMeta, options, { request });
|
|
sendJson(response, handled.httpStatus, handled.payload);
|
|
} catch (error) {
|
|
sendJson(response, 200, hwpodNodeOpsBlockedPayload(plan, requestMeta, error?.message ?? "hwpod-node-ops handler failed"));
|
|
}
|
|
}
|
|
|
|
async function handleHwpodSpecDiscoveryHttp(request, response, url, options) {
|
|
const probe = truthyFlag(url.searchParams.get("probe"));
|
|
const observedAt = new Date().toISOString();
|
|
let specs = await discoverHwpodSpecs(options);
|
|
if (probe) {
|
|
specs = await Promise.all(specs.map((spec) => probeDiscoveredHwpodSpec(spec, { request, options, observedAt })));
|
|
}
|
|
sendJson(response, 200, hwpodSpecDiscoveryPayload(specs, { observedAt }));
|
|
}
|
|
|
|
async function probeDiscoveredHwpodSpec(spec, { request, options, observedAt }) {
|
|
if (spec.ok === false) return spec;
|
|
const plan = hwpodSpecWorkspaceProbePlan(spec);
|
|
const validation = validateHwpodNodeOpsPlan(plan);
|
|
if (!validation.ok) {
|
|
return {
|
|
...spec,
|
|
availability: {
|
|
ok: false,
|
|
status: "invalid_probe_plan",
|
|
checkedAt: observedAt,
|
|
blocker: validation.error
|
|
}
|
|
};
|
|
}
|
|
const requestMeta = {
|
|
requestId: getHeader(request, "x-request-id") || `req_hwpod_spec_${randomUUID()}`,
|
|
traceId: getHeader(request, "x-trace-id") || `trc_hwpod_spec_${randomUUID()}`,
|
|
serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID,
|
|
environment: runtimeEnvironment(options.env ?? process.env)
|
|
};
|
|
const handled = await dispatchHwpodNodeOpsPlan(validation.plan, requestMeta, options, { request });
|
|
const payload = handled.payload;
|
|
return {
|
|
...spec,
|
|
availability: {
|
|
ok: payload.ok === true,
|
|
status: payload.ok === true ? "available" : "blocked",
|
|
checkedAt: observedAt,
|
|
probePlanId: payload.planId,
|
|
nodeOpsRoute: "/v1/hwpod-node-ops",
|
|
results: payload.results ?? [],
|
|
blocker: payload.blocker ?? null,
|
|
httpStatus: handled.httpStatus
|
|
}
|
|
};
|
|
}
|
|
|
|
async function dispatchHwpodNodeOpsPlan(plan, requestMeta, options, context = {}) {
|
|
const hwpodNodeOpsUrl = normalizedHwpodNodeOpsUrl(options.env ?? process.env);
|
|
const hwpodNodeWsRegistry = options.hwpodNodeWsRegistry;
|
|
const hasWsNode = hwpodNodeWsRegistry?.hasNode?.(plan.nodeId);
|
|
if (typeof options.hwpodNodeOpsHandler !== "function" && !hasWsNode && !hwpodNodeOpsUrl) {
|
|
return {
|
|
httpStatus: 200,
|
|
payload: hwpodNodeOpsBlockedPayload(plan, requestMeta, "no outbound WebSocket hwpod-node is connected and HWLAB_HWPOD_NODE_OPS_URL is not configured; cloud-api is only validating the hwpod-node-ops contract")
|
|
};
|
|
}
|
|
const handled = typeof options.hwpodNodeOpsHandler === "function"
|
|
? await options.hwpodNodeOpsHandler(plan, { request: context.request, requestMeta, env: options.env ?? process.env })
|
|
: hasWsNode
|
|
? await hwpodNodeWsRegistry.dispatch(plan, requestMeta, { timeoutMs: parsePositiveInteger(options.env?.HWLAB_HWPOD_NODE_OPS_TIMEOUT_MS, 30000) })
|
|
: await forwardHwpodNodeOpsPlan(hwpodNodeOpsUrl, plan, requestMeta, options);
|
|
const results = Array.isArray(handled?.results) ? handled.results : [];
|
|
const failed = results.some((item) => item?.ok === false);
|
|
const payload = {
|
|
ok: handled?.ok ?? !failed,
|
|
status: handled?.status ?? (failed ? "failed" : "completed"),
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
planId: plan.planId,
|
|
hwpodId: plan.hwpodId,
|
|
nodeId: plan.nodeId,
|
|
acceptedOps: plan.ops.length,
|
|
results,
|
|
blocker: handled?.blocker ?? null,
|
|
requestMeta
|
|
};
|
|
return { httpStatus: handled?.httpStatus ?? (failed ? 409 : 200), payload };
|
|
}
|
|
|
|
async function forwardHwpodNodeOpsPlan(targetUrl, plan, requestMeta, options) {
|
|
const target = await describeDirectHwpodNode(targetUrl, options);
|
|
if (!target.ok || !target.nodeId) {
|
|
return directHwpodNodeBlocked(plan, "hwpod_node_identity_unverified", "configured hwpod-node URL did not expose a verifiable nodeId", {
|
|
requestedNodeId: plan.nodeId,
|
|
targetUrl: redactNodeOpsUrl(targetUrl),
|
|
dispatchMode: "direct-url",
|
|
targetStatus: target.status ?? null,
|
|
error: target.error ?? null
|
|
});
|
|
}
|
|
if (target.ok && target.nodeId && target.nodeId !== plan.nodeId) {
|
|
return directHwpodNodeBlocked(plan, "hwpod_node_not_connected", `${plan.nodeId} is not connected via WebSocket and HWLAB_HWPOD_NODE_OPS_URL points to ${target.nodeId}`, {
|
|
requestedNodeId: plan.nodeId,
|
|
targetNodeId: target.nodeId,
|
|
targetUrl: redactNodeOpsUrl(targetUrl),
|
|
dispatchMode: "direct-url"
|
|
});
|
|
}
|
|
const response = await fetch(targetUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-request-id": requestMeta.requestId,
|
|
"x-trace-id": requestMeta.traceId,
|
|
"x-source-service-id": CLOUD_API_SERVICE_ID
|
|
},
|
|
body: JSON.stringify(plan),
|
|
signal: AbortSignal.timeout(parsePositiveInteger(options.env?.HWLAB_HWPOD_NODE_OPS_TIMEOUT_MS, 30000))
|
|
});
|
|
const body = await response.json().catch(() => null);
|
|
if (!body || typeof body !== "object") {
|
|
return {
|
|
ok: false,
|
|
status: "blocked",
|
|
httpStatus: 200,
|
|
results: [],
|
|
blocker: {
|
|
code: "hwpod_node_response_invalid",
|
|
layer: "hwpod-node",
|
|
retryable: true,
|
|
summary: `hwpod-node returned HTTP ${response.status} without a JSON object payload`
|
|
}
|
|
};
|
|
}
|
|
return {
|
|
ok: body.ok,
|
|
status: body.status,
|
|
httpStatus: response.status,
|
|
results: body.results,
|
|
blocker: body.blocker ?? null
|
|
};
|
|
}
|
|
|
|
function directHwpodNodeBlocked(plan, code, summary, details) {
|
|
const blocker = { code, layer: "hwpod-node", retryable: true, summary, details };
|
|
return {
|
|
ok: false,
|
|
status: "blocked",
|
|
httpStatus: 200,
|
|
results: plan.ops.map((op) => ({ opId: op.opId, op: op.op, ok: false, status: "blocked", blocker })),
|
|
blocker
|
|
};
|
|
}
|
|
|
|
async function describeDirectHwpodNode(targetUrl, options) {
|
|
try {
|
|
const response = await fetch(targetUrl, {
|
|
method: "GET",
|
|
signal: AbortSignal.timeout(parsePositiveInteger(options.env?.HWLAB_HWPOD_NODE_OPS_TIMEOUT_MS, 30000))
|
|
});
|
|
const body = await response.json().catch(() => null);
|
|
return { ok: response.ok && body && typeof body === "object", status: response.status, nodeId: safeOpaqueId(body?.nodeId), body };
|
|
} catch (error) {
|
|
return { ok: false, error: error instanceof Error ? error.message : String(error), nodeId: "" };
|
|
}
|
|
}
|
|
|
|
function redactNodeOpsUrl(value) {
|
|
try {
|
|
const parsed = new URL(value);
|
|
parsed.username = "";
|
|
parsed.password = "";
|
|
parsed.search = parsed.search ? "?redacted=1" : "";
|
|
return parsed.toString();
|
|
} catch {
|
|
return "<invalid-url>";
|
|
}
|
|
}
|
|
|
|
function normalizedHwpodNodeOpsUrl(env = process.env) {
|
|
const direct = String(env.HWLAB_HWPOD_NODE_OPS_URL ?? "").trim();
|
|
if (direct) return direct;
|
|
const base = String(env.HWLAB_HWPOD_NODE_URL ?? "").trim().replace(/\/+$/u, "");
|
|
return base ? `${base}/v1/hwpod-node-ops` : "";
|
|
}
|
|
|
|
|
|
function validateHwpodNodeOpsPlan(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return { ok: false, error: { code: "invalid_hwpod_node_ops_plan", message: "hwpod-node-ops body must be a JSON object" } };
|
|
}
|
|
if (value.contractVersion !== HWPOD_NODE_OPS_CONTRACT_VERSION) {
|
|
return { ok: false, error: { code: "invalid_hwpod_node_ops_contract", message: `contractVersion must be ${HWPOD_NODE_OPS_CONTRACT_VERSION}`, actual: value.contractVersion ?? null } };
|
|
}
|
|
const planId = safeOpaqueId(value.planId) || `hwpod_plan_${randomUUID()}`;
|
|
const nodeId = safeOpaqueId(value.nodeId);
|
|
const hwpodId = safeOpaqueId(value.hwpodId);
|
|
if (!nodeId) return { ok: false, error: { code: "invalid_hwpod_node_id", message: "nodeId is required" } };
|
|
if (!hwpodId) return { ok: false, error: { code: "invalid_hwpod_id", message: "hwpodId is required" } };
|
|
if (!Array.isArray(value.ops) || value.ops.length === 0) {
|
|
return { ok: false, error: { code: "invalid_hwpod_node_ops", message: "ops must be a non-empty array" } };
|
|
}
|
|
const ops = [];
|
|
for (const [index, item] of value.ops.entries()) {
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
return { ok: false, error: { code: "invalid_hwpod_node_op", message: `ops[${index}] must be a JSON object` } };
|
|
}
|
|
const op = typeof item.op === "string" ? item.op.trim() : "";
|
|
if (!HWPOD_NODE_OPS.has(op)) {
|
|
return { ok: false, error: { code: "unsupported_hwpod_node_op", message: `unsupported hwpod-node op: ${op || "<empty>"}`, supportedOps: Array.from(HWPOD_NODE_OPS) } };
|
|
}
|
|
ops.push({
|
|
opId: safeOpaqueId(item.opId) || `op_${index + 1}`,
|
|
op,
|
|
args: item.args && typeof item.args === "object" && !Array.isArray(item.args) ? item.args : {}
|
|
});
|
|
}
|
|
return {
|
|
ok: true,
|
|
plan: {
|
|
...value,
|
|
planId,
|
|
hwpodId,
|
|
nodeId,
|
|
ops
|
|
}
|
|
};
|
|
}
|
|
|
|
function hwpodNodeOpsBlockedPayload(plan, requestMeta, summary) {
|
|
return {
|
|
ok: false,
|
|
status: "blocked",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
planId: plan.planId,
|
|
hwpodId: plan.hwpodId,
|
|
nodeId: plan.nodeId,
|
|
acceptedOps: plan.ops.length,
|
|
results: plan.ops.map((op) => ({
|
|
opId: op.opId,
|
|
op: op.op,
|
|
ok: false,
|
|
status: "blocked",
|
|
blocker: {
|
|
code: "hwpod_node_unavailable",
|
|
layer: "hwpod-node",
|
|
retryable: true,
|
|
summary,
|
|
userMessage: "hwpod-node 尚未接入执行面;cloud-api 已完成 hwpod-node-ops 合同校验。"
|
|
}
|
|
})),
|
|
blocker: {
|
|
code: "hwpod_node_unavailable",
|
|
layer: "hwpod-node",
|
|
retryable: true,
|
|
summary
|
|
},
|
|
requestMeta
|
|
};
|
|
}
|
|
|
|
async function codeAgentOptions(request, response, options, authOptions = {}) {
|
|
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 = 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) {
|
|
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;
|
|
}
|
|
|
|
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 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();
|
|
}
|