Vendor OpenClaw source as Adolf fork baseline
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
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
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
This commit is contained in:
209
qa/convex-credential-broker/README.md
Normal file
209
qa/convex-credential-broker/README.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# QA Convex Credential Broker (v1)
|
||||
|
||||
Standalone Convex project for shared `qa-lab` live credentials with lease locking.
|
||||
Keep private operator notes in `~/Projects/manager/docs/`, not in public docs.
|
||||
|
||||
This broker exposes:
|
||||
|
||||
- `POST /qa-credentials/v1/acquire`
|
||||
- `POST /qa-credentials/v1/payload-chunk`
|
||||
- `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 add \
|
||||
--kind discord \
|
||||
--payload-file qa/discord-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`
|
||||
|
||||
For `kind: "telegram-user"`, broker `admin/add` validates one exclusive real-user
|
||||
credential for both the TDLib CLI driver and the Telegram Desktop visual witness:
|
||||
|
||||
- `groupId` as a numeric chat id string
|
||||
- non-empty `sutToken`
|
||||
- `testerUserId` as a numeric Telegram user id string
|
||||
- non-empty `testerUsername`
|
||||
- `telegramApiId` as a numeric string
|
||||
- non-empty `telegramApiHash`
|
||||
- non-empty `tdlibDatabaseEncryptionKey`
|
||||
- non-empty `tdlibArchiveBase64`
|
||||
- `tdlibArchiveSha256` as a SHA-256 hex string
|
||||
- non-empty `desktopTdataArchiveBase64`
|
||||
- `desktopTdataArchiveSha256` as a SHA-256 hex string
|
||||
|
||||
Long-running agent sessions should acquire this lease once, keep it for the
|
||||
whole Crabbox review/repro session, then release it from the same session file.
|
||||
Do not run parallel `telegram-user` jobs against the burner account.
|
||||
|
||||
For `kind: "discord"`, broker `admin/add` validates that payload includes:
|
||||
|
||||
- `guildId` as a Discord snowflake string
|
||||
- `channelId` as a Discord snowflake string
|
||||
- non-empty `driverBotToken`
|
||||
- non-empty `sutBotToken`
|
||||
- `sutApplicationId` as a Discord snowflake string
|
||||
|
||||
For `kind: "whatsapp"`, broker `admin/add` validates that payload includes:
|
||||
|
||||
- `driverPhoneE164` as an E.164 phone number string
|
||||
- `sutPhoneE164` as a distinct E.164 phone number string
|
||||
- non-empty `driverAuthArchiveBase64`
|
||||
- non-empty `sutAuthArchiveBase64`
|
||||
- optional `groupJid`
|
||||
|
||||
Other kinds are currently accepted as pass-through payloads. Add broker-side
|
||||
validation before treating a new kind as a hardened shared pool.
|
||||
|
||||
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"
|
||||
}'
|
||||
```
|
||||
3
qa/convex-credential-broker/convex.json
Normal file
3
qa/convex-credential-broker/convex.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"functions": "convex/"
|
||||
}
|
||||
817
qa/convex-credential-broker/convex/credentials.ts
Normal file
817
qa/convex-credential-broker/convex/credentials.ts
Normal file
@@ -0,0 +1,817 @@
|
||||
// Credentials module supports OpenClaw QA credential workflows.
|
||||
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 PAYLOAD_CHUNK_SIZE = 256_000;
|
||||
const MIN_LIST_LIMIT = 1;
|
||||
const CHUNKED_PAYLOAD_MARKER = "__openclawQaCredentialPayloadChunksV1";
|
||||
|
||||
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 ChunkedCredentialPayloadMarker = {
|
||||
[CHUNKED_PAYLOAD_MARKER]: true;
|
||||
byteLength: number;
|
||||
chunkCount: number;
|
||||
};
|
||||
|
||||
type CredentialPayloadChunkRecord = {
|
||||
_id: unknown;
|
||||
credentialId: Id<"credential_sets">;
|
||||
index: number;
|
||||
data: string;
|
||||
createdAtMs: number;
|
||||
};
|
||||
|
||||
type CredentialPayloadStorage = {
|
||||
chunks: string[];
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
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 isChunkedCredentialPayloadMarker(
|
||||
payload: unknown,
|
||||
): payload is ChunkedCredentialPayloadMarker {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return false;
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
return (
|
||||
record[CHUNKED_PAYLOAD_MARKER] === true &&
|
||||
typeof record.byteLength === "number" &&
|
||||
typeof record.chunkCount === "number"
|
||||
);
|
||||
}
|
||||
|
||||
async function readCredentialPayload(
|
||||
ctx: {
|
||||
db: {
|
||||
query: (table: "credential_payload_chunks") => {
|
||||
withIndex: (
|
||||
indexName: "by_credential_index",
|
||||
range: (q: {
|
||||
eq: (
|
||||
field: "credentialId",
|
||||
value: Id<"credential_sets">,
|
||||
) => {
|
||||
eq: (field: "index", value: number) => unknown;
|
||||
};
|
||||
}) => unknown,
|
||||
) => {
|
||||
collect: () => Promise<CredentialPayloadChunkRecord[]>;
|
||||
};
|
||||
};
|
||||
};
|
||||
},
|
||||
row: CredentialSetRecord,
|
||||
) {
|
||||
if (!isChunkedCredentialPayloadMarker(row.payload)) {
|
||||
return row.payload;
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < row.payload.chunkCount; index += 1) {
|
||||
const rows = await ctx.db
|
||||
.query("credential_payload_chunks")
|
||||
.withIndex("by_credential_index", (q) => q.eq("credentialId", row["_id"]).eq("index", index))
|
||||
.collect();
|
||||
const chunk = rows[0];
|
||||
if (!chunk) {
|
||||
throw new Error(`Credential payload chunk ${index} is missing.`);
|
||||
}
|
||||
chunks.push(chunk.data);
|
||||
}
|
||||
const serialized = chunks.join("");
|
||||
if (serialized.length !== row.payload.byteLength) {
|
||||
throw new Error("Credential payload chunk length mismatch.");
|
||||
}
|
||||
return JSON.parse(serialized) as unknown;
|
||||
}
|
||||
|
||||
function createCredentialPayloadStorage(payload: unknown): CredentialPayloadStorage {
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
const chunks: string[] = [];
|
||||
for (let offset = 0; offset < serializedPayload.length; offset += PAYLOAD_CHUNK_SIZE) {
|
||||
chunks.push(serializedPayload.slice(offset, offset + PAYLOAD_CHUNK_SIZE));
|
||||
}
|
||||
if (chunks.length <= 1) {
|
||||
return { payload, chunks: [] };
|
||||
}
|
||||
return {
|
||||
payload: {
|
||||
[CHUNKED_PAYLOAD_MARKER]: true,
|
||||
byteLength: serializedPayload.length,
|
||||
chunkCount: chunks.length,
|
||||
},
|
||||
chunks,
|
||||
};
|
||||
}
|
||||
|
||||
function toCredentialSummary(
|
||||
row: CredentialSetRecord,
|
||||
includePayload: boolean,
|
||||
resolvedPayload?: unknown,
|
||||
) {
|
||||
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: resolvedPayload ?? 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 getPayloadChunk = internalQuery({
|
||||
args: {
|
||||
kind: v.string(),
|
||||
ownerId: v.string(),
|
||||
actorRole,
|
||||
credentialId: v.id("credential_sets"),
|
||||
leaseToken: v.string(),
|
||||
index: v.number(),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args,
|
||||
): Promise<BrokerErrorResult | { status: "ok"; data: string; index: number }> => {
|
||||
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 payload request.");
|
||||
}
|
||||
if (row.status !== "active") {
|
||||
return brokerError("CREDENTIAL_DISABLED", "Credential is disabled.");
|
||||
}
|
||||
if (!row.lease || row.lease.expiresAtMs < nowMs) {
|
||||
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.actorRole !== args.actorRole) {
|
||||
return brokerError("AUTH_ROLE_MISMATCH", "Credential lease actor role mismatch.");
|
||||
}
|
||||
if (!isChunkedCredentialPayloadMarker(row.payload)) {
|
||||
return brokerError("PAYLOAD_NOT_CHUNKED", "Credential payload is not chunked.");
|
||||
}
|
||||
if (!Number.isInteger(args.index) || args.index < 0 || args.index >= row.payload.chunkCount) {
|
||||
return brokerError("INVALID_CHUNK_INDEX", "Credential payload chunk index is out of range.");
|
||||
}
|
||||
const chunks = (await ctx.db
|
||||
.query("credential_payload_chunks")
|
||||
.withIndex("by_credential_index", (q) =>
|
||||
q.eq("credentialId", args.credentialId).eq("index", args.index),
|
||||
)
|
||||
.collect()) as CredentialPayloadChunkRecord[];
|
||||
const chunk = chunks[0];
|
||||
if (!chunk) {
|
||||
return brokerError("PAYLOAD_CHUNK_MISSING", "Credential payload chunk is missing.");
|
||||
}
|
||||
return { status: "ok", data: chunk.data, index: args.index };
|
||||
},
|
||||
});
|
||||
|
||||
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 storage = createCredentialPayloadStorage(args.payload);
|
||||
const credentialId = await ctx.db.insert("credential_sets", {
|
||||
kind: args.kind,
|
||||
status,
|
||||
payload: storage.payload,
|
||||
createdAtMs: nowMs,
|
||||
updatedAtMs: nowMs,
|
||||
lastLeasedAtMs: 0,
|
||||
...(note ? { note } : {}),
|
||||
});
|
||||
|
||||
for (const [index, data] of storage.chunks.entries()) {
|
||||
await ctx.db.insert("credential_payload_chunks", {
|
||||
credentialId,
|
||||
index,
|
||||
data,
|
||||
createdAtMs: nowMs,
|
||||
});
|
||||
}
|
||||
|
||||
await insertAdminEvent({
|
||||
ctx,
|
||||
eventType: "add",
|
||||
actorRole: "maintainer",
|
||||
actorId,
|
||||
occurredAtMs: nowMs,
|
||||
credentialId,
|
||||
kind: args.kind,
|
||||
});
|
||||
|
||||
const created: CredentialSetRecord = {
|
||||
_id: credentialId,
|
||||
kind: args.kind,
|
||||
status,
|
||||
payload: storage.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);
|
||||
const summaries = await Promise.all(
|
||||
selected.map(async (row) =>
|
||||
toCredentialSummary(
|
||||
row,
|
||||
includePayload,
|
||||
includePayload ? await readCredentialPayload(ctx, row) : undefined,
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
status: "ok",
|
||||
credentials: summaries,
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
21
qa/convex-credential-broker/convex/crons.ts
Normal file
21
qa/convex-credential-broker/convex/crons.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Crons module supports OpenClaw QA credential workflows.
|
||||
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;
|
||||
463
qa/convex-credential-broker/convex/http.ts
Normal file
463
qa/convex-credential-broker/convex/http.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
// 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;
|
||||
278
qa/convex-credential-broker/convex/payload-validation.ts
Normal file
278
qa/convex-credential-broker/convex/payload-validation.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
// Payload Validation module supports OpenClaw QA credential workflows.
|
||||
export class CredentialPayloadValidationError extends Error {
|
||||
code: string;
|
||||
httpStatus: number;
|
||||
|
||||
constructor(httpStatus: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "CredentialPayloadValidationError";
|
||||
this.httpStatus = httpStatus;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
type PayloadValidationFailureFactory = (httpStatus: number, code: string, message: string) => Error;
|
||||
|
||||
const DISCORD_SNOWFLAKE_RE = /^\d{17,20}$/u;
|
||||
const E164_RE = /^\+[1-9]\d{6,14}$/u;
|
||||
const SHA256_HEX_RE = /^[a-f0-9]{64}$/u;
|
||||
const TELEGRAM_CHAT_ID_RE = /^-?\d+$/u;
|
||||
const TELEGRAM_USER_ID_RE = /^\d+$/u;
|
||||
|
||||
function createCredentialPayloadValidationError(httpStatus: number, code: string, message: string) {
|
||||
return new CredentialPayloadValidationError(httpStatus, code, message);
|
||||
}
|
||||
|
||||
function throwPayloadError(createFailure: PayloadValidationFailureFactory, message: string): never {
|
||||
throw createFailure(400, "INVALID_PAYLOAD", message);
|
||||
}
|
||||
|
||||
function requirePayloadString(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
kind: string,
|
||||
createFailure: PayloadValidationFailureFactory,
|
||||
): string {
|
||||
const raw = payload[key];
|
||||
if (typeof raw !== "string") {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
`Credential payload for kind "${kind}" must include "${key}" as a string.`,
|
||||
);
|
||||
}
|
||||
const value = raw.trim();
|
||||
if (!value) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
`Credential payload for kind "${kind}" must include a non-empty "${key}" value.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireDiscordSnowflakePayloadString(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
createFailure: PayloadValidationFailureFactory,
|
||||
) {
|
||||
const value = requirePayloadString(payload, key, "discord", createFailure);
|
||||
if (!DISCORD_SNOWFLAKE_RE.test(value)) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
`Credential payload for kind "discord" must include "${key}" as a Discord snowflake string.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTelegramCredentialPayload(
|
||||
payload: Record<string, unknown>,
|
||||
createFailure: PayloadValidationFailureFactory,
|
||||
) {
|
||||
const groupId = requirePayloadString(payload, "groupId", "telegram", createFailure);
|
||||
if (!TELEGRAM_CHAT_ID_RE.test(groupId)) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
'Credential payload for kind "telegram" must include a numeric "groupId" string.',
|
||||
);
|
||||
}
|
||||
|
||||
const driverToken = requirePayloadString(payload, "driverToken", "telegram", createFailure);
|
||||
const sutToken = requirePayloadString(payload, "sutToken", "telegram", createFailure);
|
||||
|
||||
return {
|
||||
groupId,
|
||||
driverToken,
|
||||
sutToken,
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeTelegramUserCredentialPayload(
|
||||
payload: Record<string, unknown>,
|
||||
createFailure: PayloadValidationFailureFactory,
|
||||
) {
|
||||
const kind = "telegram-user";
|
||||
const groupId = requirePayloadString(payload, "groupId", kind, createFailure);
|
||||
if (!TELEGRAM_CHAT_ID_RE.test(groupId)) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
'Credential payload for kind "telegram-user" must include a numeric "groupId" string.',
|
||||
);
|
||||
}
|
||||
const testerUserId = requirePayloadString(payload, "testerUserId", kind, createFailure);
|
||||
if (!TELEGRAM_USER_ID_RE.test(testerUserId)) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
'Credential payload for kind "telegram-user" must include a numeric "testerUserId" string.',
|
||||
);
|
||||
}
|
||||
const telegramApiId = requirePayloadString(payload, "telegramApiId", kind, createFailure);
|
||||
if (!TELEGRAM_USER_ID_RE.test(telegramApiId)) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
'Credential payload for kind "telegram-user" must include a numeric "telegramApiId" string.',
|
||||
);
|
||||
}
|
||||
const tdlibArchiveSha256 = requirePayloadString(
|
||||
payload,
|
||||
"tdlibArchiveSha256",
|
||||
kind,
|
||||
createFailure,
|
||||
).toLowerCase();
|
||||
const desktopTdataArchiveSha256 = requirePayloadString(
|
||||
payload,
|
||||
"desktopTdataArchiveSha256",
|
||||
kind,
|
||||
createFailure,
|
||||
).toLowerCase();
|
||||
if (!SHA256_HEX_RE.test(tdlibArchiveSha256)) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
'Credential payload for kind "telegram-user" must include "tdlibArchiveSha256" as a SHA-256 hex string.',
|
||||
);
|
||||
}
|
||||
if (!SHA256_HEX_RE.test(desktopTdataArchiveSha256)) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
'Credential payload for kind "telegram-user" must include "desktopTdataArchiveSha256" as a SHA-256 hex string.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
groupId,
|
||||
sutToken: requirePayloadString(payload, "sutToken", kind, createFailure),
|
||||
testerUserId,
|
||||
testerUsername: requirePayloadString(payload, "testerUsername", kind, createFailure),
|
||||
telegramApiId,
|
||||
telegramApiHash: requirePayloadString(payload, "telegramApiHash", kind, createFailure),
|
||||
tdlibDatabaseEncryptionKey: requirePayloadString(
|
||||
payload,
|
||||
"tdlibDatabaseEncryptionKey",
|
||||
kind,
|
||||
createFailure,
|
||||
),
|
||||
tdlibArchiveBase64: requirePayloadString(payload, "tdlibArchiveBase64", kind, createFailure),
|
||||
tdlibArchiveSha256,
|
||||
desktopTdataArchiveBase64: requirePayloadString(
|
||||
payload,
|
||||
"desktopTdataArchiveBase64",
|
||||
kind,
|
||||
createFailure,
|
||||
),
|
||||
desktopTdataArchiveSha256,
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeDiscordCredentialPayload(
|
||||
payload: Record<string, unknown>,
|
||||
createFailure: PayloadValidationFailureFactory,
|
||||
) {
|
||||
const guildId = requireDiscordSnowflakePayloadString(payload, "guildId", createFailure);
|
||||
const channelId = requireDiscordSnowflakePayloadString(payload, "channelId", createFailure);
|
||||
const sutApplicationId = requireDiscordSnowflakePayloadString(
|
||||
payload,
|
||||
"sutApplicationId",
|
||||
createFailure,
|
||||
);
|
||||
const voiceChannelId =
|
||||
typeof payload.voiceChannelId === "string" && payload.voiceChannelId.trim()
|
||||
? payload.voiceChannelId.trim()
|
||||
: undefined;
|
||||
if (voiceChannelId && !DISCORD_SNOWFLAKE_RE.test(voiceChannelId)) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
'Credential payload for kind "discord" must include "voiceChannelId" as a Discord snowflake string when set.',
|
||||
);
|
||||
}
|
||||
const driverBotToken = requirePayloadString(payload, "driverBotToken", "discord", createFailure);
|
||||
const sutBotToken = requirePayloadString(payload, "sutBotToken", "discord", createFailure);
|
||||
|
||||
return {
|
||||
guildId,
|
||||
channelId,
|
||||
driverBotToken,
|
||||
sutBotToken,
|
||||
sutApplicationId,
|
||||
...(voiceChannelId ? { voiceChannelId } : {}),
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireE164PayloadString(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
kind: string,
|
||||
createFailure: PayloadValidationFailureFactory,
|
||||
) {
|
||||
const value = requirePayloadString(payload, key, kind, createFailure);
|
||||
if (!E164_RE.test(value)) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
`Credential payload for kind "${kind}" must include "${key}" as an E.164 phone number string.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeWhatsAppCredentialPayload(
|
||||
payload: Record<string, unknown>,
|
||||
createFailure: PayloadValidationFailureFactory,
|
||||
) {
|
||||
const driverPhoneE164 = requireE164PayloadString(
|
||||
payload,
|
||||
"driverPhoneE164",
|
||||
"whatsapp",
|
||||
createFailure,
|
||||
);
|
||||
const sutPhoneE164 = requireE164PayloadString(payload, "sutPhoneE164", "whatsapp", createFailure);
|
||||
if (driverPhoneE164 === sutPhoneE164) {
|
||||
throwPayloadError(
|
||||
createFailure,
|
||||
'Credential payload for kind "whatsapp" must use distinct driverPhoneE164 and sutPhoneE164 values.',
|
||||
);
|
||||
}
|
||||
const driverAuthArchiveBase64 = requirePayloadString(
|
||||
payload,
|
||||
"driverAuthArchiveBase64",
|
||||
"whatsapp",
|
||||
createFailure,
|
||||
);
|
||||
const sutAuthArchiveBase64 = requirePayloadString(
|
||||
payload,
|
||||
"sutAuthArchiveBase64",
|
||||
"whatsapp",
|
||||
createFailure,
|
||||
);
|
||||
const groupJid =
|
||||
typeof payload.groupJid === "string" && payload.groupJid.trim()
|
||||
? payload.groupJid.trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
driverPhoneE164,
|
||||
sutPhoneE164,
|
||||
driverAuthArchiveBase64,
|
||||
sutAuthArchiveBase64,
|
||||
...(groupJid ? { groupJid } : {}),
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
const credentialPayloadNormalizers: Record<
|
||||
string,
|
||||
(
|
||||
payload: Record<string, unknown>,
|
||||
createFailure: PayloadValidationFailureFactory,
|
||||
) => Record<string, unknown>
|
||||
> = {
|
||||
discord: normalizeDiscordCredentialPayload,
|
||||
telegram: normalizeTelegramCredentialPayload,
|
||||
"telegram-user": normalizeTelegramUserCredentialPayload,
|
||||
whatsapp: normalizeWhatsAppCredentialPayload,
|
||||
};
|
||||
|
||||
export function normalizeCredentialPayloadForKind(
|
||||
kind: string,
|
||||
payload: Record<string, unknown>,
|
||||
createFailure: PayloadValidationFailureFactory = createCredentialPayloadValidationError,
|
||||
) {
|
||||
return credentialPayloadNormalizers[kind]?.(payload, createFailure) ?? payload;
|
||||
}
|
||||
71
qa/convex-credential-broker/convex/schema.ts
Normal file
71
qa/convex-credential-broker/convex/schema.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Schema module supports OpenClaw QA credential workflows.
|
||||
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"]),
|
||||
|
||||
credential_payload_chunks: defineTable({
|
||||
credentialId: v.id("credential_sets"),
|
||||
index: v.number(),
|
||||
data: v.string(),
|
||||
createdAtMs: v.number(),
|
||||
}).index("by_credential_index", ["credentialId", "index"]),
|
||||
|
||||
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"]),
|
||||
});
|
||||
25
qa/convex-credential-broker/convex/tsconfig.json
Normal file
25
qa/convex-credential-broker/convex/tsconfig.json
Normal 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"]
|
||||
}
|
||||
15
qa/convex-credential-broker/package.json
Normal file
15
qa/convex-credential-broker/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user