Skip to content

Commit d423b0a

Browse files
zhawtofclaude
andcommitted
chore: initial commit
Standalone JSON Schema (draft 2020-12) and validation helpers for Slack Block Kit payloads. Extracted from Tightknit's internal @tightknit/slack-block-kit-validator monorepo package. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0 parents  commit d423b0a

44 files changed

Lines changed: 7889 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/actions/setup/action.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Setup
2+
description: Install pnpm, Node.js, and dependencies
3+
4+
inputs:
5+
node-version:
6+
description: Node.js version
7+
required: false
8+
default: "22"
9+
10+
runs:
11+
using: composite
12+
steps:
13+
- uses: pnpm/action-setup@v4
14+
15+
- uses: actions/setup-node@v4
16+
with:
17+
node-version: ${{ inputs.node-version }}
18+
cache: pnpm
19+
20+
- run: pnpm install --frozen-lockfile
21+
shell: bash

.github/workflows/ci.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths-ignore:
7+
- "**.md"
8+
- "docs/**"
9+
- "LICENSE"
10+
pull_request:
11+
branches: [main]
12+
paths-ignore:
13+
- "**.md"
14+
- "docs/**"
15+
- "LICENSE"
16+
17+
concurrency:
18+
group: ${{ github.workflow }}-${{ github.ref }}
19+
cancel-in-progress: true
20+
21+
jobs:
22+
lint:
23+
name: Lint
24+
runs-on: ubuntu-latest
25+
timeout-minutes: 5
26+
steps:
27+
- uses: actions/checkout@v6
28+
- uses: ./.github/actions/setup
29+
- run: pnpm run lint
30+
31+
typecheck:
32+
name: Typecheck
33+
runs-on: ubuntu-latest
34+
timeout-minutes: 5
35+
steps:
36+
- uses: actions/checkout@v6
37+
- uses: ./.github/actions/setup
38+
- run: pnpm run typecheck
39+
40+
test:
41+
name: Test (Node ${{ matrix.node-version }})
42+
runs-on: ubuntu-latest
43+
timeout-minutes: 5
44+
strategy:
45+
matrix:
46+
node-version: [20, 22]
47+
steps:
48+
- uses: actions/checkout@v6
49+
- uses: ./.github/actions/setup
50+
with:
51+
node-version: ${{ matrix.node-version }}
52+
- run: pnpm test
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Dependabot auto-merge
2+
3+
on: pull_request
4+
5+
permissions:
6+
contents: write
7+
pull-requests: write
8+
9+
jobs:
10+
dependabot:
11+
runs-on: ubuntu-latest
12+
if: github.actor == 'dependabot[bot]'
13+
steps:
14+
- name: Fetch Dependabot metadata
15+
id: metadata
16+
uses: dependabot/fetch-metadata@v2
17+
with:
18+
github-token: "${{ secrets.GITHUB_TOKEN }}"
19+
20+
- name: Enable auto-merge for minor/patch updates
21+
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
22+
run: gh pr merge --auto --squash "$PR_URL"
23+
env:
24+
PR_URL: ${{ github.event.pull_request.html_url }}
25+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26+
27+
- name: Approve PR
28+
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
29+
run: gh pr review --approve "$PR_URL"
30+
env:
31+
PR_URL: ${{ github.event.pull_request.html_url }}
32+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/publish.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Publish
2+
3+
on:
4+
release:
5+
types: [created]
6+
7+
concurrency:
8+
group: ${{ github.workflow }}-${{ github.ref }}
9+
cancel-in-progress: true
10+
11+
jobs:
12+
publish:
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 10
15+
permissions:
16+
contents: read
17+
id-token: write
18+
steps:
19+
- uses: actions/checkout@v6
20+
- uses: ./.github/actions/setup
21+
- run: pnpm run lint
22+
- run: pnpm run typecheck
23+
- run: pnpm test
24+
- run: pnpm run build:clean
25+
- run: pnpm publish --provenance --access public --no-git-checks
26+
env:
27+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: Release Please
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
permissions:
8+
contents: write
9+
pull-requests: write
10+
11+
jobs:
12+
release-please:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: googleapis/release-please-action@v4
16+
with:
17+
config-file: release-please-config.json
18+
manifest-file: .release-please-manifest.json

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
node_modules/
2+
dist/
3+
coverage/
4+
*.tsbuildinfo
5+
.env
6+
.env.*
7+
.DS_Store
8+
*.log

.release-please-manifest.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
".": "0.1.0-alpha.0"
3+
}

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Changelog

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Tightknit and slack-block-kit-validator contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# slack-block-kit-validator
2+
3+
[![CI](https://github.com/TightknitAI/slack-block-kit-validator/actions/workflows/ci.yml/badge.svg)](https://github.com/TightknitAI/slack-block-kit-validator/actions/workflows/ci.yml)
4+
[![npm version](https://img.shields.io/npm/v/slack-block-kit-validator.svg)](https://www.npmjs.com/package/slack-block-kit-validator)
5+
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6+
7+
JSON Schema (draft 2020-12) and validation helpers for Slack Block Kit JSON. Catches invalid block payloads before Slack silently swallows them.
8+
9+
## Why this exists
10+
11+
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.
12+
13+
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).
14+
15+
## Install
16+
17+
```sh
18+
pnpm add slack-block-kit-validator
19+
```
20+
21+
Node 20+. No runtime peer dependencies.
22+
23+
## Quick start
24+
25+
```ts
26+
import { validateBlockKit } from "slack-block-kit-validator";
27+
28+
const blocks = [
29+
{ type: "section", text: { type: "mrkdwn", text: "Hello *world*" } },
30+
];
31+
32+
const { valid, errors } = validateBlockKit(blocks);
33+
if (!valid) {
34+
console.error(errors);
35+
}
36+
```
37+
38+
### Validating views
39+
40+
`validateBlockKit` takes an optional `target` for modal / home view envelopes:
41+
42+
```ts
43+
validateBlockKit(modalView, { target: "modal" });
44+
validateBlockKit(homeView, { target: "home" });
45+
```
46+
47+
When `target` is `modal` or `home`, the surface-compatibility check is enforced automatically. For bare blocks arrays, pass `surface` explicitly:
48+
49+
```ts
50+
validateBlockKit(blocks, { surface: "message" });
51+
// rejects input blocks, table on non-message surfaces, etc.
52+
```
53+
54+
### Example output
55+
56+
```ts
57+
validateBlockKit([
58+
{ type: "section" },
59+
{ type: "divider", block_id: "x" },
60+
{ type: "divider", block_id: "x" },
61+
]);
62+
63+
// {
64+
// valid: false,
65+
// errors: [
66+
// "/0 must match a schema in anyOf",
67+
// "blocks[2].block_id must be unique — 'x' appears at index 1 and 2"
68+
// ]
69+
// }
70+
```
71+
72+
## Using the helpers à la carte
73+
74+
The helpers are pure (no deps, no Ajv) and can be stacked on top of any validator — Zod, TypeBox, or a hand-rolled check:
75+
76+
```ts
77+
import {
78+
findDuplicateBlockIds,
79+
checkCumulativeMarkdownLength,
80+
checkSingleTableBlock,
81+
checkFocusOnLoadUniqueness,
82+
checkSurfaceCompatibility,
83+
} from "slack-block-kit-validator";
84+
```
85+
86+
### Using the raw JSON Schema
87+
88+
Consuming from another validator, another language, or an OpenAPI spec:
89+
90+
```ts
91+
import { slackBlockKitSchema } from "slack-block-kit-validator";
92+
93+
// With Ajv in a custom config
94+
const ajv = new Ajv2020({ strict: false, allErrors: true });
95+
const validate = ajv.compile(slackBlockKitSchema);
96+
```
97+
98+
The schema uses `$defs` for every block, element, composition object, rich-text leaf, and view envelope, so non-JS consumers can also import the JSON (`slack-block-kit-validator/schema.json`) and pick the subset they need.
99+
100+
## API reference
101+
102+
### Wrapper
103+
104+
| Export | Signature | Description |
105+
|---|---|---|
106+
| `validateBlockKit` | `(input, opts?) => { valid, errors[] }` | Runs schema + all caveat helpers. Defaults to validating a bare blocks array. |
107+
| `ValidationResult` | `{ valid: boolean; errors: string[] }` | Return type. |
108+
| `ValidationTarget` | `'blocks' \| 'modal' \| 'home'` | What shape `input` should match. |
109+
| `ValidateBlockKitOptions` | `{ target?, surface? }` | Options bag. |
110+
| `Surface` | `'message' \| 'modal' \| 'home'` | Surface compatibility target. |
111+
112+
### Helpers
113+
114+
| Helper | Signature | What it checks |
115+
|---|---|---|
116+
| `findDuplicateBlockIds` | `(blocks) => string[]` | Duplicate `block_id` values in a blocks array. |
117+
| `checkCumulativeMarkdownLength` | `(blocks) => string[]` | Sum of all `markdown` block text > 12,000 chars. |
118+
| `checkSingleTableBlock` | `(blocks) => string[]` | More than one `table` block per payload. |
119+
| `checkFocusOnLoadUniqueness` | `(blocks) => string[]` | More than one element with `focus_on_load: true` in a view (walks nested elements + accessories). |
120+
| `checkSurfaceCompatibility` | `(blocks, surface) => string[]` | Blocks not allowed on the target surface (e.g. `input` on message, `video` on modal, `file_input` outside modals). |
121+
| `checkCardActionsMax` | `(blocks) => string[]` | More than `CARD_ACTIONS_MAX` action buttons on a card block. |
122+
| `checkNumberInputBounds` | `(blocks) => string[]` | `number_input` element with `min_value > max_value`. |
123+
| `checkResponseUrlEnabledContext` | `(blocks, surface?) => string[]` | `response_url_enabled` set in contexts that don't support it. |
124+
125+
Each returns an array of human-readable error strings — empty when valid.
126+
127+
### Schema
128+
129+
| Export | Description |
130+
|---|---|
131+
| `slackBlockKitSchema` | The full JSON Schema as a parsed object. `$id` is `https://tightknit.com/schemas/slack-block-kit.schema.json`. |
132+
133+
## Coverage
134+
135+
- **18 blocks**: actions, alert, card, carousel, context, context_actions, divider, file, header, image, input, markdown, plan, rich_text, section, table, task_card, video.
136+
- **All block elements**: button, icon_button, workflow_button, feedback_buttons, plain_text / email / url / number inputs, datepicker, datetimepicker, timepicker, file_input, rich_text_input, checkboxes, radio_buttons, image, overflow, url source, and all 5 single + 5 multi-select menu variants.
137+
- **All 9 composition objects**: text (plain_text + mrkdwn), confirm, option (3 contextual variants), option_group, slack_file, dispatch_action_config, conversation_filter, trigger, workflow.
138+
- **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.
139+
- **View envelopes**: `modal_view` + `home_view` under `$defs`.
140+
- **Cross-payload rules** (via helpers): dup `block_id`, cumulative markdown, single-table, `focus_on_load` uniqueness, surface compatibility.
141+
142+
Every documented `maxLength`, regex (date / time / user ID / channel ID / team ID format), enum value, and array cardinality limit is enforced structurally.
143+
144+
## What isn't enforced
145+
146+
Server-side rules that need app-config context or deep equality checks the schema doesn't attempt:
147+
148+
- Slack OAuth scope requirements (e.g. `links.embed:write` for video blocks).
149+
- Initial-value matching (e.g. `radio_buttons.initial_option` must equal one of `options` by deep equality).
150+
- `video_url` must match the app's configured unfurl domains.
151+
- 10 MB per-file limit on `file_input` uploads.
152+
- Slack's `block_id`-must-not-start-with `block_` rule is folklore (not stated on any current docs page) and intentionally not enforced.
153+
154+
## Note on `undefined` properties
155+
156+
`validateBlockKit` strips properties whose value is `undefined` before running Ajv. `JSON.stringify` drops these before the payload reaches Slack, so common builder patterns like `value: foo ?? undefined` are no-ops on the wire but would otherwise trip the schema's `additionalProperties: false`. Explicit `null` values are preserved — they survive `JSON.stringify` and may legitimately fail the schema.
157+
158+
## Runtime & bundle considerations
159+
160+
Ajv v8 + ajv-formats weigh ~50–60 KB gzipped, which is fine for Node test runs but noticeable in a Cloudflare Worker bundle. If you want runtime validation inside a Worker, use the package's `compile:standalone` script to emit a self-contained validator that doesn't import Ajv at runtime:
161+
162+
```sh
163+
pnpm dlx ajv compile --spec=draft2020 --strict=false -c ajv-formats \
164+
-s node_modules/slack-block-kit-validator/dist/slack-block-kit.schema.json \
165+
-o standalone-validator.js
166+
```
167+
168+
For most apps the recommended pattern is **validate in tests only** — use `validateBlockKit` in unit tests next to your block builders, and let CI catch regressions before they hit production.
169+
170+
## Keeping up with Slack
171+
172+
Slack adds new block types and fields over time. To update:
173+
174+
1. Diff the latest at <https://docs.slack.dev/reference/block-kit> against the commit history of `src/slack-block-kit.schema.json`.
175+
2. Add or amend the relevant `$defs/*` entry, including required / optional / maxLength / enum.
176+
3. For new top-level blocks, add to `$defs/block.oneOf` and the `modal_view` / `home_view` inner arrays if applicable.
177+
4. For new elements, add to the relevant parent-container whitelist (`actions_block_element`, `section_accessory_element`, `input_block_element`, `context_block_element`, or `context_actions_block_element`).
178+
5. Add fixtures to `test/` covering both valid and invalid payloads.
179+
180+
## License
181+
182+
MIT. See [LICENSE](./LICENSE).
183+
184+
---
185+
186+
Maintained by the [Tightknit](https://tightknit.ai) team.

0 commit comments

Comments
 (0)