mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-27 20:16:53 +02:00
* 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)
408 lines
12 KiB
TypeScript
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,
|
|
};
|