diff --git a/qa/convex-credential-broker/README.md b/qa/convex-credential-broker/README.md index 8a3c1852ff3..f62d6dc3ae5 100644 --- a/qa/convex-credential-broker/README.md +++ b/qa/convex-credential-broker/README.md @@ -60,6 +60,10 @@ pnpm openclaw qa credentials add \ --kind telegram \ --payload-file qa/telegram-credential.json +pnpm openclaw qa credentials add \ + --kind discord \ + --payload-file qa/discord-credential.json + pnpm openclaw qa credentials list --kind telegram pnpm openclaw qa credentials remove --credential-id @@ -140,6 +144,14 @@ For `kind: "telegram"`, broker `admin/add` validates that payload includes: - non-empty `driverToken` - non-empty `sutToken` +For `kind: "discord"`, broker `admin/add` validates that payload includes: + +- `guildId` as a Discord snowflake string +- `channelId` as a Discord snowflake string +- non-empty `driverBotToken` +- non-empty `sutBotToken` +- `sutApplicationId` as a Discord snowflake string + Admin list (default redacted): ```bash diff --git a/qa/convex-credential-broker/convex/http.ts b/qa/convex-credential-broker/convex/http.ts index 34773686c4f..683fd4aad8b 100644 --- a/qa/convex-credential-broker/convex/http.ts +++ b/qa/convex-credential-broker/convex/http.ts @@ -2,6 +2,7 @@ import { httpRouter } from "convex/server"; import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { httpAction } from "./_generated/server"; +import { normalizeCredentialPayloadForKind } from "./payload-validation"; type ActorRole = "ci" | "maintainer"; @@ -201,50 +202,6 @@ function optionalListStatus(body: Record, key: string) { return value; } -function requirePayloadString(payload: Record, key: string, kind: string): string { - const raw = payload[key]; - if (typeof raw !== "string") { - throw new BrokerHttpError( - 400, - "INVALID_PAYLOAD", - `Credential payload for kind "${kind}" must include "${key}" as a string.`, - ); - } - const value = raw.trim(); - if (!value) { - throw new BrokerHttpError( - 400, - "INVALID_PAYLOAD", - `Credential payload for kind "${kind}" must include a non-empty "${key}" value.`, - ); - } - return value; -} - -function normalizeCredentialPayloadForKind(kind: string, payload: Record) { - if (kind !== "telegram") { - return payload; - } - - const groupId = requirePayloadString(payload, "groupId", "telegram"); - if (!/^-?\d+$/u.test(groupId)) { - throw new BrokerHttpError( - 400, - "INVALID_PAYLOAD", - 'Credential payload for kind "telegram" must include a numeric "groupId" string.', - ); - } - - const driverToken = requirePayloadString(payload, "driverToken", "telegram"); - const sutToken = requirePayloadString(payload, "sutToken", "telegram"); - - return { - groupId, - driverToken, - sutToken, - } satisfies Record; -} - function parseActorRole(body: Record) { const actorRole = requireString(body, "actorRole"); if (actorRole !== "ci" && actorRole !== "maintainer") { @@ -396,7 +353,11 @@ http.route({ assertMaintainerAdminAuth(parseBearerToken(request)); const body = await parseJsonObject(request); const kind = requireString(body, "kind"); - const payload = normalizeCredentialPayloadForKind(kind, requireObject(body, "payload")); + const payload = normalizeCredentialPayloadForKind( + kind, + requireObject(body, "payload"), + (httpStatus, code, message) => new BrokerHttpError(httpStatus, code, message), + ); const result = await ctx.runMutation(internal.credentials.addCredentialSet, { kind, payload, diff --git a/qa/convex-credential-broker/convex/payload-validation.ts b/qa/convex-credential-broker/convex/payload-validation.ts new file mode 100644 index 00000000000..901e81e049d --- /dev/null +++ b/qa/convex-credential-broker/convex/payload-validation.ts @@ -0,0 +1,121 @@ +export class CredentialPayloadValidationError extends Error { + code: string; + httpStatus: number; + + constructor(httpStatus: number, code: string, message: string) { + super(message); + this.name = "CredentialPayloadValidationError"; + this.httpStatus = httpStatus; + this.code = code; + } +} + +type PayloadValidationFailureFactory = (httpStatus: number, code: string, message: string) => Error; + +const DISCORD_SNOWFLAKE_RE = /^\d{17,20}$/u; +const TELEGRAM_CHAT_ID_RE = /^-?\d+$/u; + +function createCredentialPayloadValidationError(httpStatus: number, code: string, message: string) { + return new CredentialPayloadValidationError(httpStatus, code, message); +} + +function throwPayloadError(createFailure: PayloadValidationFailureFactory, message: string): never { + throw createFailure(400, "INVALID_PAYLOAD", message); +} + +function requirePayloadString( + payload: Record, + key: string, + kind: string, + createFailure: PayloadValidationFailureFactory, +): string { + const raw = payload[key]; + if (typeof raw !== "string") { + throwPayloadError( + createFailure, + `Credential payload for kind "${kind}" must include "${key}" as a string.`, + ); + } + const value = raw.trim(); + if (!value) { + throwPayloadError( + createFailure, + `Credential payload for kind "${kind}" must include a non-empty "${key}" value.`, + ); + } + return value; +} + +function requireDiscordSnowflakePayloadString( + payload: Record, + key: string, + createFailure: PayloadValidationFailureFactory, +) { + const value = requirePayloadString(payload, key, "discord", createFailure); + if (!DISCORD_SNOWFLAKE_RE.test(value)) { + throwPayloadError( + createFailure, + `Credential payload for kind "discord" must include "${key}" as a Discord snowflake string.`, + ); + } + return value; +} + +function normalizeTelegramCredentialPayload( + payload: Record, + createFailure: PayloadValidationFailureFactory, +) { + const groupId = requirePayloadString(payload, "groupId", "telegram", createFailure); + if (!TELEGRAM_CHAT_ID_RE.test(groupId)) { + throwPayloadError( + createFailure, + 'Credential payload for kind "telegram" must include a numeric "groupId" string.', + ); + } + + const driverToken = requirePayloadString(payload, "driverToken", "telegram", createFailure); + const sutToken = requirePayloadString(payload, "sutToken", "telegram", createFailure); + + return { + groupId, + driverToken, + sutToken, + } satisfies Record; +} + +function normalizeDiscordCredentialPayload( + payload: Record, + createFailure: PayloadValidationFailureFactory, +) { + const guildId = requireDiscordSnowflakePayloadString(payload, "guildId", createFailure); + const channelId = requireDiscordSnowflakePayloadString(payload, "channelId", createFailure); + const sutApplicationId = requireDiscordSnowflakePayloadString( + payload, + "sutApplicationId", + createFailure, + ); + const driverBotToken = requirePayloadString(payload, "driverBotToken", "discord", createFailure); + const sutBotToken = requirePayloadString(payload, "sutBotToken", "discord", createFailure); + + return { + guildId, + channelId, + driverBotToken, + sutBotToken, + sutApplicationId, + } satisfies Record; +} + +export function normalizeCredentialPayloadForKind( + kind: string, + payload: Record, + createFailure: PayloadValidationFailureFactory = createCredentialPayloadValidationError, +) { + if (kind === "telegram") { + return normalizeTelegramCredentialPayload(payload, createFailure); + } + if (kind === "discord") { + return normalizeDiscordCredentialPayload(payload, createFailure); + } + return payload; +} diff --git a/test/qa-convex-credential-payload-validation.test.ts b/test/qa-convex-credential-payload-validation.test.ts new file mode 100644 index 00000000000..6b0ea895c64 --- /dev/null +++ b/test/qa-convex-credential-payload-validation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + CredentialPayloadValidationError, + normalizeCredentialPayloadForKind, +} from "../qa/convex-credential-broker/convex/payload-validation.js"; + +describe("QA Convex credential payload validation", () => { + it("normalizes Discord credential payloads", () => { + expect( + normalizeCredentialPayloadForKind("discord", { + guildId: " 1496962067029299350 ", + channelId: "1496962068027281447", + driverBotToken: " driver-token ", + sutBotToken: "sut-token", + sutApplicationId: "1496963665587601428", + ignored: true, + }), + ).toEqual({ + guildId: "1496962067029299350", + channelId: "1496962068027281447", + driverBotToken: "driver-token", + sutBotToken: "sut-token", + sutApplicationId: "1496963665587601428", + }); + }); + + it("rejects malformed Discord snowflakes", () => { + expect(() => + normalizeCredentialPayloadForKind("discord", { + guildId: "not-a-snowflake", + channelId: "1496962068027281447", + driverBotToken: "driver-token", + sutBotToken: "sut-token", + sutApplicationId: "1496963665587601428", + }), + ).toThrow(CredentialPayloadValidationError); + }); + + it("rejects empty Discord bot tokens", () => { + expect(() => + normalizeCredentialPayloadForKind("discord", { + guildId: "1496962067029299350", + channelId: "1496962068027281447", + driverBotToken: " ", + sutBotToken: "sut-token", + sutApplicationId: "1496963665587601428", + }), + ).toThrow(/driverBotToken/u); + }); + + it("keeps unknown credential kinds pass-through-compatible", () => { + const payload = { anything: true }; + + expect(normalizeCredentialPayloadForKind("future-kind", payload)).toBe(payload); + }); +});