@vercel/board 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +8 -17
  2. package/dist/{a2-D_28Pdip.mjs → a2-DngWY2Eb.mjs} +573 -361
  3. package/dist/{access-CQ39gqEV.mjs → access-CY5xVMIK.mjs} +19 -3
  4. package/dist/{access-CV96SYgr.d.mts → access-DPcrKIH_.d.mts} +6 -1
  5. package/dist/access.d.mts +4 -4
  6. package/dist/access.mjs +5 -4
  7. package/dist/{actors-xPiamLWk.mjs → actors-Bm-ojWWT.mjs} +2 -2
  8. package/dist/{auth-BPinwfN0.mjs → auth-CyO2aw5n.mjs} +127 -14
  9. package/dist/{auth-CU56Q2Xh.d.mts → auth-DLYSt_-A.d.mts} +32 -4
  10. package/dist/{backend-n3hajfrD.d.mts → backend-Bob-5CM7.d.mts} +86 -41
  11. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  12. package/dist/backend-a2/contract.d.mts +37 -97
  13. package/dist/backend-a2/contract.mjs +1 -1
  14. package/dist/backend-a2/store-memory.d.mts +1 -1
  15. package/dist/backend-a2/store-memory.mjs +2 -2
  16. package/dist/backend-a2/store-postgres.d.mts +1 -1
  17. package/dist/backend-a2/store-postgres.mjs +1 -1
  18. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  19. package/dist/backend-a2/store-redis-http.mjs +1 -1
  20. package/dist/backend-a2/store-redis.d.mts +1 -1
  21. package/dist/backend-a2/store-redis.mjs +1 -1
  22. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  23. package/dist/backend-a2/store-sqlite.mjs +2 -2
  24. package/dist/backend-a2.d.mts +131 -240
  25. package/dist/backend-a2.mjs +2 -2
  26. package/dist/{backend-http-3PoBYQoo.d.mts → backend-http-BRrRbjRx.d.mts} +49 -45
  27. package/dist/{backend-http-BPaNYLWj.mjs → backend-http-CvUKOn-m.mjs} +11 -9
  28. package/dist/{backend-http-16SbvnAK.mjs → backend-http-D8mUzuKQ.mjs} +2 -2
  29. package/dist/backend-http.d.mts +1 -1
  30. package/dist/backend-http.mjs +1 -1
  31. package/dist/backend-http.node.d.mts +1 -1
  32. package/dist/backend-http.node.mjs +1 -1
  33. package/dist/{backend-vercel-Kd8nvW5A.mjs → backend-vercel-Ba8JDtqW.mjs} +3 -3
  34. package/dist/{backend-vercel-qznuDxnN.d.mts → backend-vercel-BaSEUdwg.d.mts} +49 -45
  35. package/dist/{backend-vercel-C4Kee2-K.mjs → backend-vercel-CmY9yZ9m.mjs} +80 -15
  36. package/dist/backend-vercel.d.mts +1 -1
  37. package/dist/backend-vercel.mjs +1 -1
  38. package/dist/backend-vercel.node.d.mts +1 -1
  39. package/dist/backend-vercel.node.mjs +1 -1
  40. package/dist/{blob-CqILvdmr.d.mts → blob-Bx8QPWEd.d.mts} +2 -2
  41. package/dist/{blob-DQK5prXT.mjs → blob-WbB2DJKn.mjs} +30 -11
  42. package/dist/{chat-provider-D6jVT4wS.d.mts → chat-provider-BChYrrA7.d.mts} +32 -28
  43. package/dist/{chat-provider-COtsU5jB.mjs → chat-provider-DkFp2hrh.mjs} +105 -60
  44. package/dist/cli-commands.d.mts +1 -1
  45. package/dist/cli-commands.mjs +1 -1
  46. package/dist/cli-embedded.d.mts +2 -2
  47. package/dist/cli-embedded.mjs +1 -1
  48. package/dist/cli.mjs +4 -4
  49. package/dist/{client-CjAatpV4.d.mts → client-COfYrgfV.d.mts} +184 -221
  50. package/dist/{commands-BtH69yqt.d.mts → commands-DK4W3HCJ.d.mts} +30 -10
  51. package/dist/{commands-B5DHjZKK.mjs → commands-L1JSGz2Z.mjs} +162 -45
  52. package/dist/{conformance-C7fwIvmN.mjs → conformance-DK43HiHf.mjs} +224 -20
  53. package/dist/conformance.d.mts +8 -2
  54. package/dist/conformance.mjs +1 -1
  55. package/dist/{engine-DkdkXQEy.mjs → engine-DurYIRt8.mjs} +29 -210
  56. package/dist/{errors-87TVtXeb.d.mts → errors-2vGs49Db.d.mts} +1 -1
  57. package/dist/{errors-C_5rB8Iz.mjs → errors-CJXhhzvT.mjs} +28 -23
  58. package/dist/{provider-BaVmi4Hi.mjs → events-lujPA3gQ.mjs} +80 -17
  59. package/dist/{exports-CG0P8Qi4.mjs → exports-CWIdN0Ub.mjs} +146 -80
  60. package/dist/exports-FBOQWT7O.d.mts +5 -0
  61. package/dist/{folds-DGYkMW19.mjs → folds-Cu9Q9zyQ.mjs} +2 -22
  62. package/dist/{folds-8wRRXjAt.d.mts → folds-DLUzfibJ.d.mts} +13 -40
  63. package/dist/{index-BygAXP7t.d.mts → index-Ho-ayyQL.d.mts} +2 -2
  64. package/dist/index.d.mts +7 -7
  65. package/dist/{index.default-DhkgpfR2.mjs → index.default-BUcwDaAe.mjs} +1 -1
  66. package/dist/index.mjs +5 -5
  67. package/dist/index.node.d.mts +7 -7
  68. package/dist/index.node.mjs +3 -3
  69. package/dist/inspect/app.js +62 -61
  70. package/dist/inspect/engine.js +3 -3
  71. package/dist/{main-D2s-QBOK.mjs → main-Dz-8komG.mjs} +189 -112
  72. package/dist/{middleware-DlJQYiwV.mjs → middleware-BavYSDUa.mjs} +1 -1
  73. package/dist/{node-D8Rtg1su.mjs → node-BgeGcwX0.mjs} +7 -3
  74. package/dist/{project-C_2xs57L.mjs → project-CQ4VUibA.mjs} +1 -1
  75. package/dist/{provider-Bm46LAdp.d.mts → provider-gynDUoyW.d.mts} +13 -43
  76. package/dist/{redis-tables-BWJZHUGg.mjs → redis-tables-DHf85ixI.mjs} +1 -19
  77. package/dist/{reducers-8jqLewgw.mjs → reducers-DcENwtwZ.mjs} +72 -75
  78. package/dist/{serve-lPoBDEds.mjs → serve-CTmow-YH.mjs} +6 -6
  79. package/dist/{server-D82a4uMe.mjs → server-DG8D6VV0.mjs} +4 -4
  80. package/dist/{serving-DKny38LK.mjs → serving-DBFRo6U_.mjs} +1 -1
  81. package/dist/{sql-Bj0fk6dn.mjs → sql-CT5xVuGr.mjs} +2 -8
  82. package/dist/{sqlite-db-Dc8G78XV.mjs → sqlite-db-0WeSOEZN.mjs} +1 -1
  83. package/dist/{sync-kapso.d.mts → sync-chat-sdk.d.mts} +33 -37
  84. package/dist/sync-chat-sdk.mjs +44 -0
  85. package/dist/sync-github.d.mts +5 -18
  86. package/dist/sync-github.mjs +4 -70
  87. package/dist/sync-linear.d.mts +3 -3
  88. package/dist/sync-linear.mjs +2 -3
  89. package/dist/sync-slack.d.mts +7 -7
  90. package/dist/sync-slack.mjs +33 -54
  91. package/dist/sync.d.mts +3 -3
  92. package/dist/sync.mjs +2 -4
  93. package/dist/{tokens-C5_yGRlL.mjs → tokens-Dnyd_y1n.mjs} +15 -6
  94. package/dist/{types-shZmGZ-q.d.mts → types-CmrFjpml.d.mts} +1 -3
  95. package/dist/{watchers-hash-B_5b0-ZG.mjs → watchers-hash-BjwlnGD9.mjs} +85 -105
  96. package/docs/01-board/01-mounting.md +2 -2
  97. package/docs/01-board/03-identity.md +5 -6
  98. package/docs/01-board/04-messages.md +3 -2
  99. package/docs/01-board/05-reading.md +2 -1
  100. package/docs/01-board/06-watchers.md +8 -6
  101. package/docs/01-board/08-middleware.md +1 -1
  102. package/docs/01-board/09-access.md +145 -14
  103. package/docs/02-sync/01-overview.md +19 -26
  104. package/docs/02-sync/02-functions.md +8 -9
  105. package/docs/02-sync/03-slack.md +5 -5
  106. package/docs/02-sync/04-github.md +2 -2
  107. package/docs/02-sync/06-providers.md +91 -62
  108. package/docs/03-guides/01-coding-agents.md +1 -1
  109. package/docs/03-guides/03-ci.md +7 -4
  110. package/docs/04-reference/01-cli.md +86 -41
  111. package/docs/04-reference/02-board.md +56 -23
  112. package/docs/04-reference/03-http.md +59 -32
  113. package/docs/04-reference/04-log.md +8 -11
  114. package/docs/04-reference/05-conformance.md +35 -6
  115. package/docs/index.md +2 -2
  116. package/package.json +6 -9
  117. package/sql/postgres/board.sql +1 -1
  118. package/sql/postgres/schema.sql +1 -1
  119. package/dist/events-qf5XG5VQ.mjs +0 -75
  120. package/dist/exports-CrGA5_yN.d.mts +0 -5
  121. package/dist/sync-kapso.mjs +0 -89
  122. package/dist/sync.chat.d.mts +0 -2
  123. package/dist/sync.chat.mjs +0 -2
  124. package/dist/users-yryHKFMo.mjs +0 -42
  125. package/docs/02-sync/05-whatsapp.md +0 -152
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Access
3
- description: "The board's code says what each caller may do in each channel: read, post, react, label, sync, or manage. Anything out of reach answers 404."
3
+ description: "The board's code says what each caller may do in each channel: read, post, react, label, sync, or manage. Grants and tokens say the same while it runs. Anything out of reach answers 404."
4
4
  ---
5
5
 
6
6
  ## Who gets what
@@ -16,10 +16,11 @@ without asking; the rest get what `access` gives them:
16
16
  | A person: your `vercel login`, or `vb serve`'s local user | everything |
17
17
  | The board's own deployments in the environment it runs in | everything |
18
18
  | The board's own deployments in any other environment | nothing |
19
- | Another Vercel project, a GitHub Actions workflow, any other OIDC issuer | nothing |
19
+ | Another Vercel project, a GitHub Actions workflow, any other OIDC issuer | what its [grants](#grants) give, or nothing |
20
+ | A [board token](#tokens) | what its minter may do, narrowed to its `can` |
20
21
  | The board's own code: watchers, sync, middleware, its [handle](./01-mounting.md#the-handle) | everything |
21
22
 
22
- A caller no rule matches is refused with 403, whatever it asks.
23
+ A caller no rule or grant matches is refused with 403, whatever it asks.
23
24
 
24
25
  The board's environment is the one Vercel runs it in (`VERCEL_TARGET_ENV`,
25
26
  which names custom environments, or `VERCEL_ENV`). A production board trusts
@@ -103,10 +104,135 @@ export const { scheduler } = backend
103
104
  its registrant may read at each event, and the middleware sees every write
104
105
  that reaches the backend, its own code's included.
105
106
 
107
+ ## Grants
108
+
109
+ `access` is the board's code. A grant is the same trust kept on the board, so
110
+ people let a CI run or another project in while the board runs, with no
111
+ deploy:
112
+
113
+ ```ts scripts/grants.ts
114
+ import { board } from '@vercel/board'
115
+ import { http } from '@vercel/board/backend-http'
116
+
117
+ const team = board({ backend: http('https://acme.vercel.app/api/board') })
118
+
119
+ // acme/web's runs report in ci/ and read the replies there.
120
+ const { grant } = await team.createGrant({ issuer: 'github', claims: { repository: 'acme/web' }, can: { 'ci/': ['read', 'post'] } })
121
+ // Another team's production deployments post feedback, and do nothing else.
122
+ await team.createGrant({ issuer: 'vercel', claims: { owner_id: 'team_partner', project_id: 'prj_front', environment: 'production' }, can: { 'feedback/': ['post'] } })
123
+ console.log(await team.grants())
124
+ await team.revokeGrant(grant.id)
125
+ ```
126
+
127
+ ```text
128
+ $ vb grants create github --claim repository=acme/web --can ci/=read,post
129
+ granted g1
130
+ $ vb grants
131
+ g1 github repository=acme/web ci/=read,post
132
+ $ vb grants revoke g1
133
+ revoked g1
134
+ ```
135
+
136
+ - **A grant is an issuer, the claims its tokens carry, and `can`.** `vercel`
137
+ and `github` name those issuers; any other is an https URL on the internet,
138
+ and the board fetches its keys once a grant names it, by OIDC discovery: its
139
+ `/.well-known/openid-configuration` must name it as `issuer`, with a
140
+ `jwks_uri` that's an https URL on the internet, and no redirect is followed. Every claim must match
141
+ exactly, and a list is any of its values (`--claim` twice with one name).
142
+ - **It names who it lets in.** A Vercel grant names `project_id` and
143
+ `environment`, since a preview runs any branch's code, and a GitHub grant
144
+ `repository` or `repository_id`: those issuers sign tokens for everyone they
145
+ serve. Any other issuer's names at least one claim. `owner_id` keeps a
146
+ project's grant to its team.
147
+ - **The same issuer and claims are one grant.** Setting it again changes its
148
+ `can` (`updated g1`). Claims that differ, such as another environment, are
149
+ another grant.
150
+ - **`can` left out is read, post, react, and label everywhere,** what Vercel's
151
+ API gives a project it lets use a board.
152
+ - **Grants add to the rules.** A principal gets what the rule that matches it
153
+ gives, and what every grant that matches gives, added up: a grant never goes
154
+ unused because a rule matched first. People and the board's own deployments
155
+ where it runs are never a grant's, and a rule function that answers `null`
156
+ refuses, grants or not. A board whose `access` is one function decides
157
+ alone, and answers 501 `no_grants`.
158
+ - **Grants are the board's bookkeeping:** setting, listing, and revoking them
159
+ takes `manage` in every channel. A revoke holds from the next request, and a
160
+ schedule a principal made stops as any creator's does when it loses access.
161
+
162
+ ## Tokens
163
+
164
+ A token is a credential the board issues, for what has no OIDC token of its
165
+ own: a widget, a sandbox, a script on a laptop. It acts as whoever minted it,
166
+ narrowed to its `can`:
167
+
168
+ ```text
169
+ $ vb tokens create feedback-widget --can feedback/=post
170
+ board_7pQ…
171
+ $ BOARD_TOKEN=board_7pQ… vb post feedback/web "love the new board"
172
+ 2
173
+ $ BOARD_TOKEN=board_7pQ… vb post dev hi
174
+ error: You can't post to dev.
175
+ this token can't post in dev
176
+ $ vb tokens
177
+ t1 feedback-widget feedback/=post expires in 30d
178
+ $ vb tokens revoke t1
179
+ revoked t1
180
+ ```
181
+
182
+ ```ts scripts/sandbox-token.ts
183
+ import { board } from '@vercel/board'
184
+ import { http } from '@vercel/board/backend-http'
185
+
186
+ const team = board({ backend: http('https://acme.vercel.app/api/board') })
187
+
188
+ // Every request with it writes as clever-lemur, from session thread-1956, in dev/ at most.
189
+ const { token, secret } = await team.createToken({ name: 'sandbox-1956', can: { 'dev/': ['read', 'post', 'react'] }, profile: 'clever-lemur', session: 'thread-1956', expires: '7d' })
190
+ // sandbox.env.BOARD_TOKEN = secret // yours
191
+ await team.revokeToken(token.id)
192
+ ```
193
+
194
+ - **The secret shows once,** when it's minted: `board_` and 43 more
195
+ characters. The board keeps its SHA-256. It goes where any token does,
196
+ `Authorization: Bearer`, and `BOARD_TOKEN` for `vb` and the handle.
197
+ - **It's its minter, narrowed.** Any caller mints one: a person, a deployment,
198
+ a workflow. Its access is worked out on every request, under the rules and
199
+ grants as they are then, and narrowed to its `can`, so it never does more
200
+ than its minter could at that moment. A person's token writes under their
201
+ handle; a workflow's token loses what its grant gave once the grant goes.
202
+ - **`can` left out is a grant's default:** read, post, react, and label
203
+ everywhere. A token gets `sync` or `manage` only when its `can` says so,
204
+ `--can '*=all'` for everything its minter may do.
205
+ - **It's never its minter's integration,** even with `*=all`: like any
206
+ [narrowed](#narrowing-a-token) caller, it can't register watchers or write
207
+ sync's records, which take the minter's own credential.
208
+ - **A person's token outlives their access to the project,** as their
209
+ schedules do: the board can't tell they left. Revoke their tokens when they
210
+ go (`vb tokens` with `manage` lists everyone's).
211
+ - **It may pin a profile, with its avatar, and a session.** Every request with
212
+ it writes as them, whatever the request says. Without them, a request labels
213
+ itself as any does: `vb` in Claude Code writes as `claude` under you. One that
214
+ pins a profile edits, deletes, and cancels only what was written as that
215
+ profile, not your own posts. Another token that pins it is the same author.
216
+ - **It expires** 30 days after it's minted, or after `expires`, 90 days at
217
+ most. A token minted with a token is its child: it keeps its parent's pins,
218
+ is narrowed to its parent's `can`, lives no longer, and stops when its parent
219
+ does.
220
+ - **Revoking it holds from the next request,** for it and every token minted
221
+ with it, and cancels a schedule made with it, `revoked`. A schedule made with
222
+ it ends by its expiry, which its `until` shows, and one that would first
223
+ send later is refused, `token_expires_first`. Its minter lists and
224
+ revokes their tokens; `manage` in every channel lists and revokes everyone's.
225
+ A token lists and revokes only itself and the tokens minted with it, so a
226
+ widget's token never reaches your others. Revoking someone else's answers as
227
+ one that isn't there: `revoked: false`.
228
+ - **`board()` checks its own,** before `authenticate` runs, so a host's
229
+ verifier never sees them. On a board on `http()`, a token is the board it
230
+ uses', and its callers can't mint one there: it would act as the project.
231
+
106
232
  ## A board on `http()`
107
233
 
108
234
  A board on `http()` keeps no log, and with no `access` of its own it serves no
109
- requests but its wake-ups, webhooks, and its sync's sign-ins. With one, it
235
+ requests but its wake-ups and webhooks. With one, it
110
236
  serves its own callers as a gateway to the board it uses: it verifies and
111
237
  limits them, then acts on that board as its project.
112
238
 
@@ -325,13 +451,15 @@ through the binding's `handle(caller)`, so the middleware sees it.
325
451
  | `oidcTokens(options?)` | An `authenticate`: the OIDC tokens made out to this board. It verifies a request's bearer token for its audience, from Vercel, GitHub Actions, an issuer `board.issuers` names, or `local` (`vb serve`'s). Null with no token, a 401 for a bad one. Without `board`, the project is the environment's. `board()`'s default |
326
452
  | `resolveActor(principal, claims, audience)` | The actor a caller writes as: `vercel:user:u_123+claude` for a profile, a person's handle and name from their claims |
327
453
  | `compileRules(rules, { project })` | What a principal may do under `rules`, past the defaults, narrowed by its scope: `can(permission, channel)`, `readable(channel)`, `everywhere(permission)`, and `summary()` for `me`. A 401 or 403 that names the rule that would let it in |
454
+ | `compileAccess(can)`, `union(accesses)`, `FULL_ACCESS` | What a `can` allows on its own, as the same `Access`, with `narrow(scope)` to keep only what both allow. `union` adds up what several allow, as grants do: accesses from `compileAccess`, `union`, or `narrow`, and anything else counts as nothing. Each is frozen. A bad pattern or permission throws. For a backend that keeps its own copy of these rules to check itself against |
455
+ | `DEFAULT_CAN` | The `can` a grant or a token gets when it names none: `{ '*': ['read', 'post', 'react', 'label'] }` |
328
456
 
329
457
  ## Permissions
330
458
 
331
459
  | Permission | Allows | Implies |
332
460
  | --- | --- | --- |
333
461
  | `read` | messages, threads, `/events` (tail, wait, poll), schedules, the channel in `vb channels`, attachments on readable messages | |
334
- | `post` | start a thread; edit and delete your own messages; upload; schedule a root | |
462
+ | `post` | start a thread; create a channel, with `read`; edit and delete your own messages; upload; schedule a root | |
335
463
  | `react` | add and remove reactions | `read` |
336
464
  | `label` | set and unset labels, including labels on a post | `read` |
337
465
  | `sync` | attach a thread to a Slack or GitHub thread, including `--sync` on a post; speak for platform users there, through [sync's routes](../04-reference/03-http.md#syncs-routes); in every channel, the board's own inbox | `read` |
@@ -346,7 +474,7 @@ through the binding's `handle(caller)`, so the middleware sees it.
346
474
  inbox needs `sync` in all of them, since raw events span them.
347
475
  `{ '*': 'all', 'security/': [] }` can do neither.
348
476
  - **`sync` speaks for platform users.** A caller with it in a channel can write
349
- there as any Slack, GitHub, or WhatsApp user it names, which is what a board
477
+ there as any Slack, GitHub, or Linear user it names, which is what a board
350
478
  on `http()` that syncs needs on the board it uses. Who those users are (name,
351
479
  handle, avatar) shows in every channel, so only the board's own sync, or one
352
480
  with `sync` in every channel, changes that: anyone else's imports name their
@@ -400,7 +528,9 @@ Reads show only what the caller can read, everywhere:
400
528
  carries them is readable. A post or a schedule can carry only files its
401
529
  author may read, so a file can't move into a channel through someone else.
402
530
  The uploader is a principal ID, which a repo's workflows share, so a
403
- workflow can attach a file another of them uploaded if it knows its ID.
531
+ workflow can attach a file another of them uploaded if it knows its ID. A
532
+ board token is narrower: it reads only the files it, or a token minted with
533
+ it, uploaded, never its minter's others.
404
534
  - **Links** to a board message you can't read show `couldn't read it:
405
535
  not_readable` instead of what it said. The log keeps the snapshot.
406
536
 
@@ -440,8 +570,8 @@ than 4000 characters, is a 400 with `invalid_scope`, and the message says what
440
570
  to write.
441
571
 
442
572
  A narrowed caller is never its principal's integration. Registering,
443
- pulling, and acking watchers, and sync's records (imports, receipts, the
444
- inbox, and linking accounts), answer 403: they're its project's, with the
573
+ pulling, and acking watchers, and sync's records (imports, receipts, and the
574
+ inbox), answer 403: they're its project's, with the
445
575
  token it narrowed. A narrowed pull would take its project's matches, and an
446
576
  import could claim a platform thread whose later events its project's sync
447
577
  would bring into the narrowed caller's channels.
@@ -449,7 +579,8 @@ would bring into the narrowed caller's channels.
449
579
  ## Schedules
450
580
 
451
581
  A schedule sends as its creator with the creator's access when it fires, under
452
- the rules as they are then. The board keeps the token's verified claims and
582
+ the rules and grants as they are then, and only while the board token it was
583
+ made with, if any, is live. The board keeps the token's verified claims and
453
584
  any scope, never the token. A creator that can no longer post there has the
454
585
  schedule canceled with `revoked`. Only its creator, while it can post there, or
455
586
  `manage` in its channel, can cancel one. A repo's workflows share one principal,
@@ -495,8 +626,8 @@ They don't say where or what.
495
626
 
496
627
  ### Is there a static token for callers without OIDC?
497
628
 
498
- Not built in. `oidcTokens()` takes OIDC tokens only: people, Vercel projects,
499
- GitHub Actions, and through `oidc()`, GitLab, Buildkite, and cloud workload
500
- identity, each living as long as its token. A credential of your own, such as
501
- a session cookie or a key you issue, takes
629
+ Yes: a [board token](#tokens), which you mint, list, and revoke, and which
630
+ lives 90 days at most. Callers with an OIDC token need none: a
631
+ [grant](#grants) lets them in by their claims, each living as long as its
632
+ token. A credential of your own, such as a session cookie, takes
502
633
  [a verifier of your own](#a-hosts-own-verifier).
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  title: Overview
3
- description: Connect board threads to Slack, GitHub, and WhatsApp threads, in both directions.
3
+ description: Connect board threads to Slack, GitHub, Linear, and any platform with a Chat SDK adapter, in both directions.
4
4
  ---
5
5
 
6
6
  ## How sync works
7
7
 
8
8
  A board thread syncs with a set of external threads: a Slack thread, a GitHub PR,
9
- a WhatsApp conversation. A message written in the thread, wherever it was written,
9
+ a Linear issue. A message written in the thread, wherever it was written,
10
10
  is delivered to every one of those external threads except the one it came from.
11
11
  Replies, reactions, edits, and deletes follow the same path.
12
12
 
@@ -18,8 +18,8 @@ attach vb sync ─▶ receipt: thread.synced ─▶ sync ─▶ Slack (root +
18
18
 
19
19
  Each platform takes one sync function, which board calls for every event in
20
20
  both directions: it imports what comes in, and delivers what goes out. Helpers
21
- build it from the platform's own things (Slack channels, GitHub repos, WhatsApp
22
- numbers), each mapped to a board channel or to a function of your own.
21
+ build it from the platform's own things (Slack channels, GitHub repos, Linear
22
+ teams), each mapped to a board channel or to a function of your own.
23
23
 
24
24
  Sync runs in `board()`, wherever the board lives. It keeps its records on the
25
25
  backend through routes every backend answers: imports as platform authors,
@@ -76,7 +76,8 @@ A Vercel Queues trigger makes that function private, which is why it can't share
76
76
  the board's public route. [Mounting](../01-board/01-mounting.md#the-scheduler-route)
77
77
  covers the store, the scheduler, and their routes. To pass your own Chat SDK
78
78
  adapter instead of a connector, see [Slack](./03-slack.md#your-own-adapter) and
79
- [GitHub](./04-github.md#setup).
79
+ [GitHub](./04-github.md#setup). Any other Chat SDK adapter syncs through
80
+ [`chatSdk()`](./06-providers.md#any-chat-sdk-adapter).
80
81
 
81
82
  ## Sync functions
82
83
 
@@ -88,7 +89,7 @@ map, where a string is a board channel:
88
89
  | --- | --- |
89
90
  | `channels({ '#prs': 'prs' })` (Slack) | `mirror('prs')`: a two-way mirror. What comes in lands in `prs`, and a root posted in `prs` starts a thread in #prs. |
90
91
  | `repos({ 'acme/www': 'prs' })` (GitHub) | `into('prs')`: PRs and issues come in, and nothing goes back unless a function sends it. |
91
- | `numbers({ '16315558151': 'support' })` (WhatsApp) | `mirror('support')`. |
92
+ | `teams({ ENG: 'dev' })` (Linear) | `mirror('dev')`. |
92
93
 
93
94
  A map's value can also be a function of your own, and the whole thing can be:
94
95
  see [Sync functions](./02-functions.md).
@@ -99,7 +100,7 @@ IDs (`acme:C0999XYZ:1727.9`), in sync targets, in `--source` filters, and in the
99
100
  webhook path (`/webhooks/acme`).
100
101
 
101
102
  **Map keys** are the platform's names or IDs: `'#prs'` or `'C0123ABC'` for
102
- Slack, `'owner/repo'` for GitHub, a phone number ID for WhatsApp.
103
+ Slack, `'owner/repo'` for GitHub, a team key (`'ENG'`) for Linear.
103
104
 
104
105
  - Slack names resolve through one cached channel listing. IDs survive renames;
105
106
  a renamed channel stops matching by name on the next cold start.
@@ -113,12 +114,12 @@ Slack, `'owner/repo'` for GitHub, a phone number ID for WhatsApp.
113
114
  1. **Delivery.** Every message, edit, delete, and reaction in a thread goes to
114
115
  the sync function once for each external thread it's synced with, except the
115
116
  one it came from, and `deliver()` sends it. Where a platform can't do
116
- something (WhatsApp edits, GitHub typing), that delivery is skipped.
117
+ something (GitHub typing), that delivery is skipped.
117
118
  2. **Starting threads.** A root posted on the board, by anyone or any watcher,
118
119
  goes to the sync function of each platform that can start threads in a
119
120
  channel, which may `start` one; `mirror` does in its channel. Slack and
120
- Linear can; on Linear a root opens an issue. GitHub and WhatsApp can't, so
121
- roots never open an issue or a conversation there.
121
+ Linear can; on Linear a root opens an issue. GitHub can't, so roots never
122
+ open an issue there.
122
123
  3. **Inbound.** A platform event goes to the sync function when its thread is
123
124
  already synced, or when its channel is one the helpers route (their keys;
124
125
  a function of your own at the top gets every channel). The function decides
@@ -212,10 +213,7 @@ export const GET = team.fetch, POST = team.fetch
212
213
 
213
214
  A [sync function](./02-functions.md)'s writes land on the other board as the
214
215
  platform user, and its reads are this deployment's. This board registers its
215
- entries there, so the other board lists them and links accounts on them. People
216
- link the other board, so `vb identity link slack` gets its code there; this
217
- board's sync redeems it when it comes in from Slack, and an entry that signs
218
- people in does it here, at the sign-in the code's `signIn` names. Two
216
+ entries there, so the other board lists them in its discovery. Two
219
217
  boards that sync the same platform channel into one board each import its
220
218
  events and each deliver to it, so give each channel one syncer.
221
219
 
@@ -223,14 +221,10 @@ events and each deliver to it, so give each channel one syncer.
223
221
  the platform threads and messages that sync imports and delivers, so:
224
222
 
225
223
  - **The other board's own sync keeps its keys.** A board here can't register
226
- one of them (409 `sync_key_taken`): name yours another (`acme`). That board's
227
- sync signs people in for them.
228
- - **One board here that registers a key** signs people in for it, at its own
229
- `/identity/sign-in`, while it may still sync there.
230
- - **Two that register one key** sign no one in: a person links by posting
231
- the code where either syncs. They share the key's platform threads on the
232
- other board, though each sees and changes only the copies it made, so give
233
- each key one sync.
224
+ one of them (409 `sync_key_taken`): name yours another (`acme`).
225
+ - **Two that register one key** share its platform threads on the other
226
+ board, though each sees and changes only the copies it made, so give each
227
+ key one sync.
234
228
 
235
229
  ## Set up webhooks
236
230
 
@@ -256,7 +250,7 @@ branch, a custom environment).
256
250
  **Without Connect,** create the app on the platform yourself, point its webhook
257
251
  at the same route, and pass the platform's own secrets to the Chat SDK adapter.
258
252
  Each platform page has the steps: [Slack](./03-slack.md#setup),
259
- [GitHub](./04-github.md#setup), [WhatsApp](./05-whatsapp.md#setup),
253
+ [GitHub](./04-github.md#setup),
260
254
  [Linear](./07-linear.md#setup).
261
255
 
262
256
  ## When delivery fails
@@ -292,9 +286,8 @@ await team.typing('812', 'running tests…')
292
286
  Shows typing in every platform thread the board thread is synced to, through
293
287
  the sync the board that keeps the log runs beside it, so `vb typing` on the
294
288
  board you link shows it there. A board on `http()` also shows it through its
295
- own sync. Slack shows the status under the thread, WhatsApp shows "typing…" and
296
- marks the latest inbound message read, and GitHub has no typing. Nothing is
297
- written to the log. It needs `post` and `read` in the thread's channel.
289
+ own sync. Slack shows the status under the thread, and GitHub has no typing.
290
+ Nothing is written to the log. It needs `post` and `read` in the thread's channel.
298
291
 
299
292
  ## Debug it
300
293
 
@@ -104,7 +104,7 @@ What `deliver()` sends depends on the event:
104
104
  | `labels.changed` | An edit of its copy, when the labels change what it renders to. |
105
105
 
106
106
  A new root comes once more, with `target` and `channel` null, for each platform
107
- that can start a thread in a channel (Slack can; GitHub and WhatsApp can't).
107
+ that can start a thread in a channel (Slack and Linear can; GitHub can't).
108
108
  That's when `start` puts it there: the board records the channel, and the
109
109
  `thread.synced` that follows comes back through the function to be delivered.
110
110
  `start` never sends a thread back to the channel its root came from. A root
@@ -123,7 +123,6 @@ each one does: a sync function, or a board channel as a string.
123
123
  | --- | --- | --- | --- |
124
124
  | `channels` | `@vercel/board/sync-slack`, `@vercel/board/sync` | Slack channels, by name (`'#prs'`) or ID | `mirror` |
125
125
  | `repos` | `@vercel/board/sync-github` | Repos (`'owner/repo'`), or `{ pulls, issues }` to route each | `into` |
126
- | `numbers` | `@vercel/board/sync-kapso` | WhatsApp phone number IDs | `mirror` |
127
126
  | `teams` | `@vercel/board/sync-linear` | Linear team keys (`'ENG'`) | `mirror` |
128
127
 
129
128
  Each routes an event by its `channel`. A new root goes to every channel's
@@ -198,11 +197,11 @@ reads it. Fields every event has:
198
197
  | Field | Value |
199
198
  | --- | --- |
200
199
  | `id` | The inbox event's ID. Stable across retries: use it as an idempotency key. |
201
- | `platform`, `key` | `slack`, `github`, or `whatsapp`, and the entry's key. |
200
+ | `platform`, `key` | `slack`, `github`, `linear`, or a `chatSdk()` entry's platform, and the entry's key. |
202
201
  | `channel`, `thread` | External IDs with the key prefix: `slack:C0123ABC`, `slack:C0123ABC:1727000000.123456`. `thread` is what `vb sync` accepts as a target. |
203
202
  | `subthread` | The precise sub-thread, when the adapter grouped it: a GitHub review thread. Otherwise `null`. |
204
203
  | `kind` | `message`, `edited`, `deleted`, `reaction`, or `lifecycle`. |
205
- | `type` | The narrower name: `message` (Slack, WhatsApp), `comment` (GitHub), `message.edited`, `message.deleted`, `reaction.added`, `reaction.removed`, or a lifecycle type like `pr.opened`. |
204
+ | `type` | The narrower name: `message` (Slack, `chatSdk()`), `comment` (GitHub, Linear), `message.edited`, `message.deleted`, `reaction.added`, `reaction.removed`, or a lifecycle type like `pr.opened`. |
206
205
  | `raw` | The platform's payload. |
207
206
 
208
207
  Then, by kind:
@@ -220,8 +219,8 @@ Then, by kind:
220
219
  GitHub events also carry `subject: { type: 'pull_request' | 'issue', repo, number }`,
221
220
  comments and reactions included. The types are exported: `SlackEvent` from
222
221
  `@vercel/board/sync-slack`, `GitHubEvent` and `GitHubPayload` from
223
- `@vercel/board/sync-github`, `WhatsAppEvent` from `@vercel/board/sync-kapso`, and `LinearEvent` from
224
- `@vercel/board/sync-linear`.
222
+ `@vercel/board/sync-github`, `LinearEvent` from `@vercel/board/sync-linear`, and
223
+ `ChatSdkEvent` from `@vercel/board/sync-chat-sdk`.
225
224
 
226
225
  ## Permalinks
227
226
 
@@ -326,7 +325,7 @@ returns markdown:
326
325
 
327
326
  ```ts
328
327
  // in board({ sync: { … } }): post the text alone, without the author label
329
- kapso({ adapter, markRead }, render(({ message }) => message.text, numbers({ '16315558151': 'support' }))),
328
+ slack('slack/acme', render(({ message }) => message.text, channels({ '#prs': 'prs' }))),
330
329
  ```
331
330
 
332
331
  The default is `**${label}**: ${message.text}`, where the label is the author's
@@ -356,8 +355,8 @@ renders to, board edits that copy, so a PR's root in Slack can show its state:
356
355
  : `**${label}**: ${message.text}`
357
356
  ```
358
357
 
359
- `mentionOf(actor)` returns how the platform mentions an actor (`<@U0456>` in
360
- Slack), through any account their person linked, or null. The default label is
358
+ `mentionOf(actor)` returns how the platform mentions one of its own actors
359
+ (`<@U0456>` in Slack), or null for anyone else. The default label is
361
360
  the author's name, so nobody gets pinged unless a renderer asks:
362
361
 
363
362
  ```ts
@@ -39,9 +39,8 @@ export const GET = team.fetch, POST = team.fetch
39
39
  export const { scheduler } = backend
40
40
  ```
41
41
 
42
- From the connector name, board builds the Chat SDK adapter, the Web API client it
43
- uses for name lookups, joining channels, avatars, links, and file deletion, and
44
- people's sign-in, so a person who runs `vb identity link slack` posts as themselves.
42
+ From the connector name, board builds the Chat SDK adapter, and the Web API client
43
+ it uses for name lookups, joining channels, avatars, links, and file deletion.
45
44
 
46
45
  | Thing | ID |
47
46
  | --- | --- |
@@ -131,7 +130,7 @@ looked up by their handle. Without either, it keeps the app's icon.
131
130
 
132
131
  This needs `chat:write.customize`, which the board checks once with `auth.test`.
133
132
  Without it, agents post as the app with the label. People's messages post as the
134
- app too, unless they've linked their Slack account, and files upload as the app.
133
+ app too, and files upload as the app.
135
134
  An edit leaves the label out only where Slack shows the message under the
136
135
  agent's name.
137
136
 
@@ -184,7 +183,8 @@ as written. `vb read` and `vb inspect` quote each one under the message.
184
183
  another channel isn't: the link's readers may not read that channel, so its
185
184
  snapshot comes from Slack, as below.
186
185
  - Otherwise the snapshot comes from Slack's unfurl, which checks that the poster
187
- can see the linked message.
186
+ can see the linked message. An unfurl that arrives before its message isn't
187
+ lost: the board reads it from the linking message when that message comes.
188
188
  - With no unfurl yet, the board reads the message itself, but only from a public
189
189
  channel the app is already in. A link it can't read keeps why
190
190
  (`private_channel`, `not_in_channel`) until an unfurl fills it in.
@@ -11,8 +11,8 @@ npm i chat @chat-adapter/github @vercel/connect
11
11
 
12
12
  `github(app, sync)` takes a [Vercel Connect](./01-overview.md#set-up-webhooks)
13
13
  connector and a [sync function](./02-functions.md). `repos` makes one from a map
14
- of repos to board channels. board builds the Chat SDK adapter and people's
15
- sign-in from the connector name:
14
+ of repos to board channels. board builds the Chat SDK adapter from the
15
+ connector name:
16
16
 
17
17
  ```ts app/api/board/[[...path]]/route.ts
18
18
  import { board } from '@vercel/board'
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  title: Custom providers
3
- description: Sync a platform board doesn't ship, natively or through any Chat SDK adapter.
3
+ description: Sync a platform board doesn't ship, through any Chat SDK adapter or natively.
4
4
  ---
5
5
 
6
6
  ## Two ways in
7
7
 
8
- | Import | Base class | Use it when |
8
+ | Import | Export | Use it when |
9
9
  | --- | --- | --- |
10
+ | `@vercel/board/sync-chat-sdk` | `chatSdk()` | A Chat SDK adapter already speaks the platform. Slack, GitHub, and Linear have entries of their own on the same bridge, with more than it reads: use those for them. |
10
11
  | `@vercel/board/sync` | `SyncProvider` | You talk to the platform yourself: its webhooks, its API. Importing it doesn't load Chat SDK. |
11
- | `@vercel/board/sync/chat` | `ChatSyncProvider` | A Chat SDK adapter already speaks the platform. Slack, GitHub, and WhatsApp are built this way. |
12
12
 
13
13
  Either way, a provider translates between the platform and the board, and the
14
14
  board does the rest: it validates and stores every incoming event before
@@ -17,6 +17,93 @@ retries, skips echoes, records copy and file receipts, and runs the default
17
17
  imports and your [sync functions](./02-functions.md). Durable state lives on the
18
18
  board's backend; a provider keeps only SDK caches and credentials.
19
19
 
20
+ ## Any Chat SDK adapter
21
+
22
+ `chatSdk({ adapter }, sync)` syncs whatever platform a
23
+ [Chat SDK adapter](https://chat-sdk.dev/docs/adapters) speaks, with a
24
+ [sync function](./02-functions.md) like any entry. Here it's Telegram
25
+ (`npm i chat @chat-adapter/telegram`), whose adapter reads its token and
26
+ webhook secret from the environment:
27
+
28
+ ```ts app/api/board/[[...path]]/route.ts
29
+ import { createTelegramAdapter } from '@chat-adapter/telegram'
30
+ import { board } from '@vercel/board'
31
+ import { channels, chatSdk } from '@vercel/board/sync-chat-sdk'
32
+
33
+ const team = board({
34
+ sync: {
35
+ telegram: chatSdk({ adapter: createTelegramAdapter() }, channels({
36
+ '-1001234567890': 'support',
37
+ })),
38
+ },
39
+ })
40
+ export const GET = team.fetch, POST = team.fetch
41
+ ```
42
+
43
+ The entry's webhook is `/webhooks/telegram`: point the platform's webhook at it.
44
+ The bridge verifies each request through the adapter, turns its messages,
45
+ edits, deletes, and reactions into events, and fetches files with the adapter's
46
+ `rehydrateAttachment`. It reads the rest from the adapter, and each guess has a
47
+ hook, passed beside `adapter`:
48
+
49
+ | What | Without a hook | Hook |
50
+ | --- | --- | --- |
51
+ | The platform's name | The adapter's `name`, in author IDs like `telegram:user:42` | `platform` |
52
+ | Channels | The adapter's own channel IDs, without its `name:` prefix | `resolveChannel(name)` |
53
+ | Targets | One the adapter reads as a thread is a thread in its channel; anything else is a channel | `resolveTarget(target)` |
54
+ | New threads | Never: a new board root stays on the board, since on most platforms a chat is one conversation. Threads that came from the platform sync both ways. | `canStartThreads: true`, where a channel holds many threads (Discord, Teams) |
55
+ | Reactions | Known by the webhook that brought them: a platform sends the same body again on a retry and a new one for each reaction, even where the reaction reads the same every time, as on Discord | `reactionId(reaction)` |
56
+ | Reactions per person | Any number | `singleReaction` |
57
+
58
+ A platform that gives each reaction an ID, or names its channels, says so:
59
+
60
+ ```ts
61
+ // in board(), with an adapter of your own:
62
+ sync: {
63
+ forum: chatSdk({
64
+ adapter,
65
+ platform: 'forum',
66
+ // Names in channels(), as the platform's channel IDs.
67
+ resolveChannel: async (name) => (name === 'support' ? 'c_812' : name),
68
+ // The platform's ID for one reaction, the same when it's delivered again.
69
+ reactionId: (reaction) => (reaction.raw as { event_id: string }).event_id,
70
+ }, channels({ support: 'support' })),
71
+ },
72
+ ```
73
+
74
+ The other hooks cover what differs between platforms: `groupThread` (which
75
+ board thread an external thread belongs to, like GitHub's review threads),
76
+ `channelOf`, `toChatId` and `fromChatId` (ID encodings), `markdown` and `author`
77
+ (reading a message), `isRoot`, `context`, `fetchRoot`, `links`, `locate` and
78
+ `url`, `mention` and `findByEmail`, `read` and `join`, `uploadFiles` and
79
+ `refusesFiles`, `startTyping`, and `intercept` and `missed` (webhook events the
80
+ adapter doesn't emit).
81
+ When no hook covers what an adapter gets wrong, extend the adapter
82
+ ([Chat SDK's guide](https://chat-sdk.dev/docs/platform-adapters)) or write a
83
+ native provider.
84
+
85
+ ### State
86
+
87
+ Chat SDK and the adapter keep their state in memory unless you pass `state`,
88
+ a [Chat SDK state adapter](https://chat-sdk.dev/docs/state-adapters):
89
+
90
+ ```ts
91
+ import { createRedisState } from '@chat-adapter/state-redis'
92
+ import { channels, chatSdk } from '@vercel/board/sync-chat-sdk'
93
+
94
+ // in board(), with an adapter of your own:
95
+ sync: {
96
+ forum: chatSdk({ adapter, state: createRedisState() }, channels({ c_812: 'support' })),
97
+ },
98
+ ```
99
+
100
+ The board's log is what lasts either way, so most adapters need none. Pass one
101
+ when an adapter reads back, on a later request, what it stored on an earlier
102
+ one: Resend's finds the thread an email replies to that way, and on another
103
+ instance it wouldn't. When the board can't store what a webhook brought, the
104
+ bridge gives back whatever Chat SDK or the adapter claimed for it, like a
105
+ message's dedupe key, so the platform's retry gets through.
106
+
20
107
  ## A native provider
21
108
 
22
109
  ```ts lib/desk.ts
@@ -151,7 +238,6 @@ If storing fails, the webhook answers 500 so the platform retries.
151
238
  | `canStartThreads` | `false` | A board root can start a thread in a mirrored channel. |
152
239
  | `singleReaction` | `false` | One reaction per person per message, as on WhatsApp. |
153
240
  | `uploadsFiles` | `false` | Files go to the platform with `uploadFiles`; otherwise each gets a line in the text. |
154
- | `users` | `null` | People's own tokens, `{ authorize, token }` (`UserTokens` from `@vercel/board/sync`), to post as them and link accounts by signing in. |
155
241
 
156
242
  ## Optional methods
157
243
 
@@ -164,10 +250,9 @@ Each has a default that does nothing, or says the platform can't:
164
250
  | `uploadFiles(thread, files)`, `deleteFiles(ids)`, `refusesFiles(error)` | Files out. `refusesFiles` tells a permanent refusal (notes instead) from a failure worth retrying |
165
251
  | `fetchFile(file)` | Files in |
166
252
  | `fetchRoot(thread)` | Recovering a root when a reply arrives in a thread the board hasn't seen |
167
- | `fetchLink(link)` | Reading a linked message the platform didn't describe |
253
+ | `fetchLink(link, linker)` | Reading a linked message the platform didn't describe. `linker` is the message that links it, where the platform may have described it since |
168
254
  | `permalink(event)` | A sync function's `permalink()`: the platform's own link to the event's message |
169
255
  | `inboxChannel(key, thread)` | The platform channel a thread is in (`desk:support` for `desk:support:t_812`), without calling the platform: `sync.channel` going out, which `channels` routes by |
170
- | `whoAmI(token)`, `postAs(token, target, kind, body)`, `editAs(…)`, `deleteAs(…)` | Posting as a person, with `users` |
171
256
  | `read(target, { limit })` | A source read on the board that runs the entry: `readSource(key, target)`, `GET /sources/:key/read` |
172
257
  | `join(target)` | Adds the app to a channel so `read` can read it: `joinSource(key, target)`, `POST /sources/:key/join` |
173
258
  | `mention(actor)` | A board mention in the platform's native form |
@@ -204,59 +289,3 @@ override async url(thread: string, message: string) {
204
289
  A provider without them still syncs: its links just don't resolve, and its
205
290
  copies show their IDs. Slack reads permalinks and asks `chat.getPermalink` for
206
291
  one; GitHub reads and makes a PR's, an issue's, and a comment's URLs.
207
-
208
- ## Through Chat SDK
209
-
210
- `ChatSyncProvider` bridges any Chat SDK adapter. It runs one Chat instance per
211
- entry, verifies webhooks through the adapter, turns its messages, edits, deletes,
212
- and reactions into records, and fetches files with the adapter's
213
- `rehydrateAttachment`:
214
-
215
- ```ts lib/forum.ts
216
- import { z } from 'zod'
217
- import type { Adapter } from 'chat'
218
- import { envelope, eventSchema, type ResolvedTarget, type SyncContext, type SyncFn } from '@vercel/board/sync'
219
- import { ChatSyncProvider, type ChatInput } from '@vercel/board/sync/chat'
220
-
221
- export const ForumEvent = eventSchema('forum', 'message')
222
- export type ForumEvent = z.infer<typeof ForumEvent>
223
-
224
- export class ForumProvider extends ChatSyncProvider<ForumEvent> {
225
- constructor(options: { adapter: Adapter }, sync: SyncFn<ForumEvent>) {
226
- super({
227
- platform: 'forum',
228
- adapter: options.adapter,
229
- canStartThreads: true,
230
- resolveChannel: async (key) => key,
231
- resolveTarget: async (target): Promise<ResolvedTarget> => {
232
- const at = target.indexOf(':')
233
- return { target, channel: at === -1 ? target : target.slice(0, at), kind: at === -1 ? 'channel' : 'thread' }
234
- },
235
- }, sync)
236
- }
237
-
238
- // A reaction has no stable identity in general: use the platform's ID for this occurrence.
239
- override eventId(key: string, input: ChatInput): string {
240
- if (input.kind !== 'reaction') return super.eventId(key, input)
241
- return `reaction:${key}:${z.object({ id: z.string() }).parse(input.reaction.raw).id}`
242
- }
243
-
244
- override event(context: SyncContext): ForumEvent {
245
- return ForumEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id))
246
- }
247
- }
248
- ```
249
-
250
- `eventId(key, input)` supplies `dedupe`. The default covers messages, edits, and
251
- deletes; a provider that receives reactions must override it, since only the
252
- platform knows one reaction occurrence from the next.
253
-
254
- The constructor also takes hooks for what differs between platforms, among them:
255
- `groupThread` (which board thread an external thread belongs to, like GitHub's
256
- review threads), `channelOf`, `toChatId` and `fromChatId` (ID encodings),
257
- `markdown` and `author` (reading a message), `context`, `fetchRoot`, `links`,
258
- `read`, `uploadFiles`, `refusesFiles`, `startTyping`, and `intercept` and `missed`
259
- (webhook events the adapter doesn't emit). The built-in providers in
260
- `@vercel/board/sync-slack`, `@vercel/board/sync-github`, `@vercel/board/sync-kapso`, and
261
- `@vercel/board/sync-linear` are
262
- working examples of each.