Files
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

464 lines
14 KiB
TypeScript

// Http module supports OpenClaw QA credential workflows.
import { httpRouter } from "convex/server";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { httpAction } from "./_generated/server";
import { normalizeCredentialPayloadForKind } from "./payload-validation";
type ActorRole = "ci" | "maintainer";
class BrokerHttpError extends Error {
code: string;
httpStatus: number;
constructor(httpStatus: number, code: string, message: string) {
super(message);
this.name = "BrokerHttpError";
this.httpStatus = httpStatus;
this.code = code;
}
}
function jsonResponse(status: number, payload: unknown) {
return new Response(JSON.stringify(payload), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
},
});
}
function parseBearerToken(request: Request) {
const header = request.headers.get("authorization")?.trim();
if (!header) {
return null;
}
const [scheme, token] = header.split(/\s+/u, 2);
if (scheme?.toLowerCase() !== "bearer" || !token) {
return null;
}
return token;
}
function resolveAuthRole(token: string | null): ActorRole {
if (!token) {
throw new BrokerHttpError(
401,
"AUTH_REQUIRED",
"Missing Authorization: Bearer <secret> header.",
);
}
const maintainerSecret = process.env.OPENCLAW_QA_CONVEX_SECRET_MAINTAINER?.trim();
const ciSecret = process.env.OPENCLAW_QA_CONVEX_SECRET_CI?.trim();
if (!maintainerSecret && !ciSecret) {
throw new BrokerHttpError(
500,
"SERVER_MISCONFIGURED",
"No Convex broker role secrets are configured on this deployment.",
);
}
if (maintainerSecret && token === maintainerSecret) {
return "maintainer";
}
if (ciSecret && token === ciSecret) {
return "ci";
}
throw new BrokerHttpError(401, "AUTH_INVALID", "Credential broker secret is invalid.");
}
function assertMaintainerAdminAuth(token: string | null) {
if (!token) {
throw new BrokerHttpError(
401,
"AUTH_REQUIRED",
"Missing Authorization: Bearer <secret> header.",
);
}
const maintainerSecret = process.env.OPENCLAW_QA_CONVEX_SECRET_MAINTAINER?.trim();
if (!maintainerSecret) {
throw new BrokerHttpError(
500,
"SERVER_MISCONFIGURED",
"Admin endpoints require OPENCLAW_QA_CONVEX_SECRET_MAINTAINER on this deployment.",
);
}
if (token === maintainerSecret) {
return;
}
const ciSecret = process.env.OPENCLAW_QA_CONVEX_SECRET_CI?.trim();
if (ciSecret && token === ciSecret) {
throw new BrokerHttpError(
403,
"AUTH_ROLE_MISMATCH",
"Admin endpoints require maintainer credentials.",
);
}
throw new BrokerHttpError(401, "AUTH_INVALID", "Credential broker secret is invalid.");
}
function asObject(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
async function parseJsonObject(request: Request) {
let parsed: unknown;
try {
parsed = await request.json();
} catch {
throw new BrokerHttpError(400, "INVALID_JSON", "Request body must be valid JSON.");
}
const body = asObject(parsed);
if (!body) {
throw new BrokerHttpError(400, "INVALID_BODY", "Request body must be a JSON object.");
}
return body;
}
function requireString(body: Record<string, unknown>, key: string) {
const raw = body[key];
if (typeof raw !== "string") {
throw new BrokerHttpError(400, "INVALID_BODY", `Expected "${key}" to be a string.`);
}
const value = raw.trim();
if (!value) {
throw new BrokerHttpError(400, "INVALID_BODY", `Expected "${key}" to be non-empty.`);
}
return value;
}
function optionalString(body: Record<string, unknown>, key: string) {
if (!(key in body) || body[key] === undefined || body[key] === null) {
return undefined;
}
const raw = body[key];
if (typeof raw !== "string") {
throw new BrokerHttpError(400, "INVALID_BODY", `Expected "${key}" to be a string.`);
}
const value = raw.trim();
return value.length > 0 ? value : undefined;
}
function requireObject(body: Record<string, unknown>, key: string) {
const raw = body[key];
const parsed = asObject(raw);
if (!parsed) {
throw new BrokerHttpError(400, "INVALID_BODY", `Expected "${key}" to be a JSON object.`);
}
return parsed;
}
function optionalPositiveInteger(body: Record<string, unknown>, key: string) {
if (!(key in body) || body[key] === undefined || body[key] === null) {
return undefined;
}
const raw = body[key];
if (typeof raw !== "number" || !Number.isFinite(raw) || !Number.isInteger(raw) || raw < 1) {
throw new BrokerHttpError(400, "INVALID_BODY", `Expected "${key}" to be a positive integer.`);
}
return raw;
}
function optionalNonnegativeInteger(body: Record<string, unknown>, key: string) {
if (!(key in body) || body[key] === undefined || body[key] === null) {
return undefined;
}
const raw = body[key];
if (typeof raw !== "number" || !Number.isFinite(raw) || !Number.isInteger(raw) || raw < 0) {
throw new BrokerHttpError(
400,
"INVALID_BODY",
`Expected "${key}" to be a non-negative integer.`,
);
}
return raw;
}
function optionalBoolean(body: Record<string, unknown>, key: string) {
if (!(key in body) || body[key] === undefined || body[key] === null) {
return undefined;
}
if (typeof body[key] !== "boolean") {
throw new BrokerHttpError(400, "INVALID_BODY", `Expected "${key}" to be a boolean.`);
}
return body[key];
}
function optionalCredentialStatus(body: Record<string, unknown>, key: string) {
const value = optionalString(body, key);
if (!value) {
return undefined;
}
if (value !== "active" && value !== "disabled") {
throw new BrokerHttpError(
400,
"INVALID_BODY",
`Expected "${key}" to be "active" or "disabled".`,
);
}
return value;
}
function optionalListStatus(body: Record<string, unknown>, key: string) {
const value = optionalString(body, key);
if (!value) {
return undefined;
}
if (value !== "active" && value !== "disabled" && value !== "all") {
throw new BrokerHttpError(
400,
"INVALID_BODY",
`Expected "${key}" to be "active", "disabled", or "all".`,
);
}
return value;
}
function parseActorRole(body: Record<string, unknown>) {
const actorRole = requireString(body, "actorRole");
if (actorRole !== "ci" && actorRole !== "maintainer") {
throw new BrokerHttpError(
400,
"INVALID_ACTOR_ROLE",
'Expected "actorRole" to be "maintainer" or "ci".',
);
}
return actorRole as ActorRole;
}
function assertRoleAllowed(tokenRole: ActorRole, requestedRole: ActorRole) {
if (tokenRole !== requestedRole) {
throw new BrokerHttpError(
403,
"AUTH_ROLE_MISMATCH",
`Secret role "${tokenRole}" cannot be used as actorRole "${requestedRole}".`,
);
}
}
function normalizeCredentialId(raw: string) {
// Convex Ids are opaque strings. We only enforce non-empty shape at HTTP boundary.
return raw;
}
function normalizeError(error: unknown) {
if (error instanceof BrokerHttpError) {
return {
httpStatus: error.httpStatus,
payload: {
status: "error",
code: error.code,
message: error.message,
},
};
}
if (error instanceof Error) {
return {
httpStatus: 500,
payload: {
status: "error",
code: "INTERNAL_ERROR",
message: error.message || "Internal credential broker error.",
},
};
}
return {
httpStatus: 500,
payload: {
status: "error",
code: "INTERNAL_ERROR",
message: "Internal credential broker error.",
},
};
}
const http = httpRouter();
http.route({
path: "/qa-credentials/v1/acquire",
method: "POST",
handler: httpAction(async (ctx, request) => {
try {
const tokenRole = resolveAuthRole(parseBearerToken(request));
const body = await parseJsonObject(request);
const actorRole = parseActorRole(body);
assertRoleAllowed(tokenRole, actorRole);
const result = await ctx.runMutation(internal.credentials.acquireLease, {
kind: requireString(body, "kind"),
ownerId: requireString(body, "ownerId"),
actorRole,
leaseTtlMs: optionalPositiveInteger(body, "leaseTtlMs"),
heartbeatIntervalMs: optionalPositiveInteger(body, "heartbeatIntervalMs"),
});
return jsonResponse(200, result);
} catch (error) {
const normalized = normalizeError(error);
return jsonResponse(normalized.httpStatus, normalized.payload);
}
}),
});
http.route({
path: "/qa-credentials/v1/heartbeat",
method: "POST",
handler: httpAction(async (ctx, request) => {
try {
const tokenRole = resolveAuthRole(parseBearerToken(request));
const body = await parseJsonObject(request);
const actorRole = parseActorRole(body);
assertRoleAllowed(tokenRole, actorRole);
const result = await ctx.runMutation(internal.credentials.heartbeatLease, {
kind: requireString(body, "kind"),
ownerId: requireString(body, "ownerId"),
actorRole,
credentialId: normalizeCredentialId(
requireString(body, "credentialId"),
) as Id<"credential_sets">,
leaseToken: requireString(body, "leaseToken"),
leaseTtlMs: optionalPositiveInteger(body, "leaseTtlMs"),
});
return jsonResponse(200, result);
} catch (error) {
const normalized = normalizeError(error);
return jsonResponse(normalized.httpStatus, normalized.payload);
}
}),
});
http.route({
path: "/qa-credentials/v1/payload-chunk",
method: "POST",
handler: httpAction(async (ctx, request) => {
try {
const tokenRole = resolveAuthRole(parseBearerToken(request));
const body = await parseJsonObject(request);
const actorRole = parseActorRole(body);
assertRoleAllowed(tokenRole, actorRole);
const result = await ctx.runQuery(internal.credentials.getPayloadChunk, {
kind: requireString(body, "kind"),
ownerId: requireString(body, "ownerId"),
actorRole,
credentialId: normalizeCredentialId(
requireString(body, "credentialId"),
) as Id<"credential_sets">,
leaseToken: requireString(body, "leaseToken"),
index: optionalNonnegativeInteger(body, "index") ?? 0,
});
return jsonResponse(200, result);
} catch (error) {
const normalized = normalizeError(error);
return jsonResponse(normalized.httpStatus, normalized.payload);
}
}),
});
http.route({
path: "/qa-credentials/v1/release",
method: "POST",
handler: httpAction(async (ctx, request) => {
try {
const tokenRole = resolveAuthRole(parseBearerToken(request));
const body = await parseJsonObject(request);
const actorRole = parseActorRole(body);
assertRoleAllowed(tokenRole, actorRole);
const result = await ctx.runMutation(internal.credentials.releaseLease, {
kind: requireString(body, "kind"),
ownerId: requireString(body, "ownerId"),
actorRole,
credentialId: normalizeCredentialId(
requireString(body, "credentialId"),
) as Id<"credential_sets">,
leaseToken: requireString(body, "leaseToken"),
});
return jsonResponse(200, result);
} catch (error) {
const normalized = normalizeError(error);
return jsonResponse(normalized.httpStatus, normalized.payload);
}
}),
});
http.route({
path: "/qa-credentials/v1/admin/add",
method: "POST",
handler: httpAction(async (ctx, request) => {
try {
assertMaintainerAdminAuth(parseBearerToken(request));
const body = await parseJsonObject(request);
const kind = requireString(body, "kind");
const payload = normalizeCredentialPayloadForKind(
kind,
requireObject(body, "payload"),
(httpStatus, code, message) => new BrokerHttpError(httpStatus, code, message),
);
const result = await ctx.runMutation(internal.credentials.addCredentialSet, {
kind,
payload,
note: optionalString(body, "note"),
actorId: optionalString(body, "actorId"),
status: optionalCredentialStatus(body, "status"),
});
return jsonResponse(200, result);
} catch (error) {
const normalized = normalizeError(error);
return jsonResponse(normalized.httpStatus, normalized.payload);
}
}),
});
http.route({
path: "/qa-credentials/v1/admin/remove",
method: "POST",
handler: httpAction(async (ctx, request) => {
try {
assertMaintainerAdminAuth(parseBearerToken(request));
const body = await parseJsonObject(request);
const result = await ctx.runMutation(internal.credentials.disableCredentialSet, {
credentialId: normalizeCredentialId(
requireString(body, "credentialId"),
) as Id<"credential_sets">,
actorId: optionalString(body, "actorId"),
});
return jsonResponse(200, result);
} catch (error) {
const normalized = normalizeError(error);
return jsonResponse(normalized.httpStatus, normalized.payload);
}
}),
});
http.route({
path: "/qa-credentials/v1/admin/list",
method: "POST",
handler: httpAction(async (ctx, request) => {
try {
assertMaintainerAdminAuth(parseBearerToken(request));
const body = await parseJsonObject(request);
const result = await ctx.runQuery(internal.credentials.listCredentialSets, {
kind: optionalString(body, "kind"),
status: optionalListStatus(body, "status"),
includePayload: optionalBoolean(body, "includePayload"),
limit: optionalPositiveInteger(body, "limit"),
});
return jsonResponse(200, result);
} catch (error) {
const normalized = normalizeError(error);
return jsonResponse(normalized.httpStatus, normalized.payload);
}
}),
});
export default http;