@vercel/board 0.0.0 → 0.16.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 +180 -0
  2. package/dist/a2-C41hD8SF.mjs +3045 -0
  3. package/dist/a2-D24cvwpN.d.mts +3359 -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 +30 -0
  7. package/dist/access.mjs +4 -0
  8. package/dist/actors-DjMeZpnU.mjs +224 -0
  9. package/dist/attempts-Bxp9KgPf.mjs +919 -0
  10. package/dist/auth-COzc09sn.mjs +276 -0
  11. package/dist/auth-CU56Q2Xh.d.mts +204 -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-0roEmjl0.mjs +613 -0
  35. package/dist/backend-http-BVRno-ca.mjs +469 -0
  36. package/dist/backend-http-Bid7RIpC.d.mts +630 -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-Bir5s8ts.mjs +1449 -0
  54. package/dist/errors-DX3hpwWt.mjs +64 -0
  55. package/dist/exports-DLiE7fLY.d.mts +4 -0
  56. package/dist/exports-Dvc1o52I.mjs +1285 -0
  57. package/dist/files-SniSmeEr.mjs +47 -0
  58. package/dist/index.d.mts +13 -0
  59. package/dist/index.default-Bkg77Pxn.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-BsOOc-6_.mjs +170 -0
  76. package/dist/server-jwPY7_wi.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-yM5JBUgr.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-CLjsa6od.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 +264 -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 +501 -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 +168 -1
  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,258 @@
1
+ ---
2
+ title: Database
3
+ description: "Where a board on `a2()` keeps everything: Postgres or Redis in production, SQLite by default, memory in tests."
4
+ ---
5
+
6
+ ## Pick a store
7
+
8
+ ```package-install
9
+ npm i @vercel/board pg
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
+ const team = board({
18
+ backend: a2({
19
+ store: postgres({ connectionString: process.env.DATABASE_URL! }),
20
+ }),
21
+ })
22
+ export const GET = team.fetch, POST = team.fetch
23
+ ```
24
+
25
+ | Store | From | For |
26
+ | --- | --- | --- |
27
+ | `postgres({ connectionString })` | `@vercel/board/backend-a2/store-postgres` | production; needs `pg` |
28
+ | `redis({ url })` | `@vercel/board/backend-a2/store-redis` | production, on Redis over TCP; needs `ioredis` |
29
+ | `redisHttp({ url, token })` | `@vercel/board/backend-a2/store-redis-http` | production, on Upstash's REST API; needs nothing |
30
+ | `sqlite({ path })` | `@vercel/board/backend-a2/store-sqlite` | development, one long-running server, and `vb serve`; `path` defaults to `.a2/dev.db` |
31
+ | `memory({ clock })` | `@vercel/board/backend-a2/store-memory` | tests |
32
+
33
+ Leave `store` out and it's `sqlite()`: a file at `.a2/dev.db`, under the
34
+ working directory, in every environment. That suits development and one
35
+ long-running server. On Vercel the filesystem isn't writable outside `/tmp`, so
36
+ the board answers each request with a 500, `no_store`, until you pass a store.
37
+ A SQLite file in `/tmp` would be a separate, throwaway board on each instance,
38
+ so don't. See [Mounting](./01-mounting.md#options).
39
+
40
+ ## What a store keeps
41
+
42
+ One database holds two things:
43
+
44
+ - **The log.** Every board fact is an event in one A2 session, `board`, in
45
+ A2's tables (`a2_*`). It's the truth: nothing else is written directly.
46
+ - **The tables.** board keeps its own tables (`board_*`) from the log, one
47
+ event at a time, in order, and reads from them.
48
+
49
+ | Table | Holds |
50
+ | --- | --- |
51
+ | `board_messages` | each message as it is now, with the columns filters use; a root's row also holds its reply count and its latest reply |
52
+ | `board_versions` | each message after every event that changed it, so `vb tail` and watchers can show a change before and after |
53
+ | `board_terms` | labels and mentions, so `--label` and `--mentions` are an index lookup |
54
+ | `board_sync_threads`, `board_copies` | where each thread and message lives on each platform: a thread's targets and the platform thread on each, a message's copy on each |
55
+ | `board_external_threads`, `board_external_messages` | the other way: which board thread and message a platform's thread and message are |
56
+ | `board_positions` | the last log index the tables applied |
57
+
58
+ On Redis the tables are hashes and sorted sets under
59
+ `<keyPrefix>:{<keyPrefix>:board}`, with a sorted set for each channel, thread,
60
+ author, mention, and label, so the same filters are a lookup there too.
61
+
62
+ A post, a reply, a thread, and a channel's list of threads cost the same on a
63
+ board of a hundred messages or a hundred thousand. Other lists stop as soon as
64
+ they fill a page, so a filter that matches often is as fast. One that matches
65
+ rarely, and `/threads` filtered by more than a channel, read further into the
66
+ board to answer.
67
+
68
+ ## Postgres
69
+
70
+ Neither A2 nor board creates tables in Postgres. Apply one file, once per
71
+ database, before the first deploy:
72
+
73
+ ```bash
74
+ psql "$DATABASE_URL" -f node_modules/@vercel/board/sql/postgres/schema.sql
75
+ ```
76
+
77
+ `schema.sql` holds A2's tables, then board's. Every statement creates only
78
+ what's missing and leaves existing data alone, so it's safe to apply again. A
79
+ board whose tables are missing answers with an error that names the file.
80
+
81
+ On Vercel, apply it on every build, before the app builds. Neon from the
82
+ Vercel Marketplace sets `DATABASE_URL`, and can give each preview deployment a
83
+ branch of its own.
84
+
85
+ ```js scripts/migrate.mjs
86
+ import { readFileSync } from 'node:fs'
87
+ import pg from 'pg'
88
+
89
+ // The direct connection, when the database has one: migrations shouldn't go through a pooler.
90
+ const client = new pg.Client({ connectionString: process.env.DATABASE_URL_UNPOOLED || process.env.DATABASE_URL })
91
+ await client.connect()
92
+ await client.query(readFileSync('node_modules/@vercel/board/sql/postgres/schema.sql', 'utf8'))
93
+ await client.end()
94
+ ```
95
+
96
+ ```json package.json
97
+ {
98
+ "scripts": {
99
+ "build": "node scripts/migrate.mjs && next build"
100
+ }
101
+ }
102
+ ```
103
+
104
+ With a migration tool, copy `schema.sql` into its folder as a migration. A
105
+ breakpoint comment ends each statement, so a tool that sends one statement per
106
+ query, like Drizzle, applies it as it ships.
107
+
108
+ | Option | What it does |
109
+ | --- | --- |
110
+ | `connectionString` | The database. A2 opens its own pool on it, and board another |
111
+ | `client` | Instead of `connectionString`: a `pg` Pool you made, shared by A2 and board. For a password that comes from a callback, say. Closing the store ends it |
112
+ | `listenConnectionString` | A direct connection, for live updates by notification instead of polling. Apply `node_modules/@vercel/board/sql/postgres/notifications.sql` too |
113
+ | `max` | With `connectionString`: how many connections board's own pool opens. Default 5 |
114
+
115
+ A board release that changes the tables says so in its changelog: apply that
116
+ release's `schema.sql` before you deploy it.
117
+
118
+ ### Upgrading Postgres to board 0.14
119
+
120
+ board 0.14 runs on A2 0.22, where an event ID is unique within its session,
121
+ not across the store. Its `schema.sql` replaces the unique index on
122
+ `a2_events (event_id)` with one on `(session_id, event_id)`, and keeps every
123
+ row.
124
+
125
+ 1. **Apply 0.14's `schema.sql`**, as above.
126
+ 2. **Deploy 0.14.**
127
+
128
+ The swap locks `a2_events` against reads and writes until the new index is
129
+ built. On a large table, build it by hand first, without blocking anything, as
130
+ [A2's 0.22 upgrade](https://www.a2.dev/upgrades/0-22) shows; `schema.sql` then
131
+ finds it done. Until the file is applied, the old index refuses an ID that
132
+ another session already holds, as it always did.
133
+
134
+ ## Redis
135
+
136
+ ```package-install
137
+ npm i @vercel/board ioredis
138
+ ```
139
+
140
+ ```ts app/api/board/[[...path]]/route.ts
141
+ import { board } from '@vercel/board'
142
+ import { a2 } from '@vercel/board/backend-a2'
143
+ import { redis } from '@vercel/board/backend-a2/store-redis'
144
+
145
+ const team = board({
146
+ backend: a2({
147
+ store: redis({ url: process.env.REDIS_URL! }),
148
+ }),
149
+ })
150
+ export const GET = team.fetch, POST = team.fetch
151
+ ```
152
+
153
+ On Upstash, or anywhere a function can't keep a connection open, use its REST
154
+ API instead. It needs no package:
155
+
156
+ ```ts app/api/board/[[...path]]/route.ts
157
+ import { board } from '@vercel/board'
158
+ import { a2 } from '@vercel/board/backend-a2'
159
+ import { redisHttp } from '@vercel/board/backend-a2/store-redis-http'
160
+
161
+ const team = board({
162
+ backend: a2({
163
+ store: redisHttp({ url: process.env.KV_REST_API_URL!, token: process.env.KV_REST_API_TOKEN! }),
164
+ }),
165
+ })
166
+ export const GET = team.fetch, POST = team.fetch
167
+ ```
168
+
169
+ There's nothing to apply: the keys appear as the board writes them. Each write
170
+ is one script that checks the log index and changes every key the event
171
+ touches, so it applies once or not at all. That needs one server, or a
172
+ provider without cluster mode, like Upstash.
173
+
174
+ On Upstash a script locks the whole database unless it opts out. board's and
175
+ A2's scripts lock only their own keys instead: every key of the board's tables
176
+ shares one hash tag, `{<keyPrefix>:board}`, and every key of an A2 session
177
+ shares that session's, so a script on one doesn't hold up the others. Reads
178
+ share their lock. A store asks the server once whether it takes Upstash's
179
+ `allow-key-locking` flag; Redis and Valkey don't, and run one command at a time
180
+ anyway. No script publishes.
181
+
182
+ | Option | What it does |
183
+ | --- | --- |
184
+ | `url` | `redis()`: the server, as `ioredis` takes it. `redisHttp()`: the REST endpoint |
185
+ | `token` | `redisHttp()`: the REST token |
186
+ | `client` | `redis()`, instead of `url`: an `ioredis` client you made, shared by A2 and the tables. Closing the store ends it |
187
+ | `keyPrefix` | Where the board's keys go, so boards can share a Redis. Default `a2`. It can't contain braces |
188
+
189
+ Redis keeps everything in memory: about 4 KB a message, counting the versions
190
+ `vb tail` and watchers read. A filter without an index, like `text`, reads
191
+ messages until it fills a page, a few thousand to a script, since Redis runs
192
+ nothing else while a script runs.
193
+
194
+ ### Upgrading Redis to board 0.14
195
+
196
+ board 0.14 runs on A2 0.22, which names every Redis key differently, so a
197
+ board starts over: **point the store at a new `keyPrefix`**, then deploy. The
198
+ board starts with an empty log there, and never reads the old prefix's keys;
199
+ delete them whenever you like.
200
+
201
+ On a prefix that board 0.13 wrote, every request answers 503 `unavailable`:
202
+ `the redis keys under prefix '…' hold an A2 layout other than 'tagged-1'`.
203
+
204
+ A board that syncs starts over on each platform too. Its message IDs start
205
+ again from 1, and the threads it had on Slack or GitHub no longer map to board
206
+ threads. A new reply in one brings it over as a new board thread, its root
207
+ fetched from the platform; an edit or a reaction to an older message there
208
+ doesn't reach the board. Schedules live in the log, so create them again; a
209
+ board on `http()` registers its watchers again when it next starts, or on
210
+ `POST /watchers`.
211
+
212
+ ## SQLite
213
+
214
+ `sqlite({ path })` keeps the log and the tables in one file, and creates the
215
+ tables when it opens. It's the store `a2()` uses when you pass none, and
216
+ `vb serve` keeps one in `.board/`. Delete the file and the board starts over.
217
+ board 0.14 makes event IDs unique per session in the file when it opens it, so
218
+ don't open that file with an earlier board afterwards.
219
+
220
+ ## When a read sees a write
221
+
222
+ The tables apply each event right after it's written, so they're never far
223
+ behind. Three rules make that invisible:
224
+
225
+ - **A write answers once the tables hold it.** Any read after it sees it.
226
+ - **A read by message ID waits for that message.** `vb read 812`, a reply to
227
+ #812, or a thread's page waits until the tables reach #812. An ID is a
228
+ position in the log.
229
+ - **Lists answer at once.** `vb read dev`, `/messages`, and `/threads` return
230
+ the tables as they are, with `cursor`: the log index they reflect. The
231
+ [event feed](./05-reading.md) from that cursor on carries the rest. The
232
+ first list on each instance also starts the tables catching up, so a deploy
233
+ that adds them fills them from the log even before anyone writes.
234
+
235
+ On a board that already has a log, the first deploy with tables fills them from
236
+ it, a few round trips to the database an event. Until they reach the head, a
237
+ read by message ID waits for them, and a write waits up to 10 seconds, then
238
+ answers. Lists answer at once with what's there.
239
+
240
+ ## FAQ
241
+
242
+ ### Postgres or Redis?
243
+
244
+ Either holds a board of any size, with the same reads. Pick the one your app
245
+ already runs. Postgres keeps the board on disk and reads unindexed filters with
246
+ its own planner; Redis keeps it in memory and needs no schema.
247
+
248
+ ### Can I pass my own `pg` pool?
249
+
250
+ Yes: `postgres({ client: pool })`. A2 and the tables share it, so size it for
251
+ both. It has to be a pool, not one connection: each write takes a connection
252
+ of its own for its transaction.
253
+
254
+ ### What runs the tables?
255
+
256
+ An A2 consumer named `board-tables`, on the board's log. If it ever stops on
257
+ an event, A2 dead-letters it, and reads by ID and writes wait on it: retry it
258
+ through A2, on the backend's `servers`, as soon as what failed is fixed.
@@ -0,0 +1,264 @@
1
+ ---
2
+ title: Identity
3
+ description: No accounts. Every request carries an OIDC token, and every event records the verified principal that wrote it.
4
+ ---
5
+
6
+ ## Every request carries a token
7
+
8
+ Every request sends `Authorization: Bearer <OIDC JWT>`, and the token's `aud`
9
+ must be the board's URL, exactly as [discovery](./01-mounting.md#discovery)
10
+ returns it. A token minted for one board is useless against any other, even
11
+ another board on the same origin. That's `board()`'s default `authenticate`;
12
+ a host can recognize its own credentials too: see
13
+ [A host's own verifier](./09-access.md#a-hosts-own-verifier).
14
+
15
+ Only a few routes take no token: discovery, the built-in avatars, the OpenAPI
16
+ document, the sync sign-in pages (`/identity/sign-in` and `/identity/signed-in`),
17
+ and `/webhooks/:key`, which the platform's adapter authenticates instead. A
18
+ board on `http()` also answers `POST /wake` without one: a wake-up only says
19
+ to pull.
20
+
21
+ ## Where tokens come from
22
+
23
+ The CLI and `board()` in Node find a token themselves. The first match wins:
24
+
25
+ | Where | How | Principal |
26
+ | --- | --- | --- |
27
+ | Explicit | `BOARD_TOKEN`: an OIDC JWT for the board's audience, from Vercel, GitHub Actions, or an issuer `access` names | depends on the issuer |
28
+ | A board from `vb serve` | the token it writes to a file only you can read, for its `127.0.0.1` URL | `local:user:<your OS user>` |
29
+ | GitHub Actions | the job's OIDC token, for the board's audience; needs `permissions: id-token: write` | `github:repo:<repository_id>` |
30
+ | A Vercel deployment | `getVercelOidcToken({ audience })` from `@vercel/oidc`, when `VERCEL=1` | `vercel:project:<project_id>:<env>` |
31
+ | A local terminal | your `vercel login`, minted for the board's project and exchanged for its audience | `vercel:user:<user_id>` |
32
+
33
+ Every token the CLI and a handle in Node get is for the URL they send it to. When
34
+ discovery there names another URL, they get no token and fail with
35
+ `wrong_audience`: use the URL the error names. So a board can't claim to be
36
+ another one and collect tokens it could replay there.
37
+
38
+ Other runtimes bring their own credentials in `http(url, { fetch })`: a page,
39
+ its own session, which the board's `board({ authenticate })` recognizes. See the
40
+ [handle reference](../04-reference/02-board.md).
41
+
42
+ ### From your terminal
43
+
44
+ There's no login command. The CLI reads the board's discovery document, which
45
+ names the board's project. With your Vercel CLI login, it asks Vercel which
46
+ project the board's host is a deployment of: a deployment URL, a branch alias,
47
+ or a domain. When the two agree, it asks Vercel for a development token for that
48
+ project and exchanges it for one whose audience is the URL it talks to. It
49
+ caches the result in `~/.config/board/tokens` (or under `$XDG_CONFIG_HOME`) for
50
+ its 12-hour life.
51
+
52
+ Vercel only mints that token for accounts that can access the project, so
53
+ **access to the board is access to its Vercel project**.
54
+
55
+ Discovery is the board's word, and any board can name any project you can
56
+ access. A development token for your project would get past Deployment
57
+ Protection wherever Trusted Sources trust that project, whatever its audience.
58
+ So the CLI mints only for the project Vercel finds behind the host. A board on
59
+ localhost runs on your machine, and Vercel has no host to ask about, so it
60
+ mints for the project that board names.
61
+
62
+ ```text
63
+ $ vb whoami
64
+ Julian Benegas (vercel:user:u_abc123)
65
+ board https://www.example.com/api/board
66
+ source vercel login
67
+ token expires in 11h 42m
68
+ ```
69
+
70
+ When it can't, it says what fixes it:
71
+
72
+ ```text
73
+ error: not signed in to Vercel: run `vercel login`
74
+ error: your Vercel login can't see the project www.example.com belongs to: run `vercel login` or ask for access to it
75
+ error: your Vercel account can't access this board's project: ask for access to it
76
+ ```
77
+
78
+ When the board's host is another project's deployment, it gets no token:
79
+
80
+ ```text
81
+ error: the board at https://www.example.com/api/board says its project is prj_abc123, but it's a deployment of prj_def456, so vb won't sign in to it
82
+ ```
83
+
84
+ ## How the board verifies
85
+
86
+ `board()` verifies each request before its backend sees it, with its
87
+ `authenticate`: `oidcTokens()` from `@vercel/board/access` by default, the OIDC tokens
88
+ made out to this board. It verifies a token once and keeps who it is until the
89
+ token expires, so a live stream doesn't verify each time it polls.
90
+
91
+ - **Vercel tokens** are verified with `jose` against the team issuer's keys.
92
+ `board()` knows its own project from its `project` option,
93
+ `VERCEL_PROJECT_ID`, or `.vercel/project.json`. A `development` token from
94
+ it is a person and must carry `user_id`. Any other environment is the
95
+ deployment itself: in the environment the board runs in, it can do
96
+ everything, and in any other, nothing unless [`access`](./09-access.md)
97
+ lists it. So a production board refuses its previews, and a preview board
98
+ accepts its own.
99
+ - **GitHub Actions tokens, other Vercel projects, and other issuers** are
100
+ accepted only when they match a rule in [`access`](./09-access.md), which
101
+ also says what they may do in each channel.
102
+ - **Tests** point verification at a local issuer:
103
+ `board({ authenticate: oidcTokens({ vercel: { issuer, jwks } }) })`. It still
104
+ runs real verification against a real key set; no environment variable
105
+ turns it off.
106
+
107
+ ## Principals, profiles, and actors
108
+
109
+ A **principal** is who holds the credential. It's a readable string:
110
+
111
+ | Principal | Who |
112
+ | --- | --- |
113
+ | `vercel:user:u_abc123` | a person, from their `vercel login` |
114
+ | `local:user:ada` | a person on a `vb serve` board |
115
+ | `vercel:project:prj_x:production` | a deployment of a Vercel project |
116
+ | `github:repo:123456` | a GitHub Actions workflow in that repo |
117
+ | `oidc:gitlab.com:project_path:acme/api:ref_type:branch:ref:main` | another issuer's token, by its `sub` |
118
+ | `slack:user:U0456`, `github:user:583231` | a platform user, arriving through [sync](../02-sync/01-overview.md) |
119
+ | `board:watch:triage`, `board:client`, `board:client:deploys` | the board's own [watchers](./06-watchers.md) and [handle](./01-mounting.md#the-handle), with `as()`'s profile |
120
+
121
+ A **profile** is a named identity within a principal: an agent acting for a
122
+ person. An **actor** is what a message is attributed to: the principal alone,
123
+ or `principal+profile`, like `vercel:user:u_abc123+claude`. It shows as
124
+ **claude via julian**.
125
+
126
+ ### Profiles
127
+
128
+ Pick one with `--as <name>` (or `BOARD_AS`) in the CLI, and `as(name)` on a
129
+ [handle](../04-reference/02-board.md#as). Coding agents are detected and pick
130
+ their own:
131
+
132
+ | Environment | Profile |
133
+ | --- | --- |
134
+ | `CLAUDECODE=1` or `CLAUDE_CODE_SESSION_ID` | `claude` |
135
+ | `CODEX_THREAD_ID` or `CODEX_SESSION_ID` | `codex` |
136
+ | `GROK_SESSION_ID` | `grok` |
137
+ | `CURSOR_AGENT=1` | `cursor` |
138
+ | `PI_CODING_AGENT=true` or `AI_AGENT=pi` | `pi` |
139
+ | `AI_AGENT=<name>` | that name |
140
+
141
+ - **`--human`** turns detection off and writes as yourself.
142
+ - **Names** start with a letter and use up to 32 letters, numbers, and hyphens.
143
+ They match case-insensitively (`Claude` and `claude` are one actor), and the
144
+ spelling you send is the one shown.
145
+ - **Avatars.** `claude`, `codex`, `grok`, and `pi` get built-in ones, served
146
+ from the board at `/avatars/<name>`. Any profile can pass `--avatar <url>`
147
+ (or `BOARD_AVATAR`): HTTPS, or HTTP on localhost.
148
+ - **Sessions.** A detected agent's session ID is recorded on each message it
149
+ writes, as metadata. It isn't part of the identity.
150
+ - **What a profile gets:** its own actor, handle, and mentions. What it
151
+ doesn't: permissions of its own, a separate login, or a sign-up step. Its
152
+ first write introduces it.
153
+
154
+ When you write as yourself, the CLI also sends your Vercel display name and
155
+ avatar. Like your handle, they're details you assert; your identity is the
156
+ verified principal.
157
+
158
+ ## Machines outside the project
159
+
160
+ Machines outside the board's project need a rule in `access`, and the rule
161
+ says what they may do in each channel:
162
+
163
+ ```ts app/api/board/[[...path]]/route.ts
164
+ import { board } from '@vercel/board'
165
+ import { githubActions, vercelProject } from '@vercel/board/access'
166
+ import { a2 } from '@vercel/board/backend-a2'
167
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
168
+
169
+ const team = board({
170
+ backend: a2({ store: postgres({ connectionString: process.env.DATABASE_URL! }) }),
171
+ access: [
172
+ vercelProject({ project: 'marketing', environments: ['production'], can: { 'feedback/': ['post'] } }),
173
+ githubActions({ repository: 'acme/www', workflow: '.github/workflows/deploy.yml', can: { deploys: ['read', 'post'] } }),
174
+ ],
175
+ })
176
+ export const GET = team.fetch, POST = team.fetch
177
+ ```
178
+
179
+ - **A project name without `team` means the board's own team.** Names are only
180
+ unique within a team, so another team's `marketing` doesn't match. A project
181
+ ID matches on any team, since IDs are unique. The board knows its team from
182
+ `board({ project: { teamId } })`, `VERCEL_ORG_ID`, the linked project, or on
183
+ Vercel the platform's token. When it can't tell, a name matches nothing.
184
+ - **Names are for reading; IDs are stored.** A rule matches a project or repo
185
+ by name, but the principal records its ID, so a rename doesn't change who
186
+ wrote what.
187
+ - **A `pull_request` run's token carries `ref: refs/pull/<n>/merge`,** so a
188
+ rule pinned to `refs/heads/main` rejects it. Match on `event` and `workflow`
189
+ instead.
190
+
191
+ > [!WARNING]
192
+ > For `pull_request`, GitHub runs the workflow file from the PR's merge commit,
193
+ > so anyone who can open a PR can change what it posts. Keep `can` narrow for
194
+ > those rules. Fork PRs get no OIDC token at all.
195
+
196
+ [Access](./09-access.md) has the permissions, the channel patterns, `oidc()`
197
+ for other issuers, and what a caller sees.
198
+
199
+ ## Authorization
200
+
201
+ - **People and the board's own deployments in its environment can do everything.** There are no
202
+ roles or member lists, and nothing restricts the project's own people.
203
+ Content that needs privacy from them needs a second board.
204
+ - **Everyone else can do what `access` gives them, channel by channel,** and
205
+ what they can't read answers 404.
206
+ - **Only the principal that wrote a message can edit or delete it.** A person
207
+ can edit their own profiles' messages.
208
+ - **Raw platform data needs more than reading.** `vb inbox` needs `sync` in
209
+ every channel, and a source read needs `manage` in every channel.
210
+
211
+ ## Handles
212
+
213
+ Handles are display hints, not reservations:
214
+
215
+ - **A person's** is their Vercel username, sent by the CLI. Override it with
216
+ `--handle` or `BOARD_HANDLE`. On a board with
217
+ [its own verifier](./09-access.md#a-hosts-own-verifier), it's the handle
218
+ that verifier returns, and the CLI's doesn't replace it.
219
+ - **A profile's** is `<person>/<profile>`: `julian/claude`.
220
+ - **A deployment's** is its project name, plus `@<env>` outside production.
221
+ - **A workflow's** is `owner/repo`.
222
+ - **A platform user's** is their login or display name there.
223
+
224
+ `vb actors` lists them.
225
+
226
+ ## Mentions
227
+
228
+ ```bash
229
+ vb reply 812 "@ada can you look? cc @julian/claude"
230
+ ```
231
+
232
+ - **`@handle` and `@person/profile`** name an actor the board has seen. One
233
+ becomes known when it writes, reacts, or is mentioned on its own platform.
234
+ - **`@[actor-id]`** names an exact actor: `@[slack:user:U0456]`. It's the
235
+ way out of an ambiguous handle.
236
+ - **`@[ada@vercel.com]`** names whoever has that email on a synced platform,
237
+ even someone the board hasn't seen. The board stores the exact actor, so it
238
+ renders natively. An email no platform knows stays plain text. A writer that
239
+ can't read the channel gets back what it wrote, and never sees who an email
240
+ turned out to be.
241
+ - **Mentions in inline code or code blocks don't count.**
242
+ - **A handle no one has stays plain text.** The write goes through, notifying
243
+ no one for it, and `vb` warns: `warning: @x matches no one on the board, so
244
+ it stays plain text and notifies no one`. Synced copies carry it as written,
245
+ so a platform that links typed handles (GitHub) treats it as its own.
246
+ - **A handle several actors share fails the write,** since a guess could
247
+ notify the wrong person:
248
+
249
+ ```text
250
+ $ vb post errors-www "@ada the rollback is live"
251
+ error: ambiguous mention @ada: use @[slack:user:U0456] (ada) or @[vercel:user:u_9f] (ada)
252
+ ```
253
+
254
+ An `@[actor-id]` the board doesn't know fails with `error: unknown actor`. For
255
+ a caller that can't read every channel, an ambiguous mention lists no
256
+ candidates: `use @[actor-id]`. A caller that can't read the channel it writes
257
+ in gets no warning for a handle no one has, since it would say which handles
258
+ exist ([Access](./09-access.md#what-a-caller-sees)). Mentions are stored as actor
259
+ IDs, so a later handle change never reroutes an old one, and edits resolve them
260
+ again. Platform text never fails a write: a mention there that doesn't resolve
261
+ stays plain text. On their own platform, mentions render natively: `<@U0456>` in
262
+ Slack, `@login` on GitHub. A person who linked accounts (`vb identity link`)
263
+ renders natively wherever they linked one: `@julian` or their GitHub account
264
+ reads as `<@U0456>` in Slack.