Files
openclaw/extensions/qa-lab/src/qa-credentials-admin.runtime.ts
Josh Avant 3d07dfbb65 feat(qa-lab): add Convex credential broker and admin CLI (#65596)
* QA Lab: add Convex credential source for Telegram lane

* QA Lab: scaffold Convex credential broker

* QA Lab: add Convex credential admin CLI

* QA Lab: harden Convex credential security paths

* QA Broker: validate Telegram payloads on admin add

* fix: note QA Convex credential broker in changelog (#65596) (thanks @joshavant)
2026-04-12 22:03:42 -05:00

408 lines
12 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { z } from "zod";
const DEFAULT_ENDPOINT_PREFIX = "/qa-credentials/v1";
const DEFAULT_HTTP_TIMEOUT_MS = 15_000;
const ALLOW_INSECURE_HTTP_ENV_KEY = "OPENCLAW_QA_ALLOW_INSECURE_HTTP";
const actorRoleSchema = z.union([z.literal("ci"), z.literal("maintainer")]);
const credentialStatusSchema = z.union([z.literal("active"), z.literal("disabled")]);
const listStatusSchema = z.union([z.literal("active"), z.literal("disabled"), z.literal("all")]);
const brokerErrorSchema = z.object({
status: z.literal("error"),
code: z.string().min(1),
message: z.string().min(1),
});
const credentialLeaseSchema = z.object({
ownerId: z.string().min(1),
actorRole: actorRoleSchema,
acquiredAtMs: z.number().int(),
heartbeatAtMs: z.number().int(),
expiresAtMs: z.number().int(),
});
const credentialRecordSchema = z.object({
credentialId: z.string().min(1),
kind: z.string().min(1),
status: credentialStatusSchema,
createdAtMs: z.number().int(),
updatedAtMs: z.number().int(),
lastLeasedAtMs: z.number().int(),
note: z.string().optional(),
lease: credentialLeaseSchema.optional(),
payload: z.unknown().optional(),
});
const addCredentialResponseSchema = z.object({
status: z.literal("ok"),
credential: credentialRecordSchema,
});
const removeCredentialResponseSchema = z.object({
status: z.literal("ok"),
changed: z.boolean(),
credential: credentialRecordSchema,
});
const listCredentialsResponseSchema = z.object({
status: z.literal("ok"),
credentials: z.array(credentialRecordSchema),
count: z.number().int().nonnegative().optional(),
});
export type QaCredentialAdminListStatus = z.infer<typeof listStatusSchema>;
export type QaCredentialRecord = z.infer<typeof credentialRecordSchema>;
export type QaCredentialListResponse = z.infer<typeof listCredentialsResponseSchema>;
export class QaCredentialAdminError extends Error {
code: string;
httpStatus?: number;
constructor(params: { code: string; message: string; httpStatus?: number }) {
super(params.message);
this.name = "QaCredentialAdminError";
this.code = params.code;
this.httpStatus = params.httpStatus;
}
}
type AdminConfig = {
actorId: string;
authToken: string;
addUrl: string;
endpointPrefix: string;
httpTimeoutMs: number;
listUrl: string;
removeUrl: string;
siteUrl: string;
};
type AdminBaseOptions = {
actorId?: string;
endpointPrefix?: string;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
siteUrl?: string;
};
type AddQaCredentialSetOptions = AdminBaseOptions & {
kind: string;
note?: string;
payload: Record<string, unknown>;
status?: z.infer<typeof credentialStatusSchema>;
};
type RemoveQaCredentialSetOptions = AdminBaseOptions & {
credentialId: string;
};
type ListQaCredentialSetsOptions = AdminBaseOptions & {
includePayload?: boolean;
kind?: string;
limit?: number;
status?: string;
};
function parsePositiveIntegerEnv(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
const raw = env[key]?.trim();
if (!raw) {
return fallback;
}
const value = Number(raw);
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
throw new QaCredentialAdminError({
code: "INVALID_ENV",
message: `${key} must be a positive integer.`,
});
}
return value;
}
function isTruthyOptIn(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function isLoopbackHostname(hostname: string) {
return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
}
function normalizeConvexSiteUrl(raw: string, env: NodeJS.ProcessEnv): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new QaCredentialAdminError({
code: "INVALID_SITE_URL",
message: `OPENCLAW_QA_CONVEX_SITE_URL must be a valid URL, got "${raw || "<empty>"}".`,
});
}
if (parsed.protocol === "https:") {
const text = parsed.toString();
return text.endsWith("/") ? text.slice(0, -1) : text;
}
if (parsed.protocol !== "http:") {
throw new QaCredentialAdminError({
code: "INVALID_SITE_URL",
message: "OPENCLAW_QA_CONVEX_SITE_URL must use https://.",
});
}
const allowInsecureHttp = isTruthyOptIn(env[ALLOW_INSECURE_HTTP_ENV_KEY]);
if (!allowInsecureHttp || !isLoopbackHostname(parsed.hostname)) {
throw new QaCredentialAdminError({
code: "INVALID_SITE_URL",
message: `OPENCLAW_QA_CONVEX_SITE_URL must use https://. http:// is only allowed for loopback hosts when ${ALLOW_INSECURE_HTTP_ENV_KEY}=1.`,
});
}
const text = parsed.toString();
return text.endsWith("/") ? text.slice(0, -1) : text;
}
function normalizeEndpointPrefix(value: string | undefined): string {
const trimmed = value?.trim();
if (!trimmed) {
return DEFAULT_ENDPOINT_PREFIX;
}
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
const normalized = prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed;
if (!normalized.startsWith("/") || normalized.startsWith("//")) {
throw new QaCredentialAdminError({
code: "INVALID_ARGUMENT",
message: '--endpoint-prefix must be an absolute path like "/qa-credentials/v1" (not //host).',
});
}
if (normalized.includes("\\") || normalized.split("/").some((segment) => segment === "..")) {
throw new QaCredentialAdminError({
code: "INVALID_ARGUMENT",
message: '--endpoint-prefix must not contain backslashes or ".." path segments.',
});
}
return normalized;
}
function joinEndpoint(baseUrl: string, prefix: string, suffix: string): string {
const normalizedSuffix = suffix.startsWith("/") ? suffix : `/${suffix}`;
const url = new URL(baseUrl);
url.pathname = `${prefix}${normalizedSuffix}`.replace(/\/{2,}/gu, "/");
url.search = "";
url.hash = "";
return url.toString();
}
function resolveAdminAuthToken(env: NodeJS.ProcessEnv): string {
const token = env.OPENCLAW_QA_CONVEX_SECRET_MAINTAINER?.trim();
if (token) {
return token;
}
throw new QaCredentialAdminError({
code: "MISSING_MAINTAINER_SECRET",
message: "Missing OPENCLAW_QA_CONVEX_SECRET_MAINTAINER for qa credential admin commands.",
});
}
function resolveAdminConfig(options: AdminBaseOptions): AdminConfig {
const env = options.env ?? process.env;
const siteUrl = options.siteUrl?.trim() || env.OPENCLAW_QA_CONVEX_SITE_URL?.trim();
if (!siteUrl) {
throw new QaCredentialAdminError({
code: "MISSING_SITE_URL",
message: "Missing OPENCLAW_QA_CONVEX_SITE_URL for qa credential admin commands.",
});
}
const normalizedSiteUrl = normalizeConvexSiteUrl(siteUrl, env);
const endpointPrefix = normalizeEndpointPrefix(
options.endpointPrefix?.trim() || env.OPENCLAW_QA_CONVEX_ENDPOINT_PREFIX,
);
const actorId =
options.actorId?.trim() ||
env.OPENCLAW_QA_CREDENTIAL_OWNER_ID?.trim() ||
`qa-lab-admin-${process.pid}-${randomUUID().slice(0, 8)}`;
return {
actorId,
authToken: resolveAdminAuthToken(env),
siteUrl: normalizedSiteUrl,
endpointPrefix,
httpTimeoutMs: parsePositiveIntegerEnv(
env,
"OPENCLAW_QA_CREDENTIAL_HTTP_TIMEOUT_MS",
DEFAULT_HTTP_TIMEOUT_MS,
),
addUrl: joinEndpoint(normalizedSiteUrl, endpointPrefix, "admin/add"),
removeUrl: joinEndpoint(normalizedSiteUrl, endpointPrefix, "admin/remove"),
listUrl: joinEndpoint(normalizedSiteUrl, endpointPrefix, "admin/list"),
};
}
function parseJsonResponsePayload(text: string) {
if (!text.trim()) {
return undefined;
}
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function toBrokerError(payload: unknown, httpStatus: number) {
const parsed = brokerErrorSchema.safeParse(payload);
if (!parsed.success) {
return null;
}
return new QaCredentialAdminError({
code: parsed.data.code,
message: parsed.data.message,
httpStatus,
});
}
async function postJson<T>(params: {
authToken: string;
body: Record<string, unknown>;
fetchImpl: typeof fetch;
httpTimeoutMs: number;
responseSchema: z.ZodType<T>;
url: string;
}) {
let response: Response;
try {
response = await params.fetchImpl(params.url, {
method: "POST",
headers: {
authorization: `Bearer ${params.authToken}`,
"content-type": "application/json",
},
body: JSON.stringify(params.body),
signal: AbortSignal.timeout(params.httpTimeoutMs),
});
} catch (error) {
throw new QaCredentialAdminError({
code: "BROKER_REQUEST_FAILED",
message: `Convex credential admin request failed: ${formatErrorMessage(error)}`,
});
}
const text = await response.text();
const payload = parseJsonResponsePayload(text);
const brokerError = toBrokerError(payload, response.status);
if (brokerError) {
throw brokerError;
}
if (!response.ok) {
throw new QaCredentialAdminError({
code: "BROKER_HTTP_ERROR",
message: `Convex credential admin request failed with HTTP ${response.status}.`,
httpStatus: response.status,
});
}
const parsed = params.responseSchema.safeParse(payload);
if (!parsed.success) {
throw new QaCredentialAdminError({
code: "INVALID_RESPONSE",
message: `Convex credential admin response did not match expected shape: ${parsed.error.message}`,
httpStatus: response.status,
});
}
return parsed.data;
}
function normalizeStatus(value: string | undefined): QaCredentialAdminListStatus | undefined {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
const parsed = listStatusSchema.safeParse(normalized);
if (!parsed.success) {
throw new QaCredentialAdminError({
code: "INVALID_ARGUMENT",
message: '--status must be one of "active", "disabled", or "all".',
});
}
return parsed.data;
}
function normalizeLimit(value: number | undefined) {
if (value === undefined) {
return undefined;
}
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
throw new QaCredentialAdminError({
code: "INVALID_ARGUMENT",
message: "--limit must be a positive integer.",
});
}
return value;
}
export async function addQaCredentialSet(options: AddQaCredentialSetOptions) {
const config = resolveAdminConfig(options);
const fetchImpl = options.fetchImpl ?? fetch;
return await postJson({
fetchImpl,
authToken: config.authToken,
httpTimeoutMs: config.httpTimeoutMs,
url: config.addUrl,
responseSchema: addCredentialResponseSchema,
body: {
kind: options.kind,
payload: options.payload,
...(options.note ? { note: options.note } : {}),
...(options.status ? { status: options.status } : {}),
actorId: config.actorId,
},
});
}
export async function removeQaCredentialSet(options: RemoveQaCredentialSetOptions) {
const config = resolveAdminConfig(options);
const fetchImpl = options.fetchImpl ?? fetch;
return await postJson({
fetchImpl,
authToken: config.authToken,
httpTimeoutMs: config.httpTimeoutMs,
url: config.removeUrl,
responseSchema: removeCredentialResponseSchema,
body: {
credentialId: options.credentialId,
actorId: config.actorId,
},
});
}
export async function listQaCredentialSets(options: ListQaCredentialSetsOptions) {
const config = resolveAdminConfig(options);
const fetchImpl = options.fetchImpl ?? fetch;
const status = normalizeStatus(options.status);
const limit = normalizeLimit(options.limit);
return await postJson({
fetchImpl,
authToken: config.authToken,
httpTimeoutMs: config.httpTimeoutMs,
url: config.listUrl,
responseSchema: listCredentialsResponseSchema,
body: {
...(options.kind ? { kind: options.kind } : {}),
...(status ? { status } : {}),
...(options.includePayload === true ? { includePayload: true } : {}),
...(limit !== undefined ? { limit } : {}),
},
});
}
export const __testing = {
DEFAULT_ENDPOINT_PREFIX,
DEFAULT_HTTP_TIMEOUT_MS,
normalizeConvexSiteUrl,
normalizeEndpointPrefix,
normalizeStatus,
parsePositiveIntegerEnv,
resolveAdminConfig,
};