From 45d868685fee477a49abc83df98b5b6ab9c41ff1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 23:03:36 +0100 Subject: [PATCH] fix: enforce dm allowFrom inheritance across account channels (#27936) (thanks @widingmarcus-cyber) --- CHANGELOG.md | 1 + src/commands/doctor-config-flow.ts | 27 +- ...onfig.allowlist-requires-allowfrom.test.ts | 142 +++++---- src/config/zod-schema.providers-core.ts | 270 +++++++++++++++--- src/config/zod-schema.providers-whatsapp.ts | 50 ++-- 5 files changed, 369 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9170f6bc7db..024231e62bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/DM allowlist runtime inheritance: enforce `dmPolicy: "allowlist"` `allowFrom` requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align `openclaw doctor` checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber. - Delivery queue/recovery backoff: prevent retry starvation by persisting `lastAttemptAt` on failed sends and deferring recovery retries until each entry's `lastAttemptAt + backoff` window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo. - Google Chat/Lifecycle: keep Google Chat `startAccount` pending until abort in webhook mode so startup is no longer interpreted as immediate exit, preventing auto-restart loops and webhook-target churn. (#27384) thanks @junsuwhy. - Temp dirs/Linux umask: force `0700` permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so `umask 0002` installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky. diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index b875bc3a71a..5d3ee6cf47e 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1112,23 +1112,40 @@ function detectEmptyAllowlistPolicy(cfg: OpenClawConfig): string[] { const hasEntries = (list?: Array) => Array.isArray(list) && list.map((v) => String(v).trim()).filter(Boolean).length > 0; - const checkAccount = (account: Record, prefix: string) => { + const checkAccount = ( + account: Record, + prefix: string, + parent?: Record, + ) => { const dmEntry = account.dm; const dm = dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry) ? (dmEntry as Record) : undefined; + const parentDmEntry = parent?.dm; + const parentDm = + parentDmEntry && typeof parentDmEntry === "object" && !Array.isArray(parentDmEntry) + ? (parentDmEntry as Record) + : undefined; const dmPolicy = - (account.dmPolicy as string | undefined) ?? (dm?.policy as string | undefined) ?? undefined; + (account.dmPolicy as string | undefined) ?? + (dm?.policy as string | undefined) ?? + (parent?.dmPolicy as string | undefined) ?? + (parentDm?.policy as string | undefined) ?? + undefined; if (dmPolicy !== "allowlist") { return; } - const topAllowFrom = account.allowFrom as Array | undefined; + const topAllowFrom = + (account.allowFrom as Array | undefined) ?? + (parent?.allowFrom as Array | undefined); const nestedAllowFrom = dm?.allowFrom as Array | undefined; + const parentNestedAllowFrom = parentDm?.allowFrom as Array | undefined; + const effectiveAllowFrom = topAllowFrom ?? nestedAllowFrom ?? parentNestedAllowFrom; - if (hasEntries(topAllowFrom) || hasEntries(nestedAllowFrom)) { + if (hasEntries(effectiveAllowFrom)) { return; } @@ -1153,7 +1170,7 @@ function detectEmptyAllowlistPolicy(cfg: OpenClawConfig): string[] { if (!account || typeof account !== "object") { continue; } - checkAccount(account, `channels.${channelName}.accounts.${accountId}`); + checkAccount(account, `channels.${channelName}.accounts.${accountId}`, channelConfig); } } } diff --git a/src/config/config.allowlist-requires-allowfrom.test.ts b/src/config/config.allowlist-requires-allowfrom.test.ts index a12973a0cd4..5f1a4749008 100644 --- a/src/config/config.allowlist-requires-allowfrom.test.ts +++ b/src/config/config.allowlist-requires-allowfrom.test.ts @@ -1,100 +1,109 @@ import { describe, expect, it } from "vitest"; import { validateConfigObject } from "./config.js"; -describe('dmPolicy="allowlist" requires non-empty allowFrom', () => { +describe('dmPolicy="allowlist" requires non-empty effective allowFrom', () => { it('rejects telegram dmPolicy="allowlist" without allowFrom', () => { const res = validateConfigObject({ channels: { telegram: { dmPolicy: "allowlist", botToken: "fake" } }, }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((i) => i.path.includes("allowFrom"))).toBe(true); + expect(res.issues.some((i) => i.path.includes("channels.telegram.allowFrom"))).toBe(true); } }); - it('rejects telegram dmPolicy="allowlist" with empty allowFrom', () => { - const res = validateConfigObject({ - channels: { telegram: { dmPolicy: "allowlist", allowFrom: [], botToken: "fake" } }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues.some((i) => i.path.includes("allowFrom"))).toBe(true); - } - }); - - it('accepts telegram dmPolicy="allowlist" with allowFrom entries', () => { - const res = validateConfigObject({ - channels: { telegram: { dmPolicy: "allowlist", allowFrom: ["12345"], botToken: "fake" } }, - }); - expect(res.ok).toBe(true); - }); - - it('accepts telegram dmPolicy="pairing" without allowFrom', () => { - const res = validateConfigObject({ - channels: { telegram: { dmPolicy: "pairing", botToken: "fake" } }, - }); - expect(res.ok).toBe(true); - }); - it('rejects signal dmPolicy="allowlist" without allowFrom', () => { const res = validateConfigObject({ channels: { signal: { dmPolicy: "allowlist" } }, }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((i) => i.path.includes("allowFrom"))).toBe(true); + expect(res.issues.some((i) => i.path.includes("channels.signal.allowFrom"))).toBe(true); } }); - it('accepts signal dmPolicy="allowlist" with allowFrom entries', () => { - const res = validateConfigObject({ - channels: { signal: { dmPolicy: "allowlist", allowFrom: ["+1234567890"] } }, - }); - expect(res.ok).toBe(true); - }); - it('rejects discord dmPolicy="allowlist" without allowFrom', () => { const res = validateConfigObject({ channels: { discord: { dmPolicy: "allowlist" } }, }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((i) => i.path.includes("allowFrom"))).toBe(true); + expect( + res.issues.some((i) => i.path.includes("channels.discord") && i.path.includes("allowFrom")), + ).toBe(true); } }); - it('accepts discord dmPolicy="allowlist" with allowFrom entries', () => { - const res = validateConfigObject({ - channels: { discord: { dmPolicy: "allowlist", allowFrom: ["123456789"] } }, - }); - expect(res.ok).toBe(true); - }); - it('rejects whatsapp dmPolicy="allowlist" without allowFrom', () => { const res = validateConfigObject({ channels: { whatsapp: { dmPolicy: "allowlist" } }, }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((i) => i.path.includes("allowFrom"))).toBe(true); + expect(res.issues.some((i) => i.path.includes("channels.whatsapp.allowFrom"))).toBe(true); } }); - it('accepts whatsapp dmPolicy="allowlist" with allowFrom entries', () => { + it('accepts dmPolicy="pairing" without allowFrom', () => { const res = validateConfigObject({ - channels: { whatsapp: { dmPolicy: "allowlist", allowFrom: ["+1234567890"] } }, + channels: { telegram: { dmPolicy: "pairing", botToken: "fake" } }, + }); + expect(res.ok).toBe(true); + }); +}); + +describe('account dmPolicy="allowlist" uses inherited allowFrom', () => { + it("accepts telegram account allowlist when parent allowFrom exists", () => { + const res = validateConfigObject({ + channels: { + telegram: { + allowFrom: ["12345"], + accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } }, + }, + }, }); expect(res.ok).toBe(true); }); - it('accepts telegram account dmPolicy="allowlist" without own allowFrom (inherits from parent)', () => { - // Account-level schemas skip allowFrom validation because accounts inherit - // allowFrom from the parent channel config at runtime. + it("rejects telegram account allowlist when neither account nor parent has allowFrom", () => { + const res = validateConfigObject({ + channels: { telegram: { accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } } }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect( + res.issues.some((i) => i.path.includes("channels.telegram.accounts.bot1.allowFrom")), + ).toBe(true); + } + }); + + it("accepts signal account allowlist when parent allowFrom exists", () => { const res = validateConfigObject({ channels: { - telegram: { + signal: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts discord account allowlist when parent allowFrom exists", () => { + const res = validateConfigObject({ + channels: { + discord: { allowFrom: ["123456789"], accounts: { work: { dmPolicy: "allowlist" } } }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts slack account allowlist when parent allowFrom exists", () => { + const res = validateConfigObject({ + channels: { + slack: { + allowFrom: ["U123"], + botToken: "xoxb-top", + appToken: "xapp-top", accounts: { - bot1: { dmPolicy: "allowlist", botToken: "fake" }, + work: { dmPolicy: "allowlist", botToken: "xoxb-work", appToken: "xapp-work" }, }, }, }, @@ -102,14 +111,35 @@ describe('dmPolicy="allowlist" requires non-empty allowFrom', () => { expect(res.ok).toBe(true); }); - it('accepts telegram account dmPolicy="allowlist" with allowFrom entries', () => { + it("accepts whatsapp account allowlist when parent allowFrom exists", () => { const res = validateConfigObject({ channels: { - telegram: { - accounts: { - bot1: { dmPolicy: "allowlist", allowFrom: ["12345"], botToken: "fake" }, - }, - }, + whatsapp: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts imessage account allowlist when parent allowFrom exists", () => { + const res = validateConfigObject({ + channels: { + imessage: { allowFrom: ["alice"], accounts: { work: { dmPolicy: "allowlist" } } }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts irc account allowlist when parent allowFrom exists", () => { + const res = validateConfigObject({ + channels: { irc: { allowFrom: ["nick"], accounts: { work: { dmPolicy: "allowlist" } } } }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts bluebubbles account allowlist when parent allowFrom exists", () => { + const res = validateConfigObject({ + channels: { + bluebubbles: { allowFrom: ["sender"], accounts: { work: { dmPolicy: "allowlist" } } }, }, }); expect(res.ok).toBe(true); diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 1f0799f782c..0c26727266e 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -249,6 +249,32 @@ export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ }); validateTelegramCustomCommands(value, ctx); + if (value.accounts) { + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + const effectivePolicy = account.dmPolicy ?? value.dmPolicy; + const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; + requireOpenAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.telegram.accounts.*.dmPolicy="open" requires channels.telegram.accounts.*.allowFrom (or channels.telegram.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.telegram.accounts.*.dmPolicy="allowlist" requires channels.telegram.accounts.*.allowFrom (or channels.telegram.allowFrom) to contain at least one sender ID', + }); + } + } + const baseWebhookUrl = typeof value.webhookUrl === "string" ? value.webhookUrl.trim() : ""; const baseWebhookSecret = typeof value.webhookSecret === "string" ? value.webhookSecret.trim() : ""; @@ -501,30 +527,62 @@ export const DiscordAccountSchema = z }); } - const dmPolicy = value.dmPolicy ?? value.dm?.policy ?? "pairing"; - const allowFrom = value.allowFrom ?? value.dm?.allowFrom; - const allowFromPath = - value.allowFrom !== undefined ? (["allowFrom"] as const) : (["dm", "allowFrom"] as const); - requireOpenAllowFrom({ - policy: dmPolicy, - allowFrom, - ctx, - path: [...allowFromPath], - message: - 'channels.discord.dmPolicy="open" requires channels.discord.allowFrom (or channels.discord.dm.allowFrom) to include "*"', - }); - requireAllowlistAllowFrom({ - policy: dmPolicy, - allowFrom, - ctx, - path: [...allowFromPath], - message: - 'channels.discord.dmPolicy="allowlist" requires channels.discord.allowFrom (or channels.discord.dm.allowFrom) to contain at least one sender ID', - }); + // DM allowlist validation is enforced at DiscordConfigSchema so account entries + // can inherit top-level allowFrom via runtime shallow merge. }); export const DiscordConfigSchema = DiscordAccountSchema.extend({ accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + const dmPolicy = value.dmPolicy ?? value.dm?.policy ?? "pairing"; + const allowFrom = value.allowFrom ?? value.dm?.allowFrom; + const allowFromPath = + value.allowFrom !== undefined ? (["allowFrom"] as const) : (["dm", "allowFrom"] as const); + requireOpenAllowFrom({ + policy: dmPolicy, + allowFrom, + ctx, + path: [...allowFromPath], + message: + 'channels.discord.dmPolicy="open" requires channels.discord.allowFrom (or channels.discord.dm.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: dmPolicy, + allowFrom, + ctx, + path: [...allowFromPath], + message: + 'channels.discord.dmPolicy="allowlist" requires channels.discord.allowFrom (or channels.discord.dm.allowFrom) to contain at least one sender ID', + }); + + if (!value.accounts) { + return; + } + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + const effectivePolicy = + account.dmPolicy ?? account.dm?.policy ?? value.dmPolicy ?? value.dm?.policy ?? "pairing"; + const effectiveAllowFrom = + account.allowFrom ?? account.dm?.allowFrom ?? value.allowFrom ?? value.dm?.allowFrom; + requireOpenAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.discord.accounts.*.dmPolicy="open" requires channels.discord.accounts.*.allowFrom (or channels.discord.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.discord.accounts.*.dmPolicy="allowlist" requires channels.discord.accounts.*.allowFrom (or channels.discord.allowFrom) to contain at least one sender ID', + }); + } }); export const GoogleChatDmSchema = z @@ -724,29 +782,11 @@ export const SlackAccountSchema = z ackReaction: z.string().optional(), }) .strict() - .superRefine((value, ctx) => { + .superRefine((value) => { normalizeSlackStreamingConfig(value); - const dmPolicy = value.dmPolicy ?? value.dm?.policy ?? "pairing"; - const allowFrom = value.allowFrom ?? value.dm?.allowFrom; - const allowFromPath = - value.allowFrom !== undefined ? (["allowFrom"] as const) : (["dm", "allowFrom"] as const); - requireOpenAllowFrom({ - policy: dmPolicy, - allowFrom, - ctx, - path: [...allowFromPath], - message: - 'channels.slack.dmPolicy="open" requires channels.slack.allowFrom (or channels.slack.dm.allowFrom) to include "*"', - }); - requireAllowlistAllowFrom({ - policy: dmPolicy, - allowFrom, - ctx, - path: [...allowFromPath], - message: - 'channels.slack.dmPolicy="allowlist" requires channels.slack.allowFrom (or channels.slack.dm.allowFrom) to contain at least one sender ID', - }); + // DM allowlist validation is enforced at SlackConfigSchema so account entries + // can inherit top-level allowFrom via runtime shallow merge. }); export const SlackConfigSchema = SlackAccountSchema.safeExtend({ @@ -756,6 +796,27 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({ groupPolicy: GroupPolicySchema.optional().default("allowlist"), accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(), }).superRefine((value, ctx) => { + const dmPolicy = value.dmPolicy ?? value.dm?.policy ?? "pairing"; + const allowFrom = value.allowFrom ?? value.dm?.allowFrom; + const allowFromPath = + value.allowFrom !== undefined ? (["allowFrom"] as const) : (["dm", "allowFrom"] as const); + requireOpenAllowFrom({ + policy: dmPolicy, + allowFrom, + ctx, + path: [...allowFromPath], + message: + 'channels.slack.dmPolicy="open" requires channels.slack.allowFrom (or channels.slack.dm.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: dmPolicy, + allowFrom, + ctx, + path: [...allowFromPath], + message: + 'channels.slack.dmPolicy="allowlist" requires channels.slack.allowFrom (or channels.slack.dm.allowFrom) to contain at least one sender ID', + }); + const baseMode = value.mode ?? "socket"; if (baseMode === "http" && !value.signingSecret) { ctx.addIssue({ @@ -775,6 +836,26 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({ continue; } const accountMode = account.mode ?? baseMode; + const effectivePolicy = + account.dmPolicy ?? account.dm?.policy ?? value.dmPolicy ?? value.dm?.policy ?? "pairing"; + const effectiveAllowFrom = + account.allowFrom ?? account.dm?.allowFrom ?? value.allowFrom ?? value.dm?.allowFrom; + requireOpenAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.slack.accounts.*.dmPolicy="open" requires channels.slack.accounts.*.allowFrom (or channels.slack.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.slack.accounts.*.dmPolicy="allowlist" requires channels.slack.accounts.*.allowFrom (or channels.slack.allowFrom) to contain at least one sender ID', + }); if (accountMode !== "http") { continue; } @@ -858,6 +939,33 @@ export const SignalConfigSchema = SignalAccountSchemaBase.extend({ message: 'channels.signal.dmPolicy="allowlist" requires channels.signal.allowFrom to contain at least one sender ID', }); + + if (!value.accounts) { + return; + } + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + const effectivePolicy = account.dmPolicy ?? value.dmPolicy; + const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; + requireOpenAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.signal.accounts.*.dmPolicy="open" requires channels.signal.accounts.*.allowFrom (or channels.signal.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.signal.accounts.*.dmPolicy="allowlist" requires channels.signal.accounts.*.allowFrom (or channels.signal.allowFrom) to contain at least one sender ID', + }); + } }); export const IrcGroupSchema = z @@ -965,6 +1073,32 @@ export const IrcConfigSchema = IrcAccountSchemaBase.extend({ accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(), }).superRefine((value, ctx) => { refineIrcAllowFromAndNickserv(value, ctx); + if (!value.accounts) { + return; + } + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + const effectivePolicy = account.dmPolicy ?? value.dmPolicy; + const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; + requireOpenAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.irc.accounts.*.dmPolicy="open" requires channels.irc.accounts.*.allowFrom (or channels.irc.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.irc.accounts.*.dmPolicy="allowlist" requires channels.irc.accounts.*.allowFrom (or channels.irc.allowFrom) to contain at least one sender ID', + }); + } }); export const IMessageAccountSchemaBase = z @@ -1044,6 +1178,33 @@ export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({ message: 'channels.imessage.dmPolicy="allowlist" requires channels.imessage.allowFrom to contain at least one sender ID', }); + + if (!value.accounts) { + return; + } + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + const effectivePolicy = account.dmPolicy ?? value.dmPolicy; + const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; + requireOpenAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.imessage.accounts.*.dmPolicy="open" requires channels.imessage.accounts.*.allowFrom (or channels.imessage.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.imessage.accounts.*.dmPolicy="allowlist" requires channels.imessage.accounts.*.allowFrom (or channels.imessage.allowFrom) to contain at least one sender ID', + }); + } }); const BlueBubblesAllowFromEntry = z.union([z.string(), z.number()]); @@ -1128,6 +1289,33 @@ export const BlueBubblesConfigSchema = BlueBubblesAccountSchemaBase.extend({ message: 'channels.bluebubbles.dmPolicy="allowlist" requires channels.bluebubbles.allowFrom to contain at least one sender ID', }); + + if (!value.accounts) { + return; + } + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + const effectivePolicy = account.dmPolicy ?? value.dmPolicy; + const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; + requireOpenAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.bluebubbles.accounts.*.dmPolicy="open" requires channels.bluebubbles.accounts.*.allowFrom (or channels.bluebubbles.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.bluebubbles.accounts.*.dmPolicy="allowlist" requires channels.bluebubbles.accounts.*.allowFrom (or channels.bluebubbles.allowFrom) to contain at least one sender ID', + }); + } }); export const MSTeamsChannelSchema = z diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index ab5119a7786..b8ff2938abb 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -63,6 +63,7 @@ function enforceOpenDmPolicyAllowFromStar(params: { allowFrom: unknown; ctx: z.RefinementCtx; message: string; + path?: Array; }) { if (params.dmPolicy !== "open") { return; @@ -75,7 +76,7 @@ function enforceOpenDmPolicyAllowFromStar(params: { } params.ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ["allowFrom"], + path: params.path ?? ["allowFrom"], message: params.message, }); } @@ -85,6 +86,7 @@ function enforceAllowlistDmPolicyAllowFrom(params: { allowFrom: unknown; ctx: z.RefinementCtx; message: string; + path?: Array; }) { if (params.dmPolicy !== "allowlist") { return; @@ -97,7 +99,7 @@ function enforceAllowlistDmPolicyAllowFrom(params: { } params.ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ["allowFrom"], + path: params.path ?? ["allowFrom"], message: params.message, }); } @@ -108,23 +110,7 @@ export const WhatsAppAccountSchema = WhatsAppSharedSchema.extend({ /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ authDir: z.string().optional(), mediaMaxMb: z.number().int().positive().optional(), -}) - .strict() - .superRefine((value, ctx) => { - enforceOpenDmPolicyAllowFromStar({ - dmPolicy: value.dmPolicy, - allowFrom: value.allowFrom, - ctx, - message: 'channels.whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"', - }); - enforceAllowlistDmPolicyAllowFrom({ - dmPolicy: value.dmPolicy, - allowFrom: value.allowFrom, - ctx, - message: - 'channels.whatsapp.accounts.*.dmPolicy="allowlist" requires allowFrom to contain at least one sender ID', - }); - }); +}).strict(); export const WhatsAppConfigSchema = WhatsAppSharedSchema.extend({ accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(), @@ -154,4 +140,30 @@ export const WhatsAppConfigSchema = WhatsAppSharedSchema.extend({ message: 'channels.whatsapp.dmPolicy="allowlist" requires channels.whatsapp.allowFrom to contain at least one sender ID', }); + if (!value.accounts) { + return; + } + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + const effectivePolicy = account.dmPolicy ?? value.dmPolicy; + const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; + enforceOpenDmPolicyAllowFromStar({ + dmPolicy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.whatsapp.accounts.*.dmPolicy="open" requires channels.whatsapp.accounts.*.allowFrom (or channels.whatsapp.allowFrom) to include "*"', + }); + enforceAllowlistDmPolicyAllowFrom({ + dmPolicy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.whatsapp.accounts.*.dmPolicy="allowlist" requires channels.whatsapp.accounts.*.allowFrom (or channels.whatsapp.allowFrom) to contain at least one sender ID', + }); + } });