diff --git a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts index 9a0a6631c60..41ede1e613e 100644 --- a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts @@ -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() + .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().mockResolvedValueOnce( jsonResponse({ diff --git a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts index 2148a6c6dc2..818d0cd447c 100644 --- a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts +++ b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts @@ -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; + 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; + 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( }; 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(); diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-user-credential.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-user-credential.runtime.test.ts new file mode 100644 index 00000000000..6c0f5f73bf8 --- /dev/null +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-user-credential.runtime.test.ts @@ -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); + }); +}); diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-user-credential.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-user-credential.runtime.ts new file mode 100644 index 00000000000..fe89405078f --- /dev/null +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-user-credential.runtime.ts @@ -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; + +export function parseTelegramUserQaCredentialPayload( + payload: unknown, +): TelegramUserQaCredentialPayload { + return telegramUserQaCredentialPayloadSchema.parse(payload); +} diff --git a/qa/convex-credential-broker/README.md b/qa/convex-credential-broker/README.md index 5d7c920b79e..cdeda7a6a5a 100644 --- a/qa/convex-credential-broker/README.md +++ b/qa/convex-credential-broker/README.md @@ -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 diff --git a/qa/convex-credential-broker/convex/credentials.ts b/qa/convex-credential-broker/convex/credentials.ts index b940e49e70f..ba7a74f591b 100644 --- a/qa/convex-credential-broker/convex/credentials.ts +++ b/qa/convex-credential-broker/convex/credentials.ts @@ -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; + 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; + }; + }; + }; + }, + 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 => { + 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, }; }, diff --git a/qa/convex-credential-broker/convex/http.ts b/qa/convex-credential-broker/convex/http.ts index 683fd4aad8b..1f83e778539 100644 --- a/qa/convex-credential-broker/convex/http.ts +++ b/qa/convex-credential-broker/convex/http.ts @@ -162,6 +162,21 @@ function optionalPositiveInteger(body: Record, key: string) { return raw; } +function optionalNonnegativeInteger(body: Record, 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, 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", diff --git a/qa/convex-credential-broker/convex/payload-validation.ts b/qa/convex-credential-broker/convex/payload-validation.ts index 91104a63f79..dd5440b2c3a 100644 --- a/qa/convex-credential-broker/convex/payload-validation.ts +++ b/qa/convex-credential-broker/convex/payload-validation.ts @@ -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; } +function normalizeTelegramUserCredentialPayload( + payload: Record, + 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; +} + function normalizeDiscordCredentialPayload( payload: Record, createFailure: PayloadValidationFailureFactory, @@ -177,19 +255,23 @@ function normalizeWhatsAppCredentialPayload( } satisfies Record; } +const credentialPayloadNormalizers: Record< + string, + ( + payload: Record, + createFailure: PayloadValidationFailureFactory, + ) => Record +> = { + discord: normalizeDiscordCredentialPayload, + telegram: normalizeTelegramCredentialPayload, + "telegram-user": normalizeTelegramUserCredentialPayload, + whatsapp: normalizeWhatsAppCredentialPayload, +}; + export function normalizeCredentialPayloadForKind( kind: string, payload: Record, 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; } diff --git a/qa/convex-credential-broker/convex/schema.ts b/qa/convex-credential-broker/convex/schema.ts index 444b3182c36..4b6dbb71927 100644 --- a/qa/convex-credential-broker/convex/schema.ts +++ b/qa/convex-credential-broker/convex/schema.ts @@ -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, diff --git a/test/qa-convex-credential-payload-validation.test.ts b/test/qa-convex-credential-payload-validation.test.ts index 68693ae53d9..ffcc0db3567 100644 --- a/test/qa-convex-credential-payload-validation.test.ts +++ b/test/qa-convex-credential-payload-validation.test.ts @@ -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", {