Skip to content

Commit 9e78182

Browse files
fix: flag duplicate action_id within a block (#67)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 1f75f58 commit 9e78182

7 files changed

Lines changed: 258 additions & 4 deletions

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ JSON Schema (draft 2020-12) and validation helpers for Slack Block Kit JSON. Cat
1515

1616
Slack's API returns `200 OK` when you send malformed Block Kit JSON — the metadata is dropped and the message renders as plain text (or a modal opens blank). The only way to find out is to eyeball a real Slack channel. Slack hasn't open-sourced their validator.
1717

18-
This package compiles every rule in <https://docs.slack.dev/reference/block-kit> into a single JSON Schema, plus a handful of helpers for the cross-payload rules JSON Schema can't express (duplicate `block_id`, cumulative markdown length, one-table-per-message, `focus_on_load` uniqueness, surface compatibility).
18+
This package compiles every rule in <https://docs.slack.dev/reference/block-kit> into a single JSON Schema, plus a handful of helpers for the cross-payload rules JSON Schema can't express (duplicate `block_id`, duplicate `action_id` within a block, cumulative markdown length, one-table-per-message, `focus_on_load` uniqueness, surface compatibility).
1919

2020
> Prefer an API call or an MCP server over an npm install? The repo also ships a Cloudflare Worker that exposes this package as a public, rate-limited HTTP endpoint plus a remote MCP server — see [`worker/`](./worker).
2121
@@ -83,6 +83,7 @@ The helpers are pure (no deps, no Ajv) and can be stacked on top of any validato
8383
```ts
8484
import {
8585
findDuplicateBlockIds,
86+
findDuplicateActionIds,
8687
checkCumulativeMarkdownLength,
8788
checkSingleTableBlock,
8889
checkSinglePlanBlock,
@@ -122,6 +123,7 @@ The schema uses `$defs` for every block, element, composition object, rich-text
122123
| Helper | Signature | What it checks |
123124
|---|---|---|
124125
| `findDuplicateBlockIds` | `(blocks) => string[]` | Duplicate `block_id` values in a blocks array. |
126+
| `findDuplicateActionIds` | `(blocks) => string[]` | Duplicate `action_id` values *within* a single block (legal across different blocks). |
125127
| `checkCumulativeMarkdownLength` | `(blocks) => string[]` | Sum of all `markdown` block text > 12,000 chars. |
126128
| `checkSingleTableBlock` | `(blocks) => string[]` | More than one `table` block per payload. |
127129
| `checkSinglePlanBlock` | `(blocks) => string[]` | More than one `plan` block per payload. |
@@ -148,7 +150,7 @@ Each returns an array of human-readable error strings — empty when valid.
148150
- **All 9 composition objects**: text (plain_text + mrkdwn), confirm, option (3 contextual variants), option_group, slack_file, dispatch_action_config, conversation_filter, trigger, workflow.
149151
- **Rich text**: 4 container kinds (section, list, preformatted, quote) + 10 leaf kinds (text, link, user, usergroup, team, channel, emoji, broadcast, color, date) with style flags.
150152
- **View envelopes**: `modal_view` + `home_view` under `$defs`.
151-
- **Cross-payload rules** (via helpers): dup `block_id`, cumulative markdown, single-table, single-plan, two-data-visualization-per-message, chart series/category consistency, `focus_on_load` uniqueness, surface compatibility.
153+
- **Cross-payload rules** (via helpers): dup `block_id`, dup `action_id` within a block, cumulative markdown, single-table, single-plan, two-data-visualization-per-message, chart series/category consistency, `focus_on_load` uniqueness, surface compatibility.
152154

153155
Every documented `maxLength`, regex (date / time / user ID / channel ID / team ID format), enum value, and array cardinality limit is enforced structurally.
154156

src/helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export * from "./helpers/check-response-url-enabled-context.js";
1212
export * from "./helpers/check-single-plan-block.js";
1313
export * from "./helpers/check-single-table-block.js";
1414
export * from "./helpers/check-surface-compatibility.js";
15+
export * from "./helpers/find-duplicate-action-ids.js";
1516
export * from "./helpers/find-duplicate-block-ids.js";
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Block properties that hold a block's interactive elements. Slack keys
3+
* `state.values` by `block_id` then `action_id`, so everything reachable from
4+
* one block shares a single `action_id` namespace. `element` / `accessory` are
5+
* single elements — they can't collide with anything, but they're listed for
6+
* consistency and cost nothing.
7+
*/
8+
const ELEMENT_KEYS = ["elements", "actions", "element", "accessory"] as const;
9+
10+
/**
11+
* Blocks whose children are themselves blocks, each opening its own
12+
* `action_id` namespace rather than sharing the parent's — a `carousel`'s
13+
* cards and a `container`'s child blocks each carry their own `block_id`.
14+
*/
15+
const CHILD_BLOCK_KEYS = new Map<string, string>([
16+
["carousel", "elements"],
17+
["container", "child_blocks"],
18+
]);
19+
20+
/**
21+
* Cap for recursive traversal of block payloads. Well-formed payloads nest at
22+
* most one level here (carousel → card, container → child block), so anything
23+
* deeper is malformed or adversarial. Matches the cap used in the other
24+
* walkers (`check-focus-on-load-uniqueness`, `check-number-input-bounds`).
25+
*/
26+
const MAX_WALK_DEPTH = 50;
27+
28+
const isRecord = (value: unknown): value is Record<string, unknown> =>
29+
value !== null && typeof value === "object" && !Array.isArray(value);
30+
31+
/**
32+
* Returns error messages describing any duplicate `action_id` values within a
33+
* single block. Slack rejects a payload where two interactive elements in the
34+
* same block share an `action_id`. JSON Schema can't express per-property
35+
* uniqueness across sibling items, so this check sits alongside structural
36+
* validation like {@link findDuplicateBlockIds}.
37+
*
38+
* Uniqueness is required *within* a block, not across the payload: `state.values`
39+
* is keyed `block_id` then `action_id`, so the same `action_id` in two different
40+
* blocks is legal and is not flagged.
41+
* @param blocks - array of Block Kit blocks (or anything with a nested shape)
42+
* @returns array of human-readable error messages (empty when no duplicates)
43+
*/
44+
export function findDuplicateActionIds(blocks: readonly unknown[]): string[] {
45+
const errors: string[] = [];
46+
47+
const visit = (block: unknown, path: string, depth: number): void => {
48+
if (!isRecord(block) || depth > MAX_WALK_DEPTH) {
49+
return;
50+
}
51+
52+
const childKey = typeof block.type === "string" ? CHILD_BLOCK_KEYS.get(block.type) : undefined;
53+
if (childKey !== undefined) {
54+
const children = block[childKey];
55+
if (Array.isArray(children)) {
56+
children.forEach((child, i) => {
57+
visit(child, `${path}.${childKey}[${i}]`, depth + 1);
58+
});
59+
}
60+
return;
61+
}
62+
63+
const seen = new Map<string, string>();
64+
for (const key of ELEMENT_KEYS) {
65+
const value = block[key];
66+
const entries: [string, unknown][] = Array.isArray(value)
67+
? value.map((element, i) => [`${key}[${i}]`, element])
68+
: [[key, value]];
69+
for (const [rel, element] of entries) {
70+
if (!isRecord(element) || typeof element.action_id !== "string") {
71+
continue;
72+
}
73+
const id = element.action_id;
74+
const prev = seen.get(id);
75+
if (prev !== undefined) {
76+
errors.push(
77+
`${path}.${rel}.action_id must be unique within the block — '${id}' appears at ${prev} and ${rel}`,
78+
);
79+
} else {
80+
seen.set(id, rel);
81+
}
82+
}
83+
}
84+
};
85+
86+
blocks.forEach((block, i) => {
87+
visit(block, `blocks[${i}]`, 0);
88+
});
89+
return errors;
90+
}

src/validate-block-kit.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { checkResponseUrlEnabledContext } from "./helpers/check-response-url-ena
1111
import { checkSinglePlanBlock } from "./helpers/check-single-plan-block.js";
1212
import { checkSingleTableBlock } from "./helpers/check-single-table-block.js";
1313
import { checkSurfaceCompatibility, type Surface } from "./helpers/check-surface-compatibility.js";
14+
import { findDuplicateActionIds } from "./helpers/find-duplicate-action-ids.js";
1415
import { findDuplicateBlockIds } from "./helpers/find-duplicate-block-ids.js";
1516
import schema from "./slack-block-kit.schema.json" with { type: "json" };
1617

@@ -171,8 +172,9 @@ const stripUndefined = (input: unknown, depth = 0): unknown => {
171172

172173
/**
173174
* Validates a Slack Block Kit payload against the JSON Schema and the set of
174-
* caveat helpers (duplicate block_ids, cumulative markdown length, single-table,
175-
* single-plan, focus_on_load uniqueness, surface compatibility).
175+
* caveat helpers (duplicate block_ids, duplicate action_ids within a block,
176+
* cumulative markdown length, single-table, single-plan, focus_on_load
177+
* uniqueness, surface compatibility).
176178
* @param input - the payload to validate
177179
* @param options - target shape + optional surface
178180
* @returns `{ valid, errors }` — `errors` is a flat array of human-readable messages
@@ -198,6 +200,7 @@ export function validateBlockKit(input: unknown, options: ValidateBlockKitOption
198200
}[];
199201

200202
errors.push(...findDuplicateBlockIds(blocks));
203+
errors.push(...findDuplicateActionIds(blocks));
201204
errors.push(...checkCumulativeMarkdownLength(blocks));
202205
errors.push(...checkSingleTableBlock(blocks));
203206
errors.push(...checkSinglePlanBlock(blocks));
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { findDuplicateActionIds } from "../src/helpers/find-duplicate-action-ids";
2+
3+
const button = (action_id?: string) => ({
4+
type: "button",
5+
text: { type: "plain_text", text: "Go", emoji: true },
6+
...(action_id === undefined ? {} : { action_id }),
7+
});
8+
9+
describe("findDuplicateActionIds", () => {
10+
it("returns no errors when action_ids within a block are unique", () => {
11+
expect(findDuplicateActionIds([{ type: "actions", elements: [button("a"), button("b")] }])).toEqual([]);
12+
});
13+
14+
it("returns no errors when elements have no action_id", () => {
15+
expect(findDuplicateActionIds([{ type: "actions", elements: [button(), button()] }])).toEqual([]);
16+
});
17+
18+
it("flags a duplicate action_id in an actions block with both positions", () => {
19+
expect(findDuplicateActionIds([{ type: "actions", elements: [button("123"), button("123")] }])).toEqual([
20+
"blocks[0].elements[1].action_id must be unique within the block — '123' appears at elements[0] and elements[1]",
21+
]);
22+
});
23+
24+
it("flags each repeat of the same action_id independently", () => {
25+
const errors = findDuplicateActionIds([
26+
{ type: "actions", elements: [button("a"), button("b"), button("a"), button("a")] },
27+
]);
28+
expect(errors).toHaveLength(2);
29+
expect(errors[0]).toContain("'a' appears at elements[0] and elements[2]");
30+
expect(errors[1]).toContain("'a' appears at elements[0] and elements[3]");
31+
});
32+
33+
it("allows the same action_id in two different blocks (state.values is keyed by block_id first)", () => {
34+
expect(
35+
findDuplicateActionIds([
36+
{ type: "actions", block_id: "one", elements: [button("a")] },
37+
{ type: "actions", block_id: "two", elements: [button("a")] },
38+
{ type: "section", text: { type: "mrkdwn", text: "hi" }, accessory: button("a") },
39+
]),
40+
).toEqual([]);
41+
});
42+
43+
it("flags duplicates in a context_actions block", () => {
44+
const errors = findDuplicateActionIds([
45+
{
46+
type: "context_actions",
47+
elements: [
48+
{ type: "icon_button", icon: "bolt", action_id: "x" },
49+
{ type: "icon_button", icon: "bolt", action_id: "x" },
50+
],
51+
},
52+
]);
53+
expect(errors).toEqual([
54+
"blocks[0].elements[1].action_id must be unique within the block — 'x' appears at elements[0] and elements[1]",
55+
]);
56+
});
57+
58+
it("flags duplicates across a card block's actions", () => {
59+
expect(findDuplicateActionIds([{ type: "card", actions: [button("go"), button("go")] }])).toEqual([
60+
"blocks[0].actions[1].action_id must be unique within the block — 'go' appears at actions[0] and actions[1]",
61+
]);
62+
});
63+
64+
it("scopes carousel cards separately and reports the offending card's path", () => {
65+
const errors = findDuplicateActionIds([
66+
{
67+
type: "carousel",
68+
elements: [
69+
{ type: "card", actions: [button("go")] },
70+
{ type: "card", actions: [button("go"), button("go")] },
71+
],
72+
},
73+
]);
74+
expect(errors).toEqual([
75+
"blocks[0].elements[1].actions[1].action_id must be unique within the block — 'go' appears at actions[0] and actions[1]",
76+
]);
77+
});
78+
79+
it("scopes container child blocks separately", () => {
80+
const errors = findDuplicateActionIds([
81+
{
82+
type: "container",
83+
child_blocks: [
84+
{ type: "actions", elements: [button("go")] },
85+
{ type: "actions", elements: [button("go"), button("go")] },
86+
],
87+
},
88+
]);
89+
expect(errors).toEqual([
90+
"blocks[0].child_blocks[1].elements[1].action_id must be unique within the block — 'go' appears at elements[0] and elements[1]",
91+
]);
92+
});
93+
94+
it("shares one namespace across an input block's element and any sibling keys", () => {
95+
expect(
96+
findDuplicateActionIds([
97+
{
98+
type: "input",
99+
label: { type: "plain_text", text: "Name" },
100+
element: { type: "plain_text_input", action_id: "a" },
101+
},
102+
]),
103+
).toEqual([]);
104+
});
105+
106+
it("ignores non-string action_ids", () => {
107+
expect(
108+
findDuplicateActionIds([{ type: "actions", elements: [{ action_id: 1 }, { action_id: 1 }, null, "nope"] }]),
109+
).toEqual([]);
110+
});
111+
112+
it("accepts an empty array", () => {
113+
expect(findDuplicateActionIds([])).toEqual([]);
114+
});
115+
116+
it("terminates on pathologically nested containers", () => {
117+
let block: unknown = { type: "actions", elements: [button("a"), button("a")] };
118+
for (let i = 0; i < 200; i++) {
119+
block = { type: "container", child_blocks: [block] };
120+
}
121+
expect(findDuplicateActionIds([block])).toEqual([]);
122+
});
123+
});

test/property-based.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fc from "fast-check";
22
import { checkFocusOnLoadUniqueness } from "../src/helpers/check-focus-on-load-uniqueness";
33
import { checkNumberInputBounds } from "../src/helpers/check-number-input-bounds";
4+
import { findDuplicateActionIds } from "../src/helpers/find-duplicate-action-ids";
45
import { findDuplicateBlockIds } from "../src/helpers/find-duplicate-block-ids";
56
import { validateBlockKit } from "../src/validate-block-kit";
67

@@ -32,6 +33,16 @@ describe("property: helpers return without throwing on arbitrary input", () => {
3233
);
3334
});
3435

36+
it("findDuplicateActionIds terminates on deeply nested input", () => {
37+
fc.assert(
38+
fc.property(fc.array(arbAnything(), { maxLength: 10 }), (xs) => {
39+
const errs = findDuplicateActionIds(xs);
40+
expect(Array.isArray(errs)).toBe(true);
41+
}),
42+
{ numRuns: 200 },
43+
);
44+
});
45+
3546
it("checkFocusOnLoadUniqueness terminates on deeply nested input", () => {
3647
fc.assert(
3748
fc.property(fc.array(arbAnything(), { maxLength: 10 }), (xs) => {

test/validate-block-kit.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,30 @@ describe("validateBlockKit", () => {
2323
expect(result.errors.some((e) => e.includes("only one 'table' block"))).toBe(true);
2424
});
2525

26+
it("flags two elements in one block sharing an action_id", () => {
27+
const button = (text: string) => ({
28+
type: "button",
29+
text: { type: "plain_text", text, emoji: true },
30+
action_id: "123",
31+
});
32+
const result = validateBlockKit([{ type: "actions", elements: [button("A"), button("B")] }]);
33+
expect(result.valid).toBe(false);
34+
expect(result.errors).toEqual([
35+
"blocks[0].elements[1].action_id must be unique within the block — '123' appears at elements[0] and elements[1]",
36+
]);
37+
});
38+
39+
it("accepts the same action_id in two different blocks", () => {
40+
const actions = (block_id: string) => ({
41+
type: "actions",
42+
block_id,
43+
elements: [{ type: "button", text: { type: "plain_text", text: "Go", emoji: true }, action_id: "123" }],
44+
});
45+
const result = validateBlockKit([actions("one"), actions("two")], { surface: "message" });
46+
expect(result.valid).toBe(true);
47+
expect(result.errors).toEqual([]);
48+
});
49+
2650
it("accepts a single plan block", () => {
2751
const result = validateBlockKit([{ type: "plan", title: "Sprint plan" }]);
2852
expect(result.valid).toBe(true);

0 commit comments

Comments
 (0)