mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
feat(qa): lease telegram user credentials
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user