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)
This commit is contained in:
Josh Avant
2026-04-12 22:03:42 -05:00
committed by GitHub
parent 5da237c887
commit 3d07dfbb65
25 changed files with 3678 additions and 125 deletions

View File

@@ -6,6 +6,7 @@ Files:
- `scenarios.md` - canonical QA scenario pack, kickoff mission, and operator identity.
- `frontier-harness-plan.md` - big-model bakeoff and tuning loop for harness work.
- `convex-credential-broker/` - standalone Convex v1 lease broker for pooled live credentials.
Key workflow:

View File

@@ -0,0 +1,4 @@
.convex
convex/_generated
.env.local

View File

@@ -0,0 +1,165 @@
# QA Convex Credential Broker (v1)
Standalone Convex project for shared `qa-lab` live credentials with lease locking.
This broker exposes:
- `POST /qa-credentials/v1/acquire`
- `POST /qa-credentials/v1/heartbeat`
- `POST /qa-credentials/v1/release`
- `POST /qa-credentials/v1/admin/add`
- `POST /qa-credentials/v1/admin/remove`
- `POST /qa-credentials/v1/admin/list`
The implementation matches the contract documented in
`docs/help/testing.md` for `--credential-source convex`.
## Policy baked in
- Pool partitioning: by `kind` only
- Selection: least-recently-leased (round-robin behavior)
- Secrets: separate maintainer/CI secrets
- Outage behavior: callers fail fast
- Lease event retention: 2 days (hourly cleanup cron)
- Admin event retention: 30 days (hourly cleanup cron)
- App-level encryption: not included in v1
## Quick start
1. Create a Convex deployment and authenticate your CLI.
2. From this folder:
```bash
cd qa/convex-credential-broker
npm install
npx convex dev
```
3. Deploy:
```bash
npx convex deploy
```
4. In Convex deployment environment variables, set:
- `OPENCLAW_QA_CONVEX_SECRET_MAINTAINER`
- `OPENCLAW_QA_CONVEX_SECRET_CI`
Client URL policy:
- `OPENCLAW_QA_CONVEX_SITE_URL` must use `https://` in normal use.
- Local development may use loopback `http://` only when `OPENCLAW_QA_ALLOW_INSECURE_HTTP=1`.
## Manage credentials from qa-lab CLI
Maintainers can manage rows without using the Convex dashboard:
```bash
pnpm openclaw qa credentials add \
--kind telegram \
--payload-file qa/telegram-credential.json
pnpm openclaw qa credentials list --kind telegram
pnpm openclaw qa credentials remove --credential-id <credential-id>
```
Admin endpoints require `OPENCLAW_QA_CONVEX_SECRET_MAINTAINER`.
## Local request examples
Replace `<site-url>` with your Convex site URL and `<token>` with a configured secret.
Acquire:
```bash
curl -sS -X POST "<site-url>/qa-credentials/v1/acquire" \
-H "authorization: Bearer <token>" \
-H "content-type: application/json" \
-d '{
"kind":"telegram",
"ownerId":"local-dev",
"actorRole":"maintainer",
"leaseTtlMs":1200000,
"heartbeatIntervalMs":30000
}'
```
Heartbeat:
```bash
curl -sS -X POST "<site-url>/qa-credentials/v1/heartbeat" \
-H "authorization: Bearer <token>" \
-H "content-type: application/json" \
-d '{
"kind":"telegram",
"ownerId":"local-dev",
"actorRole":"maintainer",
"credentialId":"<credential-id>",
"leaseToken":"<lease-token>",
"leaseTtlMs":1200000
}'
```
Release:
```bash
curl -sS -X POST "<site-url>/qa-credentials/v1/release" \
-H "authorization: Bearer <token>" \
-H "content-type: application/json" \
-d '{
"kind":"telegram",
"ownerId":"local-dev",
"actorRole":"maintainer",
"credentialId":"<credential-id>",
"leaseToken":"<lease-token>"
}'
```
Admin add (maintainer token only):
```bash
curl -sS -X POST "<site-url>/qa-credentials/v1/admin/add" \
-H "authorization: Bearer <maintainer-token>" \
-H "content-type: application/json" \
-d '{
"kind":"telegram",
"actorId":"local-maintainer",
"payload":{
"groupId":"-100123",
"driverToken":"driver-token",
"sutToken":"sut-token"
}
}'
```
For `kind: "telegram"`, broker `admin/add` validates that payload includes:
- `groupId` as a numeric chat id string
- non-empty `driverToken`
- non-empty `sutToken`
Admin list (default redacted):
```bash
curl -sS -X POST "<site-url>/qa-credentials/v1/admin/list" \
-H "authorization: Bearer <maintainer-token>" \
-H "content-type: application/json" \
-d '{
"kind":"telegram",
"status":"all"
}'
```
Admin remove (soft disable, fails when lease is active):
```bash
curl -sS -X POST "<site-url>/qa-credentials/v1/admin/remove" \
-H "authorization: Bearer <maintainer-token>" \
-H "content-type: application/json" \
-d '{
"credentialId":"<credential-id>",
"actorId":"local-maintainer"
}'
```

View File

@@ -0,0 +1,3 @@
{
"functions": "convex/"
}

View File

@@ -0,0 +1,642 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { internalMutation, internalQuery } from "./_generated/server";
const LEASE_EVENT_RETENTION_MS = 2 * 24 * 60 * 60 * 1_000;
const ADMIN_EVENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
const EVENT_RETENTION_BATCH_SIZE = 256;
const MAX_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1_000;
const MAX_LEASE_TTL_MS = 2 * 60 * 60 * 1_000;
const MIN_HEARTBEAT_INTERVAL_MS = 5_000;
const MIN_LEASE_TTL_MS = 30_000;
const MAX_LIST_LIMIT = 500;
const MIN_LIST_LIMIT = 1;
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
const DEFAULT_LEASE_TTL_MS = 20 * 60 * 1_000;
const DEFAULT_LIST_LIMIT = 100;
const POOL_EXHAUSTED_RETRY_AFTER_MS = 2_000;
const actorRole = v.union(v.literal("ci"), v.literal("maintainer"));
const credentialStatus = v.union(v.literal("active"), v.literal("disabled"));
const listStatus = v.union(v.literal("active"), v.literal("disabled"), v.literal("all"));
type ActorRole = "ci" | "maintainer";
type CredentialStatus = "active" | "disabled";
type ListStatus = CredentialStatus | "all";
type LeaseEventType = "acquire" | "acquire_failed" | "release";
type AdminEventType = "add" | "disable" | "disable_failed";
type BrokerErrorResult = {
status: "error";
code: string;
message: string;
retryAfterMs?: number;
};
type BrokerOkResult = {
status: "ok";
};
type CredentialLease = {
ownerId: string;
actorRole: ActorRole;
leaseToken: string;
acquiredAtMs: number;
heartbeatAtMs: number;
expiresAtMs: number;
};
type CredentialSetRecord = {
_id: Id<"credential_sets">;
kind: string;
status: CredentialStatus;
payload: unknown;
createdAtMs: number;
updatedAtMs: number;
lastLeasedAtMs: number;
note?: string;
lease?: CredentialLease;
};
type EventInsertCtx = {
db: {
insert: (
table: "lease_events" | "admin_events",
value: Record<string, unknown>,
) => Promise<unknown>;
};
};
function normalizeIntervalMs(params: {
value: number | undefined;
fallback: number;
min: number;
max: number;
}) {
const value = params.value ?? params.fallback;
const rounded = Math.floor(value);
if (!Number.isFinite(rounded) || rounded < params.min || rounded > params.max) {
return null;
}
return rounded;
}
function normalizeListLimit(value: number | undefined) {
const limit = value ?? DEFAULT_LIST_LIMIT;
const rounded = Math.floor(limit);
if (!Number.isFinite(rounded) || rounded < MIN_LIST_LIMIT || rounded > MAX_LIST_LIMIT) {
return null;
}
return rounded;
}
function brokerError(code: string, message: string, retryAfterMs?: number): BrokerErrorResult {
return retryAfterMs && retryAfterMs > 0
? {
status: "error",
code,
message,
retryAfterMs,
}
: {
status: "error",
code,
message,
};
}
function leaseIsActive(lease: CredentialLease | undefined, nowMs: number) {
return Boolean(lease && lease.expiresAtMs > nowMs);
}
function toCredentialSummary(row: CredentialSetRecord, includePayload: boolean) {
return {
credentialId: row._id,
kind: row.kind,
status: row.status,
createdAtMs: row.createdAtMs,
updatedAtMs: row.updatedAtMs,
lastLeasedAtMs: row.lastLeasedAtMs,
...(row.note ? { note: row.note } : {}),
...(row.lease
? {
lease: {
ownerId: row.lease.ownerId,
actorRole: row.lease.actorRole,
acquiredAtMs: row.lease.acquiredAtMs,
heartbeatAtMs: row.lease.heartbeatAtMs,
expiresAtMs: row.lease.expiresAtMs,
},
}
: {}),
...(includePayload ? { payload: row.payload } : {}),
};
}
async function insertLeaseEvent(params: {
ctx: EventInsertCtx;
kind: string;
eventType: LeaseEventType;
actorRole: ActorRole;
ownerId: string;
occurredAtMs: number;
credentialId?: Id<"credential_sets">;
code?: string;
message?: string;
}) {
await params.ctx.db.insert("lease_events", {
kind: params.kind,
eventType: params.eventType,
actorRole: params.actorRole,
ownerId: params.ownerId,
occurredAtMs: params.occurredAtMs,
...(params.credentialId ? { credentialId: params.credentialId } : {}),
...(params.code ? { code: params.code } : {}),
...(params.message ? { message: params.message } : {}),
});
}
async function insertAdminEvent(params: {
ctx: EventInsertCtx;
eventType: AdminEventType;
actorRole: ActorRole;
actorId: string;
occurredAtMs: number;
credentialId?: Id<"credential_sets">;
kind?: string;
code?: string;
message?: string;
}) {
await params.ctx.db.insert("admin_events", {
eventType: params.eventType,
actorRole: params.actorRole,
actorId: params.actorId,
occurredAtMs: params.occurredAtMs,
...(params.credentialId ? { credentialId: params.credentialId } : {}),
...(params.kind ? { kind: params.kind } : {}),
...(params.code ? { code: params.code } : {}),
...(params.message ? { message: params.message } : {}),
});
}
function sortByLeastRecentlyLeasedThenId(
rows: Array<{
_id: Id<"credential_sets">;
lastLeasedAtMs: number;
}>,
) {
rows.sort((left, right) => {
if (left.lastLeasedAtMs !== right.lastLeasedAtMs) {
return left.lastLeasedAtMs - right.lastLeasedAtMs;
}
const leftId = String(left._id);
const rightId = String(right._id);
return leftId.localeCompare(rightId);
});
}
function sortCredentialRowsForList(rows: CredentialSetRecord[]) {
const statusRank: Record<CredentialStatus, number> = { active: 0, disabled: 1 };
rows.sort((left, right) => {
const kindCompare = left.kind.localeCompare(right.kind);
if (kindCompare !== 0) {
return kindCompare;
}
if (left.status !== right.status) {
return statusRank[left.status] - statusRank[right.status];
}
if (left.updatedAtMs !== right.updatedAtMs) {
return right.updatedAtMs - left.updatedAtMs;
}
return String(left._id).localeCompare(String(right._id));
});
}
function normalizeActorId(value: string | undefined) {
const normalized = value?.trim();
return normalized && normalized.length > 0 ? normalized : "unknown";
}
export const acquireLease = internalMutation({
args: {
kind: v.string(),
ownerId: v.string(),
actorRole,
leaseTtlMs: v.optional(v.number()),
heartbeatIntervalMs: v.optional(v.number()),
},
handler: async (ctx, args) => {
const nowMs = Date.now();
const leaseTtlMs = normalizeIntervalMs({
value: args.leaseTtlMs,
fallback: DEFAULT_LEASE_TTL_MS,
min: MIN_LEASE_TTL_MS,
max: MAX_LEASE_TTL_MS,
});
if (!leaseTtlMs) {
return brokerError(
"INVALID_LEASE_TTL",
`leaseTtlMs must be between ${MIN_LEASE_TTL_MS} and ${MAX_LEASE_TTL_MS}.`,
);
}
const heartbeatIntervalMs = normalizeIntervalMs({
value: args.heartbeatIntervalMs,
fallback: DEFAULT_HEARTBEAT_INTERVAL_MS,
min: MIN_HEARTBEAT_INTERVAL_MS,
max: MAX_HEARTBEAT_INTERVAL_MS,
});
if (!heartbeatIntervalMs) {
return brokerError(
"INVALID_HEARTBEAT_INTERVAL",
`heartbeatIntervalMs must be between ${MIN_HEARTBEAT_INTERVAL_MS} and ${MAX_HEARTBEAT_INTERVAL_MS}.`,
);
}
const activeRows = (await ctx.db
.query("credential_sets")
.withIndex("by_kind_status", (q) => q.eq("kind", args.kind).eq("status", "active"))
.collect()) as CredentialSetRecord[];
const availableRows = activeRows.filter((row) => !leaseIsActive(row.lease, nowMs));
if (availableRows.length === 0) {
await insertLeaseEvent({
ctx,
kind: args.kind,
eventType: "acquire_failed",
actorRole: args.actorRole,
ownerId: args.ownerId,
occurredAtMs: nowMs,
code: "POOL_EXHAUSTED",
message: "No active credential in this kind is currently available.",
});
return brokerError(
"POOL_EXHAUSTED",
`No available credential for kind "${args.kind}".`,
POOL_EXHAUSTED_RETRY_AFTER_MS,
);
}
sortByLeastRecentlyLeasedThenId(availableRows);
const selected = availableRows[0];
const leaseToken = crypto.randomUUID();
await ctx.db.patch(selected._id, {
lease: {
ownerId: args.ownerId,
actorRole: args.actorRole,
leaseToken,
acquiredAtMs: nowMs,
heartbeatAtMs: nowMs,
expiresAtMs: nowMs + leaseTtlMs,
},
lastLeasedAtMs: nowMs,
updatedAtMs: nowMs,
});
await insertLeaseEvent({
ctx,
kind: args.kind,
eventType: "acquire",
actorRole: args.actorRole,
ownerId: args.ownerId,
occurredAtMs: nowMs,
credentialId: selected._id,
});
return {
status: "ok",
credentialId: selected._id,
leaseToken,
payload: selected.payload,
leaseTtlMs,
heartbeatIntervalMs,
};
},
});
export const heartbeatLease = internalMutation({
args: {
kind: v.string(),
ownerId: v.string(),
actorRole,
credentialId: v.id("credential_sets"),
leaseToken: v.string(),
leaseTtlMs: v.optional(v.number()),
},
handler: async (ctx, args): Promise<BrokerErrorResult | BrokerOkResult> => {
const nowMs = Date.now();
const leaseTtlMs = normalizeIntervalMs({
value: args.leaseTtlMs,
fallback: DEFAULT_LEASE_TTL_MS,
min: MIN_LEASE_TTL_MS,
max: MAX_LEASE_TTL_MS,
});
if (!leaseTtlMs) {
return brokerError(
"INVALID_LEASE_TTL",
`leaseTtlMs must be between ${MIN_LEASE_TTL_MS} and ${MAX_LEASE_TTL_MS}.`,
);
}
const row = (await ctx.db.get(args.credentialId)) as CredentialSetRecord | null;
if (!row) {
return brokerError("CREDENTIAL_NOT_FOUND", "Credential record does not exist.");
}
if (row.kind !== args.kind) {
return brokerError("KIND_MISMATCH", "Credential kind did not match this lease heartbeat.");
}
if (row.status !== "active") {
return brokerError(
"CREDENTIAL_DISABLED",
"Credential is disabled and cannot be heartbeated.",
);
}
if (!row.lease) {
return brokerError("LEASE_NOT_FOUND", "Credential is not currently leased.");
}
if (row.lease.ownerId !== args.ownerId || row.lease.leaseToken !== args.leaseToken) {
return brokerError("LEASE_NOT_OWNER", "Credential lease owner/token mismatch.");
}
if (row.lease.expiresAtMs < nowMs) {
return brokerError("LEASE_EXPIRED", "Credential lease has already expired.");
}
await ctx.db.patch(args.credentialId, {
lease: {
...row.lease,
heartbeatAtMs: nowMs,
expiresAtMs: nowMs + leaseTtlMs,
},
updatedAtMs: nowMs,
});
return { status: "ok" };
},
});
export const releaseLease = internalMutation({
args: {
kind: v.string(),
ownerId: v.string(),
actorRole,
credentialId: v.id("credential_sets"),
leaseToken: v.string(),
},
handler: async (ctx, args): Promise<BrokerErrorResult | BrokerOkResult> => {
const nowMs = Date.now();
const row = (await ctx.db.get(args.credentialId)) as CredentialSetRecord | null;
if (!row) {
return brokerError("CREDENTIAL_NOT_FOUND", "Credential record does not exist.");
}
if (row.kind !== args.kind) {
return brokerError("KIND_MISMATCH", "Credential kind did not match this lease release.");
}
if (!row.lease) {
return { status: "ok" };
}
if (row.lease.ownerId !== args.ownerId || row.lease.leaseToken !== args.leaseToken) {
return brokerError("LEASE_NOT_OWNER", "Credential lease owner/token mismatch.");
}
await ctx.db.patch(args.credentialId, {
lease: undefined,
updatedAtMs: nowMs,
});
await insertLeaseEvent({
ctx,
kind: args.kind,
eventType: "release",
actorRole: args.actorRole,
ownerId: args.ownerId,
occurredAtMs: nowMs,
credentialId: args.credentialId,
});
return { status: "ok" };
},
});
export const addCredentialSet = internalMutation({
args: {
kind: v.string(),
payload: v.any(),
note: v.optional(v.string()),
actorId: v.optional(v.string()),
status: v.optional(credentialStatus),
},
handler: async (ctx, args) => {
const nowMs = Date.now();
const actorId = normalizeActorId(args.actorId);
const status = args.status ?? "active";
const note = args.note?.trim();
const credentialId = await ctx.db.insert("credential_sets", {
kind: args.kind,
status,
payload: args.payload,
createdAtMs: nowMs,
updatedAtMs: nowMs,
lastLeasedAtMs: 0,
...(note ? { note } : {}),
});
await insertAdminEvent({
ctx,
eventType: "add",
actorRole: "maintainer",
actorId,
occurredAtMs: nowMs,
credentialId,
kind: args.kind,
});
const created: CredentialSetRecord = {
_id: credentialId,
kind: args.kind,
status,
payload: args.payload,
createdAtMs: nowMs,
updatedAtMs: nowMs,
lastLeasedAtMs: 0,
...(note ? { note } : {}),
};
return {
status: "ok",
credential: toCredentialSummary(created, false),
};
},
});
export const disableCredentialSet = internalMutation({
args: {
credentialId: v.id("credential_sets"),
actorId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const nowMs = Date.now();
const actorId = normalizeActorId(args.actorId);
const row = (await ctx.db.get(args.credentialId)) as CredentialSetRecord | null;
if (!row) {
await insertAdminEvent({
ctx,
eventType: "disable_failed",
actorRole: "maintainer",
actorId,
occurredAtMs: nowMs,
credentialId: args.credentialId,
code: "CREDENTIAL_NOT_FOUND",
message: "Credential record does not exist.",
});
return brokerError("CREDENTIAL_NOT_FOUND", "Credential record does not exist.");
}
if (leaseIsActive(row.lease, nowMs)) {
await insertAdminEvent({
ctx,
eventType: "disable_failed",
actorRole: "maintainer",
actorId,
occurredAtMs: nowMs,
credentialId: row._id,
kind: row.kind,
code: "LEASE_ACTIVE",
message: "Credential is currently leased and cannot be disabled yet.",
});
return brokerError("LEASE_ACTIVE", "Credential is currently leased and cannot be disabled.");
}
if (row.status === "disabled") {
return {
status: "ok",
changed: false,
credential: toCredentialSummary(row, false),
};
}
await ctx.db.patch(args.credentialId, {
status: "disabled",
lease: undefined,
updatedAtMs: nowMs,
});
await insertAdminEvent({
ctx,
eventType: "disable",
actorRole: "maintainer",
actorId,
occurredAtMs: nowMs,
credentialId: row._id,
kind: row.kind,
});
const updated: CredentialSetRecord = {
...row,
status: "disabled",
lease: undefined,
updatedAtMs: nowMs,
};
return {
status: "ok",
changed: true,
credential: toCredentialSummary(updated, false),
};
},
});
export const listCredentialSets = internalQuery({
args: {
kind: v.optional(v.string()),
status: v.optional(listStatus),
includePayload: v.optional(v.boolean()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const normalizedStatus: ListStatus = args.status ?? "all";
const includePayload = args.includePayload === true;
const limit = normalizeListLimit(args.limit);
if (!limit) {
return brokerError(
"INVALID_LIST_LIMIT",
`limit must be between ${MIN_LIST_LIMIT} and ${MAX_LIST_LIMIT}.`,
);
}
let rows: CredentialSetRecord[] = [];
const kind = args.kind?.trim();
if (kind) {
if (normalizedStatus === "all") {
rows = (await ctx.db
.query("credential_sets")
.withIndex("by_kind_lastLeasedAtMs", (q) => q.eq("kind", kind))
.collect()) as CredentialSetRecord[];
} else {
rows = (await ctx.db
.query("credential_sets")
.withIndex("by_kind_status", (q) => q.eq("kind", kind).eq("status", normalizedStatus))
.collect()) as CredentialSetRecord[];
}
} else {
rows = (await ctx.db.query("credential_sets").collect()) as CredentialSetRecord[];
if (normalizedStatus !== "all") {
rows = rows.filter((row) => row.status === normalizedStatus);
}
}
sortCredentialRowsForList(rows);
const selected = rows.slice(0, limit);
return {
status: "ok",
credentials: selected.map((row) => toCredentialSummary(row, includePayload)),
count: selected.length,
};
},
});
export const cleanupLeaseEvents = internalMutation({
args: {},
handler: async (ctx) => {
const cutoffMs = Date.now() - LEASE_EVENT_RETENTION_MS;
const staleRows = await ctx.db
.query("lease_events")
.withIndex("by_occurredAtMs", (q) => q.lt("occurredAtMs", cutoffMs))
.take(EVENT_RETENTION_BATCH_SIZE);
for (const row of staleRows) {
await ctx.db.delete(row._id);
}
if (staleRows.length === EVENT_RETENTION_BATCH_SIZE) {
await ctx.scheduler.runAfter(0, internal.credentials.cleanupLeaseEvents, {});
}
return {
status: "ok",
deleted: staleRows.length,
retentionMs: LEASE_EVENT_RETENTION_MS,
};
},
});
export const cleanupAdminEvents = internalMutation({
args: {},
handler: async (ctx) => {
const cutoffMs = Date.now() - ADMIN_EVENT_RETENTION_MS;
const staleRows = await ctx.db
.query("admin_events")
.withIndex("by_occurredAtMs", (q) => q.lt("occurredAtMs", cutoffMs))
.take(EVENT_RETENTION_BATCH_SIZE);
for (const row of staleRows) {
await ctx.db.delete(row._id);
}
if (staleRows.length === EVENT_RETENTION_BATCH_SIZE) {
await ctx.scheduler.runAfter(0, internal.credentials.cleanupAdminEvents, {});
}
return {
status: "ok",
deleted: staleRows.length,
retentionMs: ADMIN_EVENT_RETENTION_MS,
};
},
});

View File

@@ -0,0 +1,20 @@
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval(
"qa-credential-lease-event-retention",
{ hours: 1 },
internal.credentials.cleanupLeaseEvents,
{},
);
crons.interval(
"qa-credential-admin-event-retention",
{ hours: 1 },
internal.credentials.cleanupAdminEvents,
{},
);
export default crons;

View File

@@ -0,0 +1,457 @@
import { httpRouter } from "convex/server";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { httpAction } from "./_generated/server";
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 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 requirePayloadString(payload: Record<string, unknown>, key: string, kind: string): string {
const raw = payload[key];
if (typeof raw !== "string") {
throw new BrokerHttpError(
400,
"INVALID_PAYLOAD",
`Credential payload for kind "${kind}" must include "${key}" as a string.`,
);
}
const value = raw.trim();
if (!value) {
throw new BrokerHttpError(
400,
"INVALID_PAYLOAD",
`Credential payload for kind "${kind}" must include a non-empty "${key}" value.`,
);
}
return value;
}
function normalizeCredentialPayloadForKind(kind: string, payload: Record<string, unknown>) {
if (kind !== "telegram") {
return payload;
}
const groupId = requirePayloadString(payload, "groupId", "telegram");
if (!/^-?\d+$/u.test(groupId)) {
throw new BrokerHttpError(
400,
"INVALID_PAYLOAD",
'Credential payload for kind "telegram" must include a numeric "groupId" string.',
);
}
const driverToken = requirePayloadString(payload, "driverToken", "telegram");
const sutToken = requirePayloadString(payload, "sutToken", "telegram");
return {
groupId,
driverToken,
sutToken,
} satisfies Record<string, unknown>;
}
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/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"));
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;

View File

@@ -0,0 +1,63 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
const actorRole = v.union(v.literal("ci"), v.literal("maintainer"));
const credentialStatus = v.union(v.literal("active"), v.literal("disabled"));
const leaseEventType = v.union(
v.literal("acquire"),
v.literal("acquire_failed"),
v.literal("release"),
);
const adminEventType = v.union(v.literal("add"), v.literal("disable"), v.literal("disable_failed"));
export default defineSchema({
credential_sets: defineTable({
kind: v.string(),
status: credentialStatus,
payload: v.any(),
createdAtMs: v.number(),
updatedAtMs: v.number(),
lastLeasedAtMs: v.number(),
note: v.optional(v.string()),
lease: v.optional(
v.object({
ownerId: v.string(),
actorRole,
leaseToken: v.string(),
acquiredAtMs: v.number(),
heartbeatAtMs: v.number(),
expiresAtMs: v.number(),
}),
),
})
.index("by_kind_status", ["kind", "status"])
.index("by_kind_lastLeasedAtMs", ["kind", "lastLeasedAtMs"]),
lease_events: defineTable({
kind: v.string(),
eventType: leaseEventType,
actorRole,
ownerId: v.string(),
occurredAtMs: v.number(),
credentialId: v.optional(v.id("credential_sets")),
code: v.optional(v.string()),
message: v.optional(v.string()),
})
.index("by_occurredAtMs", ["occurredAtMs"])
.index("by_kind_occurredAtMs", ["kind", "occurredAtMs"])
.index("by_credential_occurredAtMs", ["credentialId", "occurredAtMs"]),
admin_events: defineTable({
eventType: adminEventType,
actorRole,
actorId: v.string(),
occurredAtMs: v.number(),
credentialId: v.optional(v.id("credential_sets")),
kind: v.optional(v.string()),
code: v.optional(v.string()),
message: v.optional(v.string()),
})
.index("by_occurredAtMs", ["occurredAtMs"])
.index("by_kind_occurredAtMs", ["kind", "occurredAtMs"])
.index("by_credential_occurredAtMs", ["credentialId", "occurredAtMs"]),
});

View File

@@ -0,0 +1,25 @@
{
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings are required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2023", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}

View File

@@ -0,0 +1,15 @@
{
"name": "@openclaw/qa-convex-credential-broker",
"version": "0.1.0",
"private": true,
"description": "Convex HTTP credential lease broker for OpenClaw QA lab",
"type": "module",
"scripts": {
"dashboard": "convex dashboard",
"deploy": "convex deploy",
"dev": "convex dev"
},
"dependencies": {
"convex": "^1.35.1"
}
}