fix: enforce dm allowFrom inheritance across account channels (#27936) (thanks @widingmarcus-cyber)

This commit is contained in:
Peter Steinberger
2026-02-26 23:03:36 +01:00
parent 0fdac31383
commit 45d868685f
5 changed files with 369 additions and 121 deletions

View File

@@ -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.

View File

@@ -1112,23 +1112,40 @@ function detectEmptyAllowlistPolicy(cfg: OpenClawConfig): string[] {
const hasEntries = (list?: Array<string | number>) =>
Array.isArray(list) && list.map((v) => String(v).trim()).filter(Boolean).length > 0;
const checkAccount = (account: Record<string, unknown>, prefix: string) => {
const checkAccount = (
account: Record<string, unknown>,
prefix: string,
parent?: Record<string, unknown>,
) => {
const dmEntry = account.dm;
const dm =
dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry)
? (dmEntry as Record<string, unknown>)
: undefined;
const parentDmEntry = parent?.dm;
const parentDm =
parentDmEntry && typeof parentDmEntry === "object" && !Array.isArray(parentDmEntry)
? (parentDmEntry as Record<string, unknown>)
: 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<string | number> | undefined;
const topAllowFrom =
(account.allowFrom as Array<string | number> | undefined) ??
(parent?.allowFrom as Array<string | number> | undefined);
const nestedAllowFrom = dm?.allowFrom as Array<string | number> | undefined;
const parentNestedAllowFrom = parentDm?.allowFrom as Array<string | number> | 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);
}
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -63,6 +63,7 @@ function enforceOpenDmPolicyAllowFromStar(params: {
allowFrom: unknown;
ctx: z.RefinementCtx;
message: string;
path?: Array<string | number>;
}) {
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<string | number>;
}) {
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',
});
}
});