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");
|
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 () => {
|
it("defaults convex credential role to maintainer outside CI", async () => {
|
||||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||||||
jsonResponse({
|
jsonResponse({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 15_000;
|
|||||||
const DEFAULT_LEASE_TTL_MS = 20 * 60 * 1_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 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 RETRYABLE_ACQUIRE_CODES = new Set(["POOL_EXHAUSTED", "NO_CREDENTIAL_AVAILABLE"]);
|
||||||
|
const CHUNKED_PAYLOAD_MARKER = "__openclawQaCredentialPayloadChunksV1";
|
||||||
|
|
||||||
const convexAcquireSuccessSchema = z.object({
|
const convexAcquireSuccessSchema = z.object({
|
||||||
status: z.literal("ok"),
|
status: z.literal("ok"),
|
||||||
@@ -38,6 +39,11 @@ const convexOkSchema = z.object({
|
|||||||
status: z.literal("ok"),
|
status: z.literal("ok"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const convexPayloadChunkSuccessSchema = z.object({
|
||||||
|
status: z.literal("ok"),
|
||||||
|
data: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
type ConvexCredentialBrokerConfig = {
|
type ConvexCredentialBrokerConfig = {
|
||||||
acquireTimeoutMs: number;
|
acquireTimeoutMs: number;
|
||||||
acquireUrl: string;
|
acquireUrl: string;
|
||||||
@@ -47,6 +53,7 @@ type ConvexCredentialBrokerConfig = {
|
|||||||
httpTimeoutMs: number;
|
httpTimeoutMs: number;
|
||||||
leaseTtlMs: number;
|
leaseTtlMs: number;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
payloadChunkUrl: string;
|
||||||
releaseUrl: string;
|
releaseUrl: string;
|
||||||
role: QaCredentialRole;
|
role: QaCredentialRole;
|
||||||
};
|
};
|
||||||
@@ -196,10 +203,39 @@ function resolveConvexCredentialBrokerConfig(params: {
|
|||||||
),
|
),
|
||||||
acquireUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "acquire"),
|
acquireUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "acquire"),
|
||||||
heartbeatUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "heartbeat"),
|
heartbeatUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "heartbeat"),
|
||||||
|
payloadChunkUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "payload-chunk"),
|
||||||
releaseUrl: joinQaCredentialEndpoint(baseUrl, endpointPrefix, "release"),
|
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: {
|
function toBrokerError(params: {
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
fallback: string;
|
fallback: string;
|
||||||
@@ -259,6 +295,42 @@ async function postConvexBroker(params: {
|
|||||||
return payload;
|
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: {
|
function computeAcquireBackoffMs(params: {
|
||||||
attempt: number;
|
attempt: number;
|
||||||
randomImpl: () => number;
|
randomImpl: () => number;
|
||||||
@@ -355,7 +427,13 @@ export async function acquireQaCredentialLease<TPayload>(
|
|||||||
};
|
};
|
||||||
let parsedPayload: TPayload;
|
let parsedPayload: TPayload;
|
||||||
try {
|
try {
|
||||||
parsedPayload = opts.parsePayload(acquired.payload);
|
const resolvedPayload = await resolveConvexCredentialPayload({
|
||||||
|
acquired,
|
||||||
|
config,
|
||||||
|
fetchImpl,
|
||||||
|
kind: opts.kind,
|
||||||
|
});
|
||||||
|
parsedPayload = opts.parsePayload(resolvedPayload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
await releaseLease();
|
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:
|
This broker exposes:
|
||||||
|
|
||||||
- `POST /qa-credentials/v1/acquire`
|
- `POST /qa-credentials/v1/acquire`
|
||||||
|
- `POST /qa-credentials/v1/payload-chunk`
|
||||||
- `POST /qa-credentials/v1/heartbeat`
|
- `POST /qa-credentials/v1/heartbeat`
|
||||||
- `POST /qa-credentials/v1/release`
|
- `POST /qa-credentials/v1/release`
|
||||||
- `POST /qa-credentials/v1/admin/add`
|
- `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 `driverToken`
|
||||||
- non-empty `sutToken`
|
- 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:
|
For `kind: "discord"`, broker `admin/add` validates that payload includes:
|
||||||
|
|
||||||
- `guildId` as a Discord snowflake string
|
- `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_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||||
const MIN_LEASE_TTL_MS = 30_000;
|
const MIN_LEASE_TTL_MS = 30_000;
|
||||||
const MAX_LIST_LIMIT = 500;
|
const MAX_LIST_LIMIT = 500;
|
||||||
|
const PAYLOAD_CHUNK_SIZE = 256_000;
|
||||||
const MIN_LIST_LIMIT = 1;
|
const MIN_LIST_LIMIT = 1;
|
||||||
|
const CHUNKED_PAYLOAD_MARKER = "__openclawQaCredentialPayloadChunksV1";
|
||||||
|
|
||||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
||||||
const DEFAULT_LEASE_TTL_MS = 20 * 60 * 1_000;
|
const DEFAULT_LEASE_TTL_MS = 20 * 60 * 1_000;
|
||||||
@@ -60,6 +62,25 @@ type CredentialSetRecord = {
|
|||||||
lease?: CredentialLease;
|
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 = {
|
type EventInsertCtx = {
|
||||||
db: {
|
db: {
|
||||||
insert: (
|
insert: (
|
||||||
@@ -111,7 +132,88 @@ function leaseIsActive(lease: CredentialLease | undefined, nowMs: number) {
|
|||||||
return Boolean(lease && lease.expiresAtMs > nowMs);
|
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 {
|
return {
|
||||||
credentialId: row._id,
|
credentialId: row._id,
|
||||||
kind: row.kind,
|
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({
|
export const heartbeatLease = internalMutation({
|
||||||
args: {
|
args: {
|
||||||
kind: v.string(),
|
kind: v.string(),
|
||||||
@@ -431,16 +586,26 @@ export const addCredentialSet = internalMutation({
|
|||||||
const actorId = normalizeActorId(args.actorId);
|
const actorId = normalizeActorId(args.actorId);
|
||||||
const status = args.status ?? "active";
|
const status = args.status ?? "active";
|
||||||
const note = args.note?.trim();
|
const note = args.note?.trim();
|
||||||
|
const storage = createCredentialPayloadStorage(args.payload);
|
||||||
const credentialId = await ctx.db.insert("credential_sets", {
|
const credentialId = await ctx.db.insert("credential_sets", {
|
||||||
kind: args.kind,
|
kind: args.kind,
|
||||||
status,
|
status,
|
||||||
payload: args.payload,
|
payload: storage.payload,
|
||||||
createdAtMs: nowMs,
|
createdAtMs: nowMs,
|
||||||
updatedAtMs: nowMs,
|
updatedAtMs: nowMs,
|
||||||
lastLeasedAtMs: 0,
|
lastLeasedAtMs: 0,
|
||||||
...(note ? { note } : {}),
|
...(note ? { note } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const [index, data] of storage.chunks.entries()) {
|
||||||
|
await ctx.db.insert("credential_payload_chunks", {
|
||||||
|
credentialId,
|
||||||
|
index,
|
||||||
|
data,
|
||||||
|
createdAtMs: nowMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await insertAdminEvent({
|
await insertAdminEvent({
|
||||||
ctx,
|
ctx,
|
||||||
eventType: "add",
|
eventType: "add",
|
||||||
@@ -455,7 +620,7 @@ export const addCredentialSet = internalMutation({
|
|||||||
_id: credentialId,
|
_id: credentialId,
|
||||||
kind: args.kind,
|
kind: args.kind,
|
||||||
status,
|
status,
|
||||||
payload: args.payload,
|
payload: storage.payload,
|
||||||
createdAtMs: nowMs,
|
createdAtMs: nowMs,
|
||||||
updatedAtMs: nowMs,
|
updatedAtMs: nowMs,
|
||||||
lastLeasedAtMs: 0,
|
lastLeasedAtMs: 0,
|
||||||
@@ -583,9 +748,18 @@ export const listCredentialSets = internalQuery({
|
|||||||
|
|
||||||
sortCredentialRowsForList(rows);
|
sortCredentialRowsForList(rows);
|
||||||
const selected = rows.slice(0, limit);
|
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 {
|
return {
|
||||||
status: "ok",
|
status: "ok",
|
||||||
credentials: selected.map((row) => toCredentialSummary(row, includePayload)),
|
credentials: summaries,
|
||||||
count: selected.length,
|
count: selected.length,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -162,6 +162,21 @@ function optionalPositiveInteger(body: Record<string, unknown>, key: string) {
|
|||||||
return raw;
|
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) {
|
function optionalBoolean(body: Record<string, unknown>, key: string) {
|
||||||
if (!(key in body) || body[key] === undefined || body[key] === null) {
|
if (!(key in body) || body[key] === undefined || body[key] === null) {
|
||||||
return undefined;
|
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({
|
http.route({
|
||||||
path: "/qa-credentials/v1/release",
|
path: "/qa-credentials/v1/release",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ type PayloadValidationFailureFactory = (httpStatus: number, code: string, messag
|
|||||||
|
|
||||||
const DISCORD_SNOWFLAKE_RE = /^\d{17,20}$/u;
|
const DISCORD_SNOWFLAKE_RE = /^\d{17,20}$/u;
|
||||||
const E164_RE = /^\+[1-9]\d{6,14}$/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_CHAT_ID_RE = /^-?\d+$/u;
|
||||||
|
const TELEGRAM_USER_ID_RE = /^\d+$/u;
|
||||||
|
|
||||||
function createCredentialPayloadValidationError(httpStatus: number, code: string, message: string) {
|
function createCredentialPayloadValidationError(httpStatus: number, code: string, message: string) {
|
||||||
return new CredentialPayloadValidationError(httpStatus, code, message);
|
return new CredentialPayloadValidationError(httpStatus, code, message);
|
||||||
@@ -84,6 +86,82 @@ function normalizeTelegramCredentialPayload(
|
|||||||
} satisfies Record<string, unknown>;
|
} 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(
|
function normalizeDiscordCredentialPayload(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
createFailure: PayloadValidationFailureFactory,
|
createFailure: PayloadValidationFailureFactory,
|
||||||
@@ -177,19 +255,23 @@ function normalizeWhatsAppCredentialPayload(
|
|||||||
} satisfies Record<string, unknown>;
|
} 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(
|
export function normalizeCredentialPayloadForKind(
|
||||||
kind: string,
|
kind: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
createFailure: PayloadValidationFailureFactory = createCredentialPayloadValidationError,
|
createFailure: PayloadValidationFailureFactory = createCredentialPayloadValidationError,
|
||||||
) {
|
) {
|
||||||
if (kind === "telegram") {
|
return credentialPayloadNormalizers[kind]?.(payload, createFailure) ?? payload;
|
||||||
return normalizeTelegramCredentialPayload(payload, createFailure);
|
|
||||||
}
|
|
||||||
if (kind === "discord") {
|
|
||||||
return normalizeDiscordCredentialPayload(payload, createFailure);
|
|
||||||
}
|
|
||||||
if (kind === "whatsapp") {
|
|
||||||
return normalizeWhatsAppCredentialPayload(payload, createFailure);
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ export default defineSchema({
|
|||||||
.index("by_kind_status", ["kind", "status"])
|
.index("by_kind_status", ["kind", "status"])
|
||||||
.index("by_kind_lastLeasedAtMs", ["kind", "lastLeasedAtMs"]),
|
.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({
|
lease_events: defineTable({
|
||||||
kind: v.string(),
|
kind: v.string(),
|
||||||
eventType: leaseEventType,
|
eventType: leaseEventType,
|
||||||
|
|||||||
@@ -69,6 +69,68 @@ describe("QA Convex credential payload validation", () => {
|
|||||||
expect(normalizeCredentialPayloadForKind("future-kind", payload)).toBe(payload);
|
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", () => {
|
it("normalizes WhatsApp credential payloads", () => {
|
||||||
expect(
|
expect(
|
||||||
normalizeCredentialPayloadForKind("whatsapp", {
|
normalizeCredentialPayloadForKind("whatsapp", {
|
||||||
|
|||||||
Reference in New Issue
Block a user