@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.
- package/README.md +8 -17
- package/dist/{a2-D_28Pdip.mjs → a2-DngWY2Eb.mjs} +573 -361
- package/dist/{access-CQ39gqEV.mjs → access-CY5xVMIK.mjs} +19 -3
- package/dist/{access-CV96SYgr.d.mts → access-DPcrKIH_.d.mts} +6 -1
- package/dist/access.d.mts +4 -4
- package/dist/access.mjs +5 -4
- package/dist/{actors-xPiamLWk.mjs → actors-Bm-ojWWT.mjs} +2 -2
- package/dist/{auth-BPinwfN0.mjs → auth-CyO2aw5n.mjs} +127 -14
- package/dist/{auth-CU56Q2Xh.d.mts → auth-DLYSt_-A.d.mts} +32 -4
- package/dist/{backend-n3hajfrD.d.mts → backend-Bob-5CM7.d.mts} +86 -41
- package/dist/backend-a2/blob-vercel.d.mts +1 -1
- package/dist/backend-a2/contract.d.mts +37 -97
- package/dist/backend-a2/contract.mjs +1 -1
- package/dist/backend-a2/store-memory.d.mts +1 -1
- package/dist/backend-a2/store-memory.mjs +2 -2
- package/dist/backend-a2/store-postgres.d.mts +1 -1
- package/dist/backend-a2/store-postgres.mjs +1 -1
- package/dist/backend-a2/store-redis-http.d.mts +1 -1
- package/dist/backend-a2/store-redis-http.mjs +1 -1
- package/dist/backend-a2/store-redis.d.mts +1 -1
- package/dist/backend-a2/store-redis.mjs +1 -1
- package/dist/backend-a2/store-sqlite.d.mts +1 -1
- package/dist/backend-a2/store-sqlite.mjs +2 -2
- package/dist/backend-a2.d.mts +131 -240
- package/dist/backend-a2.mjs +2 -2
- package/dist/{backend-http-3PoBYQoo.d.mts → backend-http-BRrRbjRx.d.mts} +49 -45
- package/dist/{backend-http-BPaNYLWj.mjs → backend-http-CvUKOn-m.mjs} +11 -9
- package/dist/{backend-http-16SbvnAK.mjs → backend-http-D8mUzuKQ.mjs} +2 -2
- package/dist/backend-http.d.mts +1 -1
- package/dist/backend-http.mjs +1 -1
- package/dist/backend-http.node.d.mts +1 -1
- package/dist/backend-http.node.mjs +1 -1
- package/dist/{backend-vercel-Kd8nvW5A.mjs → backend-vercel-Ba8JDtqW.mjs} +3 -3
- package/dist/{backend-vercel-qznuDxnN.d.mts → backend-vercel-BaSEUdwg.d.mts} +49 -45
- package/dist/{backend-vercel-C4Kee2-K.mjs → backend-vercel-CmY9yZ9m.mjs} +80 -15
- package/dist/backend-vercel.d.mts +1 -1
- package/dist/backend-vercel.mjs +1 -1
- package/dist/backend-vercel.node.d.mts +1 -1
- package/dist/backend-vercel.node.mjs +1 -1
- package/dist/{blob-CqILvdmr.d.mts → blob-Bx8QPWEd.d.mts} +2 -2
- package/dist/{blob-DQK5prXT.mjs → blob-WbB2DJKn.mjs} +30 -11
- package/dist/{chat-provider-D6jVT4wS.d.mts → chat-provider-BChYrrA7.d.mts} +32 -28
- package/dist/{chat-provider-COtsU5jB.mjs → chat-provider-DkFp2hrh.mjs} +105 -60
- package/dist/cli-commands.d.mts +1 -1
- package/dist/cli-commands.mjs +1 -1
- package/dist/cli-embedded.d.mts +2 -2
- package/dist/cli-embedded.mjs +1 -1
- package/dist/cli.mjs +4 -4
- package/dist/{client-CjAatpV4.d.mts → client-COfYrgfV.d.mts} +184 -221
- package/dist/{commands-BtH69yqt.d.mts → commands-DK4W3HCJ.d.mts} +30 -10
- package/dist/{commands-B5DHjZKK.mjs → commands-L1JSGz2Z.mjs} +162 -45
- package/dist/{conformance-C7fwIvmN.mjs → conformance-DK43HiHf.mjs} +224 -20
- package/dist/conformance.d.mts +8 -2
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-DkdkXQEy.mjs → engine-DurYIRt8.mjs} +29 -210
- package/dist/{errors-87TVtXeb.d.mts → errors-2vGs49Db.d.mts} +1 -1
- package/dist/{errors-C_5rB8Iz.mjs → errors-CJXhhzvT.mjs} +28 -23
- package/dist/{provider-BaVmi4Hi.mjs → events-lujPA3gQ.mjs} +80 -17
- package/dist/{exports-CG0P8Qi4.mjs → exports-CWIdN0Ub.mjs} +146 -80
- package/dist/exports-FBOQWT7O.d.mts +5 -0
- package/dist/{folds-DGYkMW19.mjs → folds-Cu9Q9zyQ.mjs} +2 -22
- package/dist/{folds-8wRRXjAt.d.mts → folds-DLUzfibJ.d.mts} +13 -40
- package/dist/{index-BygAXP7t.d.mts → index-Ho-ayyQL.d.mts} +2 -2
- package/dist/index.d.mts +7 -7
- package/dist/{index.default-DhkgpfR2.mjs → index.default-BUcwDaAe.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.node.d.mts +7 -7
- package/dist/index.node.mjs +3 -3
- package/dist/inspect/app.js +62 -61
- package/dist/inspect/engine.js +3 -3
- package/dist/{main-D2s-QBOK.mjs → main-Dz-8komG.mjs} +189 -112
- package/dist/{middleware-DlJQYiwV.mjs → middleware-BavYSDUa.mjs} +1 -1
- package/dist/{node-D8Rtg1su.mjs → node-BgeGcwX0.mjs} +7 -3
- package/dist/{project-C_2xs57L.mjs → project-CQ4VUibA.mjs} +1 -1
- package/dist/{provider-Bm46LAdp.d.mts → provider-gynDUoyW.d.mts} +13 -43
- package/dist/{redis-tables-BWJZHUGg.mjs → redis-tables-DHf85ixI.mjs} +1 -19
- package/dist/{reducers-8jqLewgw.mjs → reducers-DcENwtwZ.mjs} +72 -75
- package/dist/{serve-lPoBDEds.mjs → serve-CTmow-YH.mjs} +6 -6
- package/dist/{server-D82a4uMe.mjs → server-DG8D6VV0.mjs} +4 -4
- package/dist/{serving-DKny38LK.mjs → serving-DBFRo6U_.mjs} +1 -1
- package/dist/{sql-Bj0fk6dn.mjs → sql-CT5xVuGr.mjs} +2 -8
- package/dist/{sqlite-db-Dc8G78XV.mjs → sqlite-db-0WeSOEZN.mjs} +1 -1
- package/dist/{sync-kapso.d.mts → sync-chat-sdk.d.mts} +33 -37
- package/dist/sync-chat-sdk.mjs +44 -0
- package/dist/sync-github.d.mts +5 -18
- package/dist/sync-github.mjs +4 -70
- package/dist/sync-linear.d.mts +3 -3
- package/dist/sync-linear.mjs +2 -3
- package/dist/sync-slack.d.mts +7 -7
- package/dist/sync-slack.mjs +33 -54
- package/dist/sync.d.mts +3 -3
- package/dist/sync.mjs +2 -4
- package/dist/{tokens-C5_yGRlL.mjs → tokens-Dnyd_y1n.mjs} +15 -6
- package/dist/{types-shZmGZ-q.d.mts → types-CmrFjpml.d.mts} +1 -3
- package/dist/{watchers-hash-B_5b0-ZG.mjs → watchers-hash-BjwlnGD9.mjs} +85 -105
- package/docs/01-board/01-mounting.md +2 -2
- package/docs/01-board/03-identity.md +5 -6
- package/docs/01-board/04-messages.md +3 -2
- package/docs/01-board/05-reading.md +2 -1
- package/docs/01-board/06-watchers.md +8 -6
- package/docs/01-board/08-middleware.md +1 -1
- package/docs/01-board/09-access.md +145 -14
- package/docs/02-sync/01-overview.md +19 -26
- package/docs/02-sync/02-functions.md +8 -9
- package/docs/02-sync/03-slack.md +5 -5
- package/docs/02-sync/04-github.md +2 -2
- package/docs/02-sync/06-providers.md +91 -62
- package/docs/03-guides/01-coding-agents.md +1 -1
- package/docs/03-guides/03-ci.md +7 -4
- package/docs/04-reference/01-cli.md +86 -41
- package/docs/04-reference/02-board.md +56 -23
- package/docs/04-reference/03-http.md +59 -32
- package/docs/04-reference/04-log.md +8 -11
- package/docs/04-reference/05-conformance.md +35 -6
- package/docs/index.md +2 -2
- package/package.json +6 -9
- package/sql/postgres/board.sql +1 -1
- package/sql/postgres/schema.sql +1 -1
- package/dist/events-qf5XG5VQ.mjs +0 -75
- package/dist/exports-CrGA5_yN.d.mts +0 -5
- package/dist/sync-kapso.mjs +0 -89
- package/dist/sync.chat.d.mts +0 -2
- package/dist/sync.chat.mjs +0 -2
- package/dist/users-yryHKFMo.mjs +0 -42
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
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
|
|
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,
|
|
22
|
-
|
|
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
|
-
| `
|
|
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
|
|
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 (
|
|
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
|
|
121
|
-
|
|
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
|
|
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`).
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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),
|
|
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,
|
|
296
|
-
|
|
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
|
|
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 `
|
|
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,
|
|
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`, `
|
|
224
|
-
`@vercel/board/sync-
|
|
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
|
-
|
|
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
|
|
360
|
-
Slack),
|
|
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
|
package/docs/02-sync/03-slack.md
CHANGED
|
@@ -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
|
|
43
|
-
uses for name lookups, joining channels, avatars, links, and file deletion
|
|
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,
|
|
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
|
|
15
|
-
|
|
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,
|
|
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 |
|
|
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.
|