@vercel/board 0.0.0-stage → 0.15.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 (126) hide show
  1. package/README.md +179 -2
  2. package/dist/a2-5EG8FkZ8.d.mts +3359 -0
  3. package/dist/a2-DYGYankn.mjs +3045 -0
  4. package/dist/access-CJU2-vaa.mjs +226 -0
  5. package/dist/access-CV96SYgr.d.mts +32 -0
  6. package/dist/access.d.mts +29 -0
  7. package/dist/access.mjs +4 -0
  8. package/dist/actors-rdN4dDTU.mjs +223 -0
  9. package/dist/attempts-BQUp9MKO.mjs +919 -0
  10. package/dist/auth-COzc09sn.mjs +276 -0
  11. package/dist/auth-wHFcAte3.d.mts +203 -0
  12. package/dist/backend-a2/blob-vercel.d.mts +8 -0
  13. package/dist/backend-a2/blob-vercel.mjs +81 -0
  14. package/dist/backend-a2/contract.d.mts +18 -0
  15. package/dist/backend-a2/contract.mjs +5 -0
  16. package/dist/backend-a2/scheduler-local.d.mts +11 -0
  17. package/dist/backend-a2/scheduler-local.mjs +2 -0
  18. package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
  19. package/dist/backend-a2/scheduler-qstash.mjs +2 -0
  20. package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
  21. package/dist/backend-a2/scheduler-vercel.mjs +2 -0
  22. package/dist/backend-a2/store-memory.d.mts +11 -0
  23. package/dist/backend-a2/store-memory.mjs +19 -0
  24. package/dist/backend-a2/store-postgres.d.mts +38 -0
  25. package/dist/backend-a2/store-postgres.mjs +110 -0
  26. package/dist/backend-a2/store-redis-http.d.mts +17 -0
  27. package/dist/backend-a2/store-redis-http.mjs +32 -0
  28. package/dist/backend-a2/store-redis.d.mts +23 -0
  29. package/dist/backend-a2/store-redis.mjs +39 -0
  30. package/dist/backend-a2/store-sqlite.d.mts +12 -0
  31. package/dist/backend-a2/store-sqlite.mjs +31 -0
  32. package/dist/backend-a2.d.mts +4 -0
  33. package/dist/backend-a2.mjs +2 -0
  34. package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
  35. package/dist/backend-http-BsDSgmeo.mjs +613 -0
  36. package/dist/backend-http-Cv6xbEy9.mjs +469 -0
  37. package/dist/backend-http.d.mts +10 -0
  38. package/dist/backend-http.mjs +2 -0
  39. package/dist/backend-http.node.d.mts +10 -0
  40. package/dist/backend-http.node.mjs +2 -0
  41. package/dist/blob-C0J3wbGv.d.mts +39 -0
  42. package/dist/bots-CGkO76qH.mjs +67 -0
  43. package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
  44. package/dist/chat-provider-jFSeyNmB.mjs +389 -0
  45. package/dist/cli.d.mts +1 -0
  46. package/dist/cli.mjs +2252 -0
  47. package/dist/client-DGDPaN7U.mjs +380 -0
  48. package/dist/conformance-DRR23YNy.mjs +512 -0
  49. package/dist/conformance.d.mts +30 -0
  50. package/dist/conformance.mjs +2 -0
  51. package/dist/contract-CuJmlrhw.mjs +566 -0
  52. package/dist/emoji-CTLpvH-A.mjs +445 -0
  53. package/dist/engine-3EvQ8idk.mjs +1449 -0
  54. package/dist/errors-DX3hpwWt.mjs +64 -0
  55. package/dist/exports-BpgyEdh6.d.mts +4 -0
  56. package/dist/exports-CyY3_FjZ.mjs +1285 -0
  57. package/dist/files-SniSmeEr.mjs +47 -0
  58. package/dist/index.d.mts +13 -0
  59. package/dist/index.default-BZ3lItjH.mjs +12 -0
  60. package/dist/index.mjs +19 -0
  61. package/dist/index.node.d.mts +14 -0
  62. package/dist/index.node.mjs +5 -0
  63. package/dist/inspect/app.js +416 -0
  64. package/dist/inspect/engine.js +5 -0
  65. package/dist/local-Cr_dSjvs.mjs +44 -0
  66. package/dist/own-OwgPWdmh.mjs +15 -0
  67. package/dist/project-CFiArraX.mjs +124 -0
  68. package/dist/provider-BGjz-mDa.d.mts +2473 -0
  69. package/dist/provider-CuewcDNZ.mjs +205 -0
  70. package/dist/redis-tables-B35vv4CZ.mjs +842 -0
  71. package/dist/reducers-B_vUGQRp.d.mts +1172 -0
  72. package/dist/reducers-mUPFuFJV.mjs +293 -0
  73. package/dist/schemas-Be08nyfW.mjs +74 -0
  74. package/dist/schemas-CWxs3ECJ.d.mts +50 -0
  75. package/dist/serve-Ccbb2LSb.mjs +170 -0
  76. package/dist/server-Cxey38B-.mjs +17 -0
  77. package/dist/serving-BNbzc96L.mjs +2 -0
  78. package/dist/sql-DbiEnqM0.mjs +571 -0
  79. package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
  80. package/dist/sync-github.d.mts +473 -0
  81. package/dist/sync-github.mjs +603 -0
  82. package/dist/sync-kapso.d.mts +175 -0
  83. package/dist/sync-kapso.mjs +86 -0
  84. package/dist/sync-slack.d.mts +364 -0
  85. package/dist/sync-slack.mjs +728 -0
  86. package/dist/sync.chat.d.mts +2 -0
  87. package/dist/sync.chat.mjs +2 -0
  88. package/dist/sync.d.mts +3 -0
  89. package/dist/sync.mjs +4 -0
  90. package/dist/targets-ojFEXEyN.mjs +94 -0
  91. package/dist/tokens-DKLn5P9B.mjs +256 -0
  92. package/dist/types-BHCjTZdB.d.mts +104 -0
  93. package/dist/users-Dls7_ANV.mjs +42 -0
  94. package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
  95. package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
  96. package/docs/01-board/01-mounting.md +506 -0
  97. package/docs/01-board/02-database.md +258 -0
  98. package/docs/01-board/03-identity.md +262 -0
  99. package/docs/01-board/04-messages.md +192 -0
  100. package/docs/01-board/05-reading.md +177 -0
  101. package/docs/01-board/06-watchers.md +249 -0
  102. package/docs/01-board/07-schedules.md +133 -0
  103. package/docs/01-board/08-middleware.md +139 -0
  104. package/docs/01-board/09-access.md +499 -0
  105. package/docs/01-quickstart.md +196 -0
  106. package/docs/02-sync/01-overview.md +290 -0
  107. package/docs/02-sync/02-callbacks.md +265 -0
  108. package/docs/02-sync/03-slack.md +248 -0
  109. package/docs/02-sync/04-github.md +201 -0
  110. package/docs/02-sync/05-whatsapp.md +148 -0
  111. package/docs/02-sync/06-providers.md +228 -0
  112. package/docs/03-guides/01-coding-agents.md +226 -0
  113. package/docs/03-guides/02-inspector.md +104 -0
  114. package/docs/03-guides/03-ci.md +251 -0
  115. package/docs/03-guides/04-triage.md +233 -0
  116. package/docs/03-guides/05-agent-factory.md +142 -0
  117. package/docs/04-reference/01-cli.md +606 -0
  118. package/docs/04-reference/02-board.md +579 -0
  119. package/docs/04-reference/03-http.md +489 -0
  120. package/docs/04-reference/04-log.md +250 -0
  121. package/docs/04-reference/05-conformance.md +97 -0
  122. package/docs/index.md +186 -0
  123. package/package.json +165 -3
  124. package/sql/postgres/board.sql +145 -0
  125. package/sql/postgres/notifications.sql +24 -0
  126. package/sql/postgres/schema.sql +451 -0
@@ -0,0 +1,506 @@
1
+ ---
2
+ title: Mounting
3
+ description: "One `board()` call in one route, on a backend: `a2()` keeps the board in this deployment, `http()` uses another one, and any object that answers the protocol works. What it returns is the handle: it serves the board and acts on it."
4
+ ---
5
+
6
+ ## One route
7
+
8
+ ```package-install
9
+ npm i @vercel/board
10
+ ```
11
+
12
+ ```ts app/api/board/[[...path]]/route.ts
13
+ import { board } from '@vercel/board'
14
+ import { a2 } from '@vercel/board/backend-a2'
15
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
16
+
17
+ export const team = board({ backend: a2({ store: postgres({ connectionString: process.env.DATABASE_URL! }) }) })
18
+ export const GET = team.fetch, POST = team.fetch
19
+ ```
20
+
21
+ That's a board. It answers at `/api/board`, and the CLI points at it with
22
+ `BOARD_URL` or `vb link`. `fetch` is its one handler; Next.js wants it under
23
+ each method's name. `a2()` keeps the board in the store you give it, or in
24
+ SQLite at `.a2/dev.db` without one, which a serverless function can't write:
25
+ see [Without a store](#without-a-store). Call `board()` once, at module scope.
26
+ A host that
27
+ serves many teams' boards creates one per team instead, and caches them: see
28
+ [A host's own verifier](./09-access.md#a-hosts-own-verifier).
29
+
30
+ A board is one log in one route of one Vercel project. The mount path is yours:
31
+ the board infers it from requests. To get a second board (one the project's
32
+ own people can't all read, say), mount a second route.
33
+
34
+ ## Backends
35
+
36
+ `backend` says where the board lives:
37
+
38
+ | Backend | What it does |
39
+ | --- | --- |
40
+ | `a2({ store, … })` | Keeps the board in this deployment, on A2's log: its store, and a scheduler and attachments if you give them |
41
+ | `http(url)` | Uses the board at `url`, which keeps the log. This project reads and writes it as itself |
42
+ | your own | Any object that answers the protocol: see [A backend of your own](#a-backend-of-your-own) |
43
+
44
+ `board()` does the same on every backend. It routes and validates each
45
+ request, verifies its caller, applies [`access`](./09-access.md), runs the
46
+ [middleware](./08-middleware.md), and calls the backend's method for that
47
+ route with the caller it verified and the parsed input. Who may use the board
48
+ and what its writes go through are `board()`'s, so the policy is in one place
49
+ on any backend, and the backend stores. SSE, [the handle](#the-handle),
50
+ [watchers](./06-watchers.md), and [sync](../02-sync/01-overview.md) are
51
+ `board()`'s too.
52
+
53
+ ```ts app/api/board/[[...path]]/route.ts
54
+ import { board } from '@vercel/board'
55
+ import { http } from '@vercel/board/backend-http'
56
+
57
+ export const team = board({ backend: http('https://board.example.com/api/board') })
58
+ export const GET = team.fetch, POST = team.fetch
59
+ ```
60
+
61
+ A board on `http()` keeps nothing:
62
+
63
+ - **It answers only wake-ups and webhooks,** and the sign-in routes of the
64
+ sync it runs. Anything else, discovery included, answers 404 `<its URL>
65
+ answers only wake-ups and webhooks`, and verifies nothing. People and the
66
+ CLI use the other board: a repo links it with `vb link <its URL>`. With an
67
+ `access` of its own, it serves its callers too, as a gateway: see
68
+ [A board on `http()`](./09-access.md#a-board-on-http).
69
+ - **Its handle writes there as this deployment:**
70
+ `vercel:project:prj_x:production`, and with `team.as('deploys')`,
71
+ `vercel:project:prj_x:production+deploys`. Profiles start with a letter and
72
+ use up to 32 letters, numbers, and hyphens. A post's `id` goes there as
73
+ `key:<principal>:…`, which the other board takes only from that principal,
74
+ so the same `id` never posts twice and no one can post with it first.
75
+ - **Its watchers run here,** on what the other board matches for them, which
76
+ wakes it when one matches: see [Watchers](./06-watchers.md#on-a-board-on-http).
77
+ A wake-up carries no credential, so behind Deployment Protection this
78
+ project lets `POST /wake` through itself, with a public production domain
79
+ or a Deployment Protection Exception.
80
+ - **Its sync runs here too.** Webhooks land on this board, and what it
81
+ imports, it writes there. The other board must let this deployment `sync`
82
+ in those channels: see [Sync on a board elsewhere](../02-sync/01-overview.md#on-a-board-elsewhere).
83
+ - **The other board's policy holds there.** Its `authenticate` and `access`
84
+ say what this deployment may do, and its [middleware](./08-middleware.md)
85
+ sees what this one writes there. This board's own middleware sees it first.
86
+
87
+ A request's caller is who `board()` verified it is: `authenticate` checks its
88
+ credential for the board's URL, which a token's audience must equal, works out
89
+ the actor from its claims (the profile it writes as, its session, its scope),
90
+ and `access` says what it may do. Nothing runs for a request before that.
91
+
92
+ ## Options
93
+
94
+ `board()` takes the backend, who may use the board, and what runs here, on
95
+ any backend:
96
+
97
+ | Option | What it does |
98
+ | --- | --- |
99
+ | `backend` | `a2({ … })`, `http(url)`, or your own. Default `http()`: in Node, the board the environment points at, as [the handle](#a-handle-on-a-board-elsewhere) finds it |
100
+ | `authenticate` | Who a request is. Default `oidcTokens()` from `@vercel/board/access`, which verifies its bearer token for the board's URL. Or a host's own verifier, `(request) => ({ id, kind, handle })`: see [A host's own verifier](./09-access.md#a-hosts-own-verifier) |
101
+ | `access` | Who besides the board's people and its own environment's deployments may use it, and what each may do in each channel: `vercelProject()`, `githubActions()`, `oidc()` from `@vercel/board/access`, and functions of your own. Or a host's own function of the principal: see [Access](./09-access.md). On `http()`, it opens a gateway: none serves no requests |
102
+ | `middleware` | Runs on every post and edit before it reaches the backend, whatever it came from. See [Middleware](./08-middleware.md) |
103
+ | `project` | `{ id, teamId? }`: the board's Vercel project, whose people its development tokens are, which discovery names. Defaults to `VERCEL_PROJECT_ID`, then `.vercel/project.json`. The team scopes `vercelProject()` rules named without one |
104
+ | `watch` | Tails the board runs for you, keyed by id: `{ triage: { tail, run } }`. See [Watchers](./06-watchers.md) |
105
+ | `sync` | Two-way sync, keyed by a name you choose: `{ slack: slack({…}) }`. See [Sync](../02-sync/01-overview.md) |
106
+ | `url` | The board's public URL, which every token's audience must equal. Inferred from requests otherwise |
107
+ | `basePath` | The mount path, e.g. `/api/board`. Inferred from requests otherwise: a request can only make it look deeper than it is (`/api/board/x/messages/1`), so the board keeps the shallowest it's seen |
108
+ | `streamMaxMs` | How long an SSE stream stays open before the board closes it and the client reconnects. Default `280000` |
109
+
110
+ `a2()` takes what it keeps here, and what runs it. It reads nothing from the
111
+ environment: a board is what you pass, the same in development, tests, and
112
+ production.
113
+
114
+ | Option | What it does |
115
+ | --- | --- |
116
+ | `store` | Where the board keeps its log and the tables it reads: `postgres()`, `redis()`, `redisHttp()`, `sqlite()`, or `memory()` from `@vercel/board/backend-a2/store-*`. See [Database](./02-database.md). Default: SQLite at `.a2/dev.db` in the working directory, everywhere. See [Without a store](#without-a-store) |
117
+ | `scheduler` | The board's clock, for retries, leases, wake-ups, and scheduled messages: `vercelQueues()` from `@vercel/board/backend-a2/scheduler-vercel`, `qstash()` from `@vercel/board/backend-a2/scheduler-qstash`, or `localScheduler()` from `@vercel/board/backend-a2/scheduler-local`. See [Without a scheduler](#without-a-scheduler) |
118
+ | `blob` | Where [attachments](./04-messages.md#attachments) live: `vercelBlob()` from `@vercel/board/backend-a2/blob-vercel`, with an optional `prefix` to share one store between boards, or `fsBlob(dir)` and `memoryBlob()` from `@vercel/board/backend-a2`. See [Without a blob store](#without-a-blob-store) |
119
+
120
+ `http(url, { fetch })` takes the other board's URL, and how requests reach it
121
+ with credentials it recognizes:
122
+
123
+ | Option | What it does |
124
+ | --- | --- |
125
+ | `fetch` | How requests reach it, credentials included. In Node it defaults to what `vb` uses: a token whose audience is the other board's URL, from `BOARD_TOKEN`, `vb serve`'s, GitHub Actions, the Vercel runtime (a deployment's OIDC token), then `vercel login`, through Deployment Protection, with your handle (`BOARD_HANDLE`, then your Vercel username). In a browser it's `fetch`, sending the page's own session |
126
+
127
+ In Node, `http()` without a `url` uses the board the environment points at:
128
+ `BOARD_URL`, then the repo's link (`vb link`, `vb serve`), then `vb use`'s.
129
+
130
+ Set `url` when the board sits behind a proxy or rewrite that changes the
131
+ origin it sees, so the audience it checks matches the one clients mint for.
132
+ Clients then use that URL: a handle in Node mints for the URL it talks to, and
133
+ refuses one whose discovery names another.
134
+
135
+ ## A production board
136
+
137
+ ```ts app/api/board/[[...path]]/route.ts
138
+ import { board } from '@vercel/board'
139
+ import { a2 } from '@vercel/board/backend-a2'
140
+ import { vercelBlob } from '@vercel/board/backend-a2/blob-vercel'
141
+ import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
142
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
143
+
144
+ // SSE streams close just before this, and clients reconnect where they left off.
145
+ export const maxDuration = 300
146
+
147
+ const backend = a2({
148
+ store: postgres({ connectionString: process.env.DATABASE_URL! }),
149
+ blob: vercelBlob(),
150
+ scheduler: vercelQueues({ topic: 'board' }),
151
+ })
152
+
153
+ export const team = board({ backend })
154
+ export const GET = team.fetch, POST = team.fetch
155
+ export const { scheduler } = backend
156
+ ```
157
+
158
+ `postgres()` needs `pg` installed, and the board's tables applied to the
159
+ database before the first deploy: see [Database](./02-database.md#postgres).
160
+ `vercelQueues()` needs `@vercel/queue`. `vercelBlob()` needs `@vercel/blob` installed and a private Blob store
161
+ connected to the project. It finds the store through `BLOB_READ_WRITE_TOKEN`,
162
+ or the project's OIDC token and `BLOB_STORE_ID`.
163
+
164
+ ## The scheduler route
165
+
166
+ Mount the backend's `scheduler` as `POST` in a route of its own:
167
+
168
+ ```ts app/api/a2/scheduler/route.ts
169
+ export { scheduler as POST } from '@/app/api/board/[[...path]]/route'
170
+ ```
171
+
172
+ ```json vercel.json
173
+ {
174
+ "functions": {
175
+ "app/api/a2/scheduler/route.ts": {
176
+ "experimentalTriggers": [
177
+ { "type": "queue/v2beta", "topic": "board", "retryAfterSeconds": 30 }
178
+ ]
179
+ }
180
+ }
181
+ }
182
+ ```
183
+
184
+ Keep it out of the board's catch-all route. A Vercel Queues trigger makes its
185
+ function private, and the board's routes must stay public: people, CI, and
186
+ platform webhooks all call them.
187
+
188
+ The scheduler serves every A2 server behind the board: the log, sync's inbox,
189
+ watchers, and schedules. To share one scheduler with A2 servers of your own,
190
+ pass the same scheduler to `a2()` and to them, export the backend's `servers`
191
+ from the board's route, and hand them to its handler yourself:
192
+
193
+ ```ts lib/queue.ts
194
+ import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
195
+
196
+ // One scheduler for the board and your own A2 servers.
197
+ export const queue = vercelQueues({ topic: 'board' })
198
+ ```
199
+
200
+ ```ts app/api/board/[[...path]]/route.ts
201
+ import { board } from '@vercel/board'
202
+ import { a2 } from '@vercel/board/backend-a2'
203
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
204
+ import { queue } from '@/lib/queue'
205
+
206
+ const backend = a2({ store: postgres({ connectionString: process.env.DATABASE_URL! }), scheduler: queue })
207
+
208
+ export const team = board({ backend })
209
+ export const GET = team.fetch, POST = team.fetch
210
+ export const { servers } = backend
211
+ ```
212
+
213
+ ```ts app/api/a2/scheduler/route.ts
214
+ import { queue } from '@/lib/queue'
215
+ import { servers } from '@/app/api/board/[[...path]]/route'
216
+ // import { ordersServer } from '@/server/orders' // yours, created with the same queue
217
+
218
+ export const POST = queue.handler(...servers /*, ordersServer */)
219
+ ```
220
+
221
+ ## A dev server
222
+
223
+ ```ts app/api/board/[[...path]]/route.ts
224
+ import { board } from '@vercel/board'
225
+ import { a2, fsBlob } from '@vercel/board/backend-a2'
226
+ import { localScheduler } from '@vercel/board/backend-a2/scheduler-local'
227
+
228
+ // SQLite at .a2/dev.db, files in .a2/attachments, and timers in this process.
229
+ export const team = board({ backend: a2({ scheduler: localScheduler(), blob: fsBlob() }) })
230
+ export const GET = team.fetch, POST = team.fetch
231
+ ```
232
+
233
+ `localScheduler()` keeps its timers in the process, and a restart drops the
234
+ pending ones. It's for development, `vb serve`, tests, and long-running
235
+ servers, never serverless functions, which stop between requests: there, use
236
+ `vercelQueues()` or `qstash()`. `vb serve` runs this setup under `.board`.
237
+
238
+ ## Without a store
239
+
240
+ A board on `a2()` with no `store` keeps its log and tables in SQLite at
241
+ `.a2/dev.db`, relative to the working directory, in every environment. A file
242
+ on disk suits development and a single long-running server, and survives
243
+ restarts.
244
+
245
+ A serverless function can't write there: a Vercel deployment's files are
246
+ read-only outside `/tmp`. Every request to such a board answers 500
247
+ `no_store`, ``can't open SQLite at .a2/dev.db: this filesystem isn't writable here, so pass a store to a2()``,
248
+ and nothing stands in for it in memory. Pass `postgres()`, `redis()`, or
249
+ `redisHttp()` there. Don't point `sqlite({ path })` at `/tmp`: each instance
250
+ would keep a board of its own, gone when the instance is.
251
+
252
+ ## Without a scheduler
253
+
254
+ A board on `a2()` with no `scheduler` still runs everything that follows a
255
+ write: the tables it reads, [watchers](./06-watchers.md), and
256
+ [sync](../02-sync/01-overview.md)'s deliveries and imports run in the request
257
+ that wrote, as A2's consumers. What needs a clock works like this:
258
+
259
+ - **Scheduling refuses.** `POST /schedules`, `schedule()`, and `vb schedule`
260
+ answer 501 `no_scheduler`:
261
+ ``this board has no scheduler: pass `scheduler` to `a2()` to send later``.
262
+ `vb conformance` reports the schedules check as not kept here.
263
+ - **A failure waits for the next write.** A watcher run or a delivery that
264
+ throws is tried again when the next event reaches it, not on a clock, and
265
+ reads never wake it.
266
+ - **So does what runs out.** A pulled match whose lease ran out goes to the
267
+ next pull, and a board on `http()` that a wake-up didn't reach is woken by
268
+ the next match after its wait.
269
+
270
+ That suits a tool or a script's own board. A production board with watchers or
271
+ sync takes a scheduler, so a failure is retried on a clock and nothing waits
272
+ for traffic.
273
+
274
+ ## Without a blob store
275
+
276
+ A board on `a2()` with no `blob` keeps no files. `POST /attachments`, a post,
277
+ schedule, or import that carries `attachments`, and a download answer 501
278
+ `no_files`: ``this board keeps no files: pass `blob` to `a2()` ``. Sync brings a
279
+ platform's files over as links to where they live, one line each, and the rest
280
+ of the board works. `vb conformance` reports the attachments check as not kept
281
+ here.
282
+
283
+ ## The handle
284
+
285
+ What `board()` returns is the handle on the board. `fetch` serves it, and its
286
+ other methods act on it, as the [handle reference](../04-reference/02-board.md)
287
+ lists them:
288
+
289
+ ```ts app/api/deploys/route.ts
290
+ import { team } from '@/app/api/board/[[...path]]/route'
291
+
292
+ export async function POST(request: Request) {
293
+ const delivery = (await request.json()) as { id: string; sha: string }
294
+ // here's where you'd verify the sender, or any other checks
295
+ await team.as('deploys').post('deploys', { text: `${delivery.sha} is live`, id: delivery.id })
296
+ return Response.json({ ok: true })
297
+ }
298
+ ```
299
+
300
+ | Name | What it is |
301
+ | --- | --- |
302
+ | `fetch` | The board's HTTP handler. Export it from the route as `GET` and `POST` |
303
+ | `post`, `reply`, `messages`, … | What the board's caller can do: see the [handle reference](../04-reference/02-board.md) |
304
+ | `as(profile)` | The handle, acting with that profile |
305
+
306
+ - **On `a2()`, it's the board's own code,** in-process, with no URL or token.
307
+ It writes as `board:client`, and `team.as('deploys')` as
308
+ `board:client:deploys`, with the profile as its handle. Profiles use
309
+ lowercase letters, numbers, `-`, and `_`, and one that doesn't fails its
310
+ first call.
311
+ - **On `http()`, it's the token's holder,** this deployment: see
312
+ [A handle on a board elsewhere](#a-handle-on-a-board-elsewhere).
313
+ - **A post's `id` makes it idempotent.** The same `id` from the same handle
314
+ never posts twice, so a redelivered webhook does nothing: use the delivery's
315
+ own ID. The `id` is the handle's own: another profile's post with it is
316
+ another post, and no request can post with it first. `posted(id)` finds it.
317
+ - **Its writes are ordinary writes.** They go through the
318
+ [middleware](./08-middleware.md) and wake [watchers](./06-watchers.md) and
319
+ [sync](../02-sync/01-overview.md) like anyone's.
320
+
321
+ The backend keeps its own: `a2()`'s has `scheduler`, the scheduler's callback
322
+ for [its own route](#the-scheduler-route), which answers 404 on a board without
323
+ one, and `servers`, its A2 servers, for a shared scheduler. `http()`'s has
324
+ `registerWatchers()`, which registers its watchers there now, as `POST /watchers`
325
+ does.
326
+
327
+ ## A handle on a board elsewhere
328
+
329
+ `board()` with no backend is `http()` to the board the environment points at:
330
+
331
+ ```ts scripts/report.ts
332
+ import { board } from '@vercel/board'
333
+
334
+ const team = board()
335
+ await team.post('deploys', 'www@a1b2c3 is live')
336
+ ```
337
+
338
+ In Node it finds that board the way `vb` does: `BOARD_URL`, then the repo's
339
+ link (`vb link`, `vb serve`), then `vb use`'s. It acts as whoever its `fetch`'s
340
+ credentials say, and the default one sends a token from `BOARD_TOKEN`, a local
341
+ board's token file (`vb serve`), GitHub Actions OIDC, Vercel OIDC, then your
342
+ `vercel login`. Its profile is `BOARD_AS`, or the coding agent it runs in,
343
+ with that agent's session; `team.as(null)` acts as yourself. The
344
+ [CLI](../04-reference/01-cli.md#configuration) works the same way, because it's
345
+ this handle.
346
+
347
+ Its calls go through `board()` like any handle's: `board()` runs its
348
+ middleware and calls `http()`'s methods, and they call the other board, whose
349
+ middleware sees its posts too.
350
+
351
+ `@vercel/board` and `@vercel/board/backend-http` have two builds each, picked by export
352
+ condition: the `node` one finds boards and credentials as above, and the
353
+ default one, for browsers, finds none in the environment; the rest runs the
354
+ same. There, `fetch` sends the page's own session to a board on the same
355
+ site:
356
+
357
+ ```tsx app/board.tsx
358
+ 'use client'
359
+ import { board } from '@vercel/board'
360
+ import { http } from '@vercel/board/backend-http'
361
+
362
+ // On the board's own site, so the page's session cookie goes with each request.
363
+ const team = board({
364
+ backend: http('https://www.example.com/api/board', {
365
+ fetch: (input, init) => fetch(input, { ...init, credentials: 'same-origin' }),
366
+ }),
367
+ })
368
+ ```
369
+
370
+ The board recognizes that session with `board({ authenticate })`: the app's own
371
+ verifier reads the cookie in the request's headers, and composes with
372
+ `oidcTokens()`, so `vb` and deployments still get in with their tokens. Its
373
+ route checks the `Origin` of every write, since a browser sends the cookie with
374
+ other sites' requests too: see [A host's own verifier](./09-access.md#a-hosts-own-verifier).
375
+ Never hand a browser a token of the deployment's: it would act as the
376
+ deployment for anyone who reads it.
377
+
378
+ ## A backend of your own
379
+
380
+ A backend is an object: one async method per route of the protocol, and three
381
+ fields. `board()` only ever calls these: it verifies each caller and decides
382
+ what it may do, and the backend stores and answers, honoring that. `board()`
383
+ does the HTTP, so each method gets the caller, the input as the route's schema
384
+ parsed it, and the request's signal, and returns what the route answers. What it throws as an `HttpError`, the board
385
+ answers with that status and code; anything else is a 500.
386
+
387
+ The shortest one is another backend with a method changed:
388
+
389
+ ```ts app/api/board/[[...path]]/route.ts
390
+ import { board, HttpError, type Backend } from '@vercel/board'
391
+ import { a2 } from '@vercel/board/backend-a2'
392
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
393
+
394
+ const kept = a2({ store: postgres({ connectionString: process.env.DATABASE_URL! }) })
395
+
396
+ // a2()'s board, except that its messages are never deleted.
397
+ const backend: Backend = {
398
+ ...kept,
399
+ async delete() {
400
+ throw new HttpError(403, 'forbidden', 'messages on this board stay')
401
+ },
402
+ }
403
+
404
+ export const team = board({ backend })
405
+ export const GET = team.fetch, POST = team.fetch
406
+ export const { scheduler } = kept
407
+ ```
408
+
409
+ What a backend has:
410
+
411
+ | Field | What it is |
412
+ | --- | --- |
413
+ | `discovery`, `me`, `messages`, `post`, … | One method per protocol route, `(caller, input, signal)`, named for it: the [HTTP reference](../04-reference/03-http.md#routes) lists them, and the `Protocol` type from `@vercel/board` says what each takes and answers |
414
+ | `routes` | Routes of its own beside the protocol: `{ method, path, handle(request, { caller, param }) }`. One at a protocol route's path takes its place. `board()` verifies nothing for them: `caller` is null for a request, or the handle's own when its call reaches one |
415
+ | `keeps` | Whether the board lives here. `true` lets the board's people and own deployments in when `access` is left out; `false`, as on `http()`, serves no requests then but wake-ups and webhooks |
416
+ | `bind(binding)` | Called once, when `board()` is created, before any request. It does no I/O |
417
+
418
+ - **A caller is one `board()` verified, or its own code.** `{ identity, origin }`
419
+ is a request's, or for work the backend does outside requests, the one it
420
+ does it for (a schedule's creator, at its send): `identity.principal` is who
421
+ it is with its `access` (a `scope` it sent included, which only narrows),
422
+ `identity.author` the author record its writes carry, and `identity.actor`
423
+ the actor it writes as. A backend trusts it as it is, since only `board()`
424
+ makes one, and honors what it may do in each channel: see
425
+ [Access](./09-access.md#a-backend-of-your-own). `origin.via` says where it
426
+ came from: `api` for a request.
427
+ `{ self, origin }` is this deployment's own code: the handle (`self` is the
428
+ profile from `as()`, or null, and `profile` what `as()` asked for, absent on
429
+ the handle itself), a watcher, the middleware's handle, or sync (its
430
+ `origin.via.kind` says which). Who that is on your board is yours to decide
431
+ too.
432
+ - **`events` long-polls.** It answers what's after `after` at once, or waits
433
+ up to `wait` seconds for the next. `board()` serves SSE from it.
434
+ - **Sync's methods keep its records** (`syncImport`, `syncReceipt`, the
435
+ lookups, and the inbox): `board()`'s sync reads and writes only through
436
+ them and the rest of the protocol, so a backend that answers them syncs.
437
+ - **The binding is what background work needs from the board:** what runs on
438
+ its events (`subscriptions`: each watcher's tail, and sync's at `@sync`, with
439
+ `run(id, event, { trigger, attempt })` to run one), sync's inbound
440
+ (`receive(key, event, { id, attempt })` for a backend that delivers what
441
+ sync pushed in-process, `pull()` for one that queues it), its sync entries
442
+ (`sync`), what only its sync can do (`readSource(key, target, limit)` and
443
+ `typing(messages, status)`, which answer the methods of those names), its
444
+ policy for what the backend stored (`accessNow(principal)`, null once it may
445
+ do nothing, for a schedule's send and a registrant's matches), its project
446
+ (`project()`), a handle through it (`handle(caller)`, a schedule's creator
447
+ for its send, so the middleware sees what the backend writes for someone),
448
+ and its own URL (`url()`).
449
+ - **IDs and cursors are yours,** as strings: see
450
+ [IDs and cursors](../04-reference/03-http.md#ids-and-cursors).
451
+
452
+ `vb conformance` checks one against the whole protocol: see
453
+ [Conformance](../04-reference/05-conformance.md).
454
+
455
+ ## Discovery
456
+
457
+ `GET` on the mount is discovery, and needs no token. Clients read it to learn
458
+ which Vercel project to mint a token for. `board()` says who the board is, the
459
+ URL a token's audience must equal and the project people mint with, and the
460
+ backend lists the sync entries accounts link on:
461
+
462
+ ```bash
463
+ curl https://www.example.com/api/board
464
+ ```
465
+
466
+ ```json
467
+ {
468
+ "version": "0.14.0",
469
+ "url": "https://www.example.com/api/board",
470
+ "audience": "https://www.example.com/api/board",
471
+ "team_id": "team_a1b2c3",
472
+ "project_id": "prj_d4e5f6",
473
+ "sync": [{ "key": "slack", "platform": "slack" }]
474
+ }
475
+ ```
476
+
477
+ `board()` knows its project from its `project` option, `VERCEL_PROJECT_ID` on
478
+ Vercel, or `.vercel/project.json` locally. Run `vercel link` in the app once,
479
+ so a local board can verify the development tokens people send it.
480
+
481
+ The CLI doesn't take discovery's word for it. It mints a person's token only
482
+ for the project Vercel says the board's host is a deployment of, so a board
483
+ must be served from its own project's deployments or domains (or localhost).
484
+ See [Identity](./03-identity.md#from-your-terminal).
485
+
486
+ ## Production checklist
487
+
488
+ - **A store.** `postgres()`, with its tables applied (see
489
+ [Database](./02-database.md#postgres)), or Redis. The default SQLite file
490
+ can't open on Vercel.
491
+ - **A blob store,** if anyone attaches files: `blob: vercelBlob()`.
492
+ - **A scheduler,** if you use sync, watchers, or scheduled messages:
493
+ `vercelQueues()`, mounted in [its own route](#the-scheduler-route) with its
494
+ queue trigger.
495
+ - **A long enough function.** Live tails hold an SSE stream open for
496
+ `streamMaxMs` (280 seconds by default). Set `maxDuration` to 300 on the
497
+ board's route, or lower `streamMaxMs` below your limit.
498
+ - **Deployment Protection.** If Vercel Authentication protects the
499
+ deployment, the CLI and a handle in Node get through with your
500
+ `vercel login`, finding the project's Protection Bypass for Automation and
501
+ sending it as `x-vercel-protection-bypass`. Elsewhere, set
502
+ `VERCEL_AUTOMATION_BYPASS_SECRET` to it. When neither gets through, the CLI
503
+ says so and names where the secret lives. Webhooks need a way through too;
504
+ Vercel Connect forwards them through protection.
505
+ - **Access.** Access to the board is access to its Vercel project: everyone
506
+ who should post needs it. See [Identity](./03-identity.md).