fix(channels): config validation rejects documented channels.<id>.configWrites on 13 channels (#117206)

This commit is contained in:
Ayaan Gazali
2026-08-01 00:33:16 -07:00
committed by GitHub
parent 730cf56915
commit 11069c3df5
18 changed files with 109 additions and 22 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"core": 2307,
"channel": 3664,
"channel": 3692,
"plugin": 4055
}
+3 -3
View File
@@ -1,4 +1,4 @@
820fe810979007010e2ade951ac178d204fcac10e48a6657cdd4b75de6ce0aee config-baseline.json
00b1e5142ead3ba8522cc4815dfb6f8d3fb5ea4e2937df93da280d44506308d7 config-baseline.json
b89715475e4b18a0d32765fda42bcce38537f6d49f949bb4ff0c7d1630101882 config-baseline.core.json
26077716f773821c1ad07160632c3a5ed48f7bdcc95ea82cf77c99bb8bba5834 config-baseline.channel.json
c02f1b49ac814cb27fd692a8107528b6ccfa7ea9e5aea854fc81830abca38eb4 config-baseline.plugin.json
e9a81ee89ff032033012413e161316e4d07e8f6b206382a25fed2f8485151b5e config-baseline.channel.json
d0352edda9ee2e6ff3c52b163bf2364ffef6a1d0d2ffe3b0b38de28756fd6c1b config-baseline.plugin.json
+1
View File
@@ -18,6 +18,7 @@ const RawBuzzConfigSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
markdown: MarkdownConfigSchema,
relayUrl: z
.string()
@@ -12,6 +12,7 @@ const ClickClackAccountConfigSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
baseUrl: z.string().url().optional(),
apiBaseUrl: z.string().url().optional(),
token: buildSecretInputSchema().optional(),
+1
View File
@@ -22,6 +22,7 @@ const ThreadBindingsSchema = z
const LineCommonConfigSchemaBase = z.object({
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
channelAccessToken: z.string().optional(),
channelSecret: z.string().optional(),
tokenFile: z.string().optional(),
+1
View File
@@ -130,6 +130,7 @@ function hasCanonicalMatrixAccountStreaming(account: unknown): boolean {
const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
defaultAccount: z.string().optional(),
// Accounts stay schema-open, but retired scalar streaming must fail loudly
// instead of silently resolving to "off"; doctor migrates the old spelling.
@@ -28,6 +28,7 @@ const NextcloudTalkAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
markdown: MarkdownConfigSchema,
baseUrl: z.string().optional(),
botSecret: buildSecretInputSchema().optional(),
+1
View File
@@ -78,6 +78,7 @@ export const NostrConfigSchema = z.object({
/** Whether this channel is enabled */
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
/** Markdown formatting overrides (tables). */
markdown: MarkdownConfigSchema,
@@ -26,6 +26,7 @@ const QaChannelAccountConfigSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
baseUrl: z.string().url().optional(),
botUserId: z.string().optional(),
botDisplayName: z.string().optional(),
+1
View File
@@ -9,6 +9,7 @@ const RaftAccountSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
profile: z.string().min(1).optional(),
})
.strict();
+1
View File
@@ -13,6 +13,7 @@ const RelayUrlSchema = z
export const ReefChannelConfigSchema = z
.object({
enabled: z.boolean().default(true),
configWrites: z.boolean().optional(),
relayUrl: RelayUrlSchema.default("https://reefwire.ai"),
handle: HandleSchema.optional(),
email: z.email().optional(),
+1
View File
@@ -16,6 +16,7 @@ const SmsAccountConfigSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
accountSid: z.string().optional(),
authToken: SecretInputSchema.optional(),
fromNumber: z.string().optional(),
+1
View File
@@ -28,6 +28,7 @@ const TlonNetworkSchema = z
const tlonCommonConfigFields = {
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
ship: ShipSchema.optional(),
url: z.string().optional(),
code: z.string().optional(),
+3
View File
@@ -18,6 +18,8 @@ const TwitchAccountShape = {
channel: z.string().min(1),
/** Enable this account */
enabled: z.boolean().optional(),
/** Allow channel-initiated configuration writes */
configWrites: z.boolean().optional(),
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
allowFrom: z.array(z.string()).optional(),
/** Roles allowed to interact with the bot (e.g., ["moderator", "vip", "subscriber"]) */
@@ -47,6 +49,7 @@ const TwitchAccountSchema = z.object(TwitchAccountShape);
const TwitchConfigBaseShape = {
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
markdown: MarkdownConfigSchema.optional(),
defaultAccount: z.string().optional(),
};
+1
View File
@@ -12,6 +12,7 @@ import { buildSecretInputSchema } from "./secret-input.js";
const zaloAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
markdown: MarkdownConfigSchema,
botToken: buildSecretInputSchema().optional(),
tokenFile: z.string().optional(),
+1
View File
@@ -16,6 +16,7 @@ const groupConfigSchema = buildGroupEntrySchema()
const zalouserAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
File diff suppressed because one or more lines are too long
@@ -0,0 +1,71 @@
// Verifies every bundled channel schema accepts the documented configWrites policy key.
import { describe, expect, it } from "vitest";
import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "../../config/bundled-channel-config-metadata.generated.js";
type JsonSchemaLike = {
properties?: Record<string, unknown>;
additionalProperties?: unknown;
allOf?: unknown[];
anyOf?: unknown[];
oneOf?: unknown[];
};
function asSchema(value: unknown): JsonSchemaLike | undefined {
return value && typeof value === "object" ? (value as JsonSchemaLike) : undefined;
}
function schemasAtLevel(schema: JsonSchemaLike | undefined): JsonSchemaLike[] {
if (!schema) {
return [];
}
return [
schema,
...[schema.allOf, schema.anyOf, schema.oneOf]
.flatMap((variants) => variants ?? [])
.flatMap((variant) => schemasAtLevel(asSchema(variant))),
];
}
/** Any closed alternative silently refuses the whole config when the documented key is missing. */
function rejectsKey(schema: JsonSchemaLike | undefined, key: string): boolean {
return schemasAtLevel(schema).some(
(candidate) =>
candidate.additionalProperties === false && !Object.hasOwn(candidate.properties ?? {}, key),
);
}
function accountSchemas(schema: JsonSchemaLike | undefined): JsonSchemaLike[] {
return schemasAtLevel(schema).flatMap((root) => {
const accounts = asSchema(root.properties?.accounts);
return schemasAtLevel(accounts).flatMap((accountsVariant) =>
schemasAtLevel(asSchema(accountsVariant.additionalProperties)),
);
});
}
describe("bundled channel configWrites contract", () => {
const channels = GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.map((entry) => ({
id: entry.channelId,
schema: asSchema(entry.schema),
}));
it("covers the bundled channels", () => {
expect(channels.length).toBeGreaterThan(0);
});
it.each(channels.map((channel) => [channel.id, channel] as const))(
"%s accepts channels.<id>.configWrites",
(_id, channel) => {
expect(rejectsKey(channel.schema, "configWrites")).toBe(false);
},
);
it.each(channels.map((channel) => [channel.id, channel] as const))(
"%s accepts channels.<id>.accounts.<account>.configWrites",
(_id, channel) => {
expect(
accountSchemas(channel.schema).some((account) => rejectsKey(account, "configWrites")),
).toBe(false);
},
);
});