feat(qa): lease telegram user credentials

This commit is contained in:
Ayaan Zaidi
2026-05-10 14:29:45 +05:30
parent 4a81aaa0c5
commit 1b2f4d87ef
10 changed files with 626 additions and 16 deletions

View File

@@ -80,6 +80,59 @@ describe("credential lease runtime", () => {
expect(headers.authorization).toBe("Bearer maintainer-secret");
});
it("hydrates chunked convex credential payloads after acquire", async () => {
const serialized = JSON.stringify({
groupId: "-100123",
driverToken: "driver",
sutToken: "sut",
});
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
status: "ok",
credentialId: "cred-chunked",
leaseToken: "lease-chunked",
payload: {
__openclawQaCredentialPayloadChunksV1: true,
byteLength: serialized.length,
chunkCount: 2,
},
}),
)
.mockResolvedValueOnce(jsonResponse({ status: "ok", data: serialized.slice(0, 20) }))
.mockResolvedValueOnce(jsonResponse({ status: "ok", data: serialized.slice(20) }));
const lease = await acquireQaCredentialLease({
kind: "telegram",
source: "convex",
role: "ci",
env: {
OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site",
OPENCLAW_QA_CONVEX_SECRET_CI: "ci-secret",
},
fetchImpl,
resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }),
parsePayload: (payload) =>
payload as { groupId: string; driverToken: string; sutToken: string },
});
expect(lease.payload).toEqual({
groupId: "-100123",
driverToken: "driver",
sutToken: "sut",
});
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(fetchImpl.mock.calls[1]?.[0]).toBe(
"https://qa-cred.example.convex.site/qa-credentials/v1/payload-chunk",
);
expect(JSON.parse(String(fetchImpl.mock.calls[1]?.[1]?.body))).toMatchObject({
credentialId: "cred-chunked",
index: 0,
leaseToken: "lease-chunked",
});
});
it("defaults convex credential role to maintainer outside CI", async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValueOnce(
jsonResponse({

View File

@@ -17,6 +17,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 15_000;
const DEFAULT_LEASE_TTL_MS = 20 * 60 * 1_000;
const RETRY_BACKOFF_MS = [500, 1_000, 2_000, 4_000, 5_000] as const;
const RETRYABLE_ACQUIRE_CODES = new Set(["POOL_EXHAUSTED", "NO_CREDENTIAL_AVAILABLE"]);
const CHUNKED_PAYLOAD_MARKER = "__openclawQaCredentialPayloadChunksV1";
const convexAcquireSuccessSchema = z.object({
status: z.literal("ok"),
@@ -38,6 +39,11 @@ const convexOkSchema = z.object({
status: z.literal("ok"),
});
const convexPayloadChunkSuccessSchema = z.object({
status: z.literal("ok"),
data: z.string(),
});
type ConvexCredentialBrokerConfig = {
acquireTimeoutMs: number;
acquireUrl: string;
@@ -47,6 +53,7 @@ type ConvexCredentialBrokerConfig = {
httpTimeoutMs: number;
leaseTtlMs: number;
ownerId: string;
payloadChunkUrl: string;
releaseUrl: string;
role: QaCredentialRole;
};
@@ -196,10 +203,39 @@ function resolveConvexCredentialBrokerConfig(params: {
),
acquireUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "acquire"),
heartbeatUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "heartbeat"),
payloadChunkUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "payload-chunk"),
releaseUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "release"),
};
}
function parseChunkedPayloadMarker(payload: unknown) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const record = payload as Record<string, unknown>;
if (record[CHUNKED_PAYLOAD_MARKER] !== true) {
return null;
}
if (
typeof record.chunkCount !== "number" ||
!Number.isInteger(record.chunkCount) ||
record.chunkCount < 1
) {
throw new Error("Chunked credential payload marker has an invalid chunkCount.");
}
if (
typeof record.byteLength !== "number" ||
!Number.isInteger(record.byteLength) ||
record.byteLength < 0
) {
throw new Error("Chunked credential payload marker has an invalid byteLength.");
}
return {
chunkCount: record.chunkCount,
byteLength: record.byteLength,
};
}
function toBrokerError(params: {
payload: unknown;
fallback: string;
@@ -259,6 +295,42 @@ async function postConvexBroker(params: {
return payload;
}
async function resolveConvexCredentialPayload(params: {
acquired: z.infer<typeof convexAcquireSuccessSchema>;
config: ConvexCredentialBrokerConfig;
fetchImpl: typeof fetch;
kind: string;
}) {
const marker = parseChunkedPayloadMarker(params.acquired.payload);
if (!marker) {
return params.acquired.payload;
}
const chunks: string[] = [];
for (let index = 0; index < marker.chunkCount; index += 1) {
const payload = await postConvexBroker({
fetchImpl: params.fetchImpl,
timeoutMs: params.config.httpTimeoutMs,
authToken: params.config.authToken,
url: params.config.payloadChunkUrl,
body: {
kind: params.kind,
ownerId: params.config.ownerId,
actorRole: params.config.role,
credentialId: params.acquired.credentialId,
leaseToken: params.acquired.leaseToken,
index,
},
});
const parsed = convexPayloadChunkSuccessSchema.parse(payload);
chunks.push(parsed.data);
}
const serialized = chunks.join("");
if (serialized.length !== marker.byteLength) {
throw new Error("Chunked credential payload length mismatch.");
}
return JSON.parse(serialized) as unknown;
}
function computeAcquireBackoffMs(params: {
attempt: number;
randomImpl: () => number;
@@ -355,7 +427,13 @@ export async function acquireQaCredentialLease<TPayload>(
};
let parsedPayload: TPayload;
try {
parsedPayload = opts.parsePayload(acquired.payload);
const resolvedPayload = await resolveConvexCredentialPayload({
acquired,
config,
fetchImpl,
kind: opts.kind,
});
parsedPayload = opts.parsePayload(resolvedPayload);
} catch (error) {
try {
await releaseLease();

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
TELEGRAM_USER_QA_CREDENTIAL_KIND,
parseTelegramUserQaCredentialPayload,
} from "./telegram-user-credential.runtime.js";
describe("Telegram user QA credential payload", () => {
it("parses the account-wide CLI and Desktop credential shape", () => {
const sha256 = "a".repeat(64);
expect(
parseTelegramUserQaCredentialPayload({
groupId: " -100123 ",
sutToken: " sut-token ",
testerUserId: " 8709353529 ",
testerUsername: " OpenClawTestUser ",
telegramApiId: " 123456 ",
telegramApiHash: " api-hash ",
tdlibDatabaseEncryptionKey: " db-key ",
tdlibArchiveBase64: " tdlib-archive ",
tdlibArchiveSha256: sha256.toUpperCase(),
desktopTdataArchiveBase64: " desktop-archive ",
desktopTdataArchiveSha256: sha256,
}),
).toEqual({
groupId: "-100123",
sutToken: "sut-token",
testerUserId: "8709353529",
testerUsername: "OpenClawTestUser",
telegramApiId: "123456",
telegramApiHash: "api-hash",
tdlibDatabaseEncryptionKey: "db-key",
tdlibArchiveBase64: "tdlib-archive",
tdlibArchiveSha256: sha256,
desktopTdataArchiveBase64: "desktop-archive",
desktopTdataArchiveSha256: sha256,
});
expect(TELEGRAM_USER_QA_CREDENTIAL_KIND).toBe("telegram-user");
});
it("rejects malformed payloads", () => {
expect(() =>
parseTelegramUserQaCredentialPayload({
groupId: "-100123",
sutToken: "sut-token",
testerUserId: "not-a-user-id",
testerUsername: "OpenClawTestUser",
telegramApiId: "123456",
telegramApiHash: "api-hash",
tdlibDatabaseEncryptionKey: "db-key",
tdlibArchiveBase64: "tdlib-archive",
tdlibArchiveSha256: "a".repeat(64),
desktopTdataArchiveBase64: "desktop-archive",
desktopTdataArchiveSha256: "b".repeat(64),
}),
).toThrow(/numeric string/u);
});
});

View File

@@ -0,0 +1,36 @@
import { z } from "openclaw/plugin-sdk/zod";
export const TELEGRAM_USER_QA_CREDENTIAL_KIND = "telegram-user";
const sha256HexSchema = z
.string()
.trim()
.toLowerCase()
.regex(/^[a-f0-9]{64}$/u, "must be a SHA-256 hex string");
const numericStringSchema = z.string().trim().regex(/^\d+$/u, "must be a numeric string");
const telegramUserQaCredentialPayloadSchema = z.object({
groupId: z
.string()
.trim()
.regex(/^-?\d+$/u, "must be a numeric Telegram chat id"),
sutToken: z.string().trim().min(1),
testerUserId: numericStringSchema,
testerUsername: z.string().trim().min(1),
telegramApiId: numericStringSchema,
telegramApiHash: z.string().trim().min(1),
tdlibDatabaseEncryptionKey: z.string().trim().min(1),
tdlibArchiveBase64: z.string().trim().min(1),
tdlibArchiveSha256: sha256HexSchema,
desktopTdataArchiveBase64: z.string().trim().min(1),
desktopTdataArchiveSha256: sha256HexSchema,
});
export type TelegramUserQaCredentialPayload = z.infer<typeof telegramUserQaCredentialPayloadSchema>;
export function parseTelegramUserQaCredentialPayload(
payload: unknown,
): TelegramUserQaCredentialPayload {
return telegramUserQaCredentialPayloadSchema.parse(payload);
}

View File

@@ -6,6 +6,7 @@ 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`
@@ -145,6 +146,21 @@ For `kind: "telegram"`, broker `admin/add` validates that payload includes:
- 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
For `kind: "discord"`, broker `admin/add` validates that payload includes:
- `guildId` as a Discord snowflake string

View File

@@ -11,7 +11,9 @@ 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;
@@ -60,6 +62,25 @@ type CredentialSetRecord = {
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: (
@@ -111,7 +132,88 @@ function leaseIsActive(lease: CredentialLease | undefined, nowMs: number) {
return Boolean(lease && lease.expiresAtMs > nowMs);
}
function toCredentialSummary(row: CredentialSetRecord, includePayload: boolean) {
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,
@@ -131,7 +233,7 @@ function toCredentialSummary(row: CredentialSetRecord, includePayload: boolean)
},
}
: {}),
...(includePayload ? { payload: row.payload } : {}),
...(includePayload ? { payload: resolvedPayload ?? row.payload } : {}),
};
}
@@ -317,6 +419,59 @@ export const acquireLease = internalMutation({
},
});
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(),
@@ -431,16 +586,26 @@ export const addCredentialSet = internalMutation({
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: args.payload,
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",
@@ -455,7 +620,7 @@ export const addCredentialSet = internalMutation({
_id: credentialId,
kind: args.kind,
status,
payload: args.payload,
payload: storage.payload,
createdAtMs: nowMs,
updatedAtMs: nowMs,
lastLeasedAtMs: 0,
@@ -583,9 +748,18 @@ export const listCredentialSets = internalQuery({
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: selected.map((row) => toCredentialSummary(row, includePayload)),
credentials: summaries,
count: selected.length,
};
},

View File

@@ -162,6 +162,21 @@ function optionalPositiveInteger(body: Record<string, unknown>, key: string) {
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;
@@ -317,6 +332,35 @@ http.route({
}),
});
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",

View File

@@ -14,7 +14,9 @@ type PayloadValidationFailureFactory = (httpStatus: number, code: string, messag
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);
@@ -84,6 +86,82 @@ function normalizeTelegramCredentialPayload(
} 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,
@@ -177,19 +255,23 @@ function normalizeWhatsAppCredentialPayload(
} 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,
) {
if (kind === "telegram") {
return normalizeTelegramCredentialPayload(payload, createFailure);
}
if (kind === "discord") {
return normalizeDiscordCredentialPayload(payload, createFailure);
}
if (kind === "whatsapp") {
return normalizeWhatsAppCredentialPayload(payload, createFailure);
}
return payload;
return credentialPayloadNormalizers[kind]?.(payload, createFailure) ?? payload;
}

View File

@@ -33,6 +33,13 @@ export default defineSchema({
.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,

View File

@@ -69,6 +69,68 @@ describe("QA Convex credential payload validation", () => {
expect(normalizeCredentialPayloadForKind("future-kind", payload)).toBe(payload);
});
it("normalizes Telegram user credential payloads", () => {
const sha256 = "a".repeat(64);
expect(
normalizeCredentialPayloadForKind("telegram-user", {
groupId: " -100123 ",
sutToken: " sut-token ",
testerUserId: " 8709353529 ",
testerUsername: " OpenClawTestUser ",
telegramApiId: " 123456 ",
telegramApiHash: " api-hash ",
tdlibDatabaseEncryptionKey: " db-key ",
tdlibArchiveBase64: " tdlib-archive ",
tdlibArchiveSha256: sha256.toUpperCase(),
desktopTdataArchiveBase64: " desktop-archive ",
desktopTdataArchiveSha256: sha256,
ignored: true,
}),
).toEqual({
groupId: "-100123",
sutToken: "sut-token",
testerUserId: "8709353529",
testerUsername: "OpenClawTestUser",
telegramApiId: "123456",
telegramApiHash: "api-hash",
tdlibDatabaseEncryptionKey: "db-key",
tdlibArchiveBase64: "tdlib-archive",
tdlibArchiveSha256: sha256,
desktopTdataArchiveBase64: "desktop-archive",
desktopTdataArchiveSha256: sha256,
});
});
it("rejects malformed Telegram user credential payloads", () => {
const validPayload = {
groupId: "-100123",
sutToken: "sut-token",
testerUserId: "8709353529",
testerUsername: "OpenClawTestUser",
telegramApiId: "123456",
telegramApiHash: "api-hash",
tdlibDatabaseEncryptionKey: "db-key",
tdlibArchiveBase64: "tdlib-archive",
tdlibArchiveSha256: "a".repeat(64),
desktopTdataArchiveBase64: "desktop-archive",
desktopTdataArchiveSha256: "b".repeat(64),
};
expect(() =>
normalizeCredentialPayloadForKind("telegram-user", {
...validPayload,
testerUserId: "tester",
}),
).toThrow(/testerUserId/u);
expect(() =>
normalizeCredentialPayloadForKind("telegram-user", {
...validPayload,
tdlibArchiveSha256: "not-sha",
}),
).toThrow(/tdlibArchiveSha256/u);
});
it("normalizes WhatsApp credential payloads", () => {
expect(
normalizeCredentialPayloadForKind("whatsapp", {