@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.
- package/README.md +180 -0
- package/dist/a2-C41hD8SF.mjs +3045 -0
- package/dist/a2-D24cvwpN.d.mts +3359 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +30 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-DjMeZpnU.mjs +224 -0
- package/dist/attempts-Bxp9KgPf.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-CU56Q2Xh.d.mts +204 -0
- package/dist/backend-a2/blob-vercel.d.mts +8 -0
- package/dist/backend-a2/blob-vercel.mjs +81 -0
- package/dist/backend-a2/contract.d.mts +18 -0
- package/dist/backend-a2/contract.mjs +5 -0
- package/dist/backend-a2/scheduler-local.d.mts +11 -0
- package/dist/backend-a2/scheduler-local.mjs +2 -0
- package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
- package/dist/backend-a2/scheduler-qstash.mjs +2 -0
- package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
- package/dist/backend-a2/scheduler-vercel.mjs +2 -0
- package/dist/backend-a2/store-memory.d.mts +11 -0
- package/dist/backend-a2/store-memory.mjs +19 -0
- package/dist/backend-a2/store-postgres.d.mts +38 -0
- package/dist/backend-a2/store-postgres.mjs +110 -0
- package/dist/backend-a2/store-redis-http.d.mts +17 -0
- package/dist/backend-a2/store-redis-http.mjs +32 -0
- package/dist/backend-a2/store-redis.d.mts +23 -0
- package/dist/backend-a2/store-redis.mjs +39 -0
- package/dist/backend-a2/store-sqlite.d.mts +12 -0
- package/dist/backend-a2/store-sqlite.mjs +31 -0
- package/dist/backend-a2.d.mts +4 -0
- package/dist/backend-a2.mjs +2 -0
- package/dist/backend-http-0roEmjl0.mjs +613 -0
- package/dist/backend-http-BVRno-ca.mjs +469 -0
- package/dist/backend-http-Bid7RIpC.d.mts +630 -0
- package/dist/backend-http.d.mts +10 -0
- package/dist/backend-http.mjs +2 -0
- package/dist/backend-http.node.d.mts +10 -0
- package/dist/backend-http.node.mjs +2 -0
- package/dist/blob-C0J3wbGv.d.mts +39 -0
- package/dist/bots-CGkO76qH.mjs +67 -0
- package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
- package/dist/chat-provider-jFSeyNmB.mjs +389 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2252 -0
- package/dist/client-DGDPaN7U.mjs +380 -0
- package/dist/conformance-DRR23YNy.mjs +512 -0
- package/dist/conformance.d.mts +30 -0
- package/dist/conformance.mjs +2 -0
- package/dist/contract-CuJmlrhw.mjs +566 -0
- package/dist/emoji-CTLpvH-A.mjs +445 -0
- package/dist/engine-Bir5s8ts.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-DLiE7fLY.d.mts +4 -0
- package/dist/exports-Dvc1o52I.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-Bkg77Pxn.mjs +12 -0
- package/dist/index.mjs +19 -0
- package/dist/index.node.d.mts +14 -0
- package/dist/index.node.mjs +5 -0
- package/dist/inspect/app.js +416 -0
- package/dist/inspect/engine.js +5 -0
- package/dist/local-Cr_dSjvs.mjs +44 -0
- package/dist/own-OwgPWdmh.mjs +15 -0
- package/dist/project-CFiArraX.mjs +124 -0
- package/dist/provider-BGjz-mDa.d.mts +2473 -0
- package/dist/provider-CuewcDNZ.mjs +205 -0
- package/dist/redis-tables-B35vv4CZ.mjs +842 -0
- package/dist/reducers-B_vUGQRp.d.mts +1172 -0
- package/dist/reducers-mUPFuFJV.mjs +293 -0
- package/dist/schemas-Be08nyfW.mjs +74 -0
- package/dist/schemas-CWxs3ECJ.d.mts +50 -0
- package/dist/serve-BsOOc-6_.mjs +170 -0
- package/dist/server-jwPY7_wi.mjs +17 -0
- package/dist/serving-BNbzc96L.mjs +2 -0
- package/dist/sql-DbiEnqM0.mjs +571 -0
- package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
- package/dist/sync-github.d.mts +473 -0
- package/dist/sync-github.mjs +603 -0
- package/dist/sync-kapso.d.mts +175 -0
- package/dist/sync-kapso.mjs +86 -0
- package/dist/sync-slack.d.mts +364 -0
- package/dist/sync-slack.mjs +728 -0
- package/dist/sync.chat.d.mts +2 -0
- package/dist/sync.chat.mjs +2 -0
- package/dist/sync.d.mts +3 -0
- package/dist/sync.mjs +4 -0
- package/dist/targets-yM5JBUgr.mjs +94 -0
- package/dist/tokens-DKLn5P9B.mjs +256 -0
- package/dist/types-BHCjTZdB.d.mts +104 -0
- package/dist/users-Dls7_ANV.mjs +42 -0
- package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
- package/dist/watchers-hash-CLjsa6od.mjs +225 -0
- package/docs/01-board/01-mounting.md +506 -0
- package/docs/01-board/02-database.md +258 -0
- package/docs/01-board/03-identity.md +264 -0
- package/docs/01-board/04-messages.md +192 -0
- package/docs/01-board/05-reading.md +177 -0
- package/docs/01-board/06-watchers.md +249 -0
- package/docs/01-board/07-schedules.md +133 -0
- package/docs/01-board/08-middleware.md +139 -0
- package/docs/01-board/09-access.md +501 -0
- package/docs/01-quickstart.md +196 -0
- package/docs/02-sync/01-overview.md +290 -0
- package/docs/02-sync/02-callbacks.md +265 -0
- package/docs/02-sync/03-slack.md +248 -0
- package/docs/02-sync/04-github.md +201 -0
- package/docs/02-sync/05-whatsapp.md +148 -0
- package/docs/02-sync/06-providers.md +228 -0
- package/docs/03-guides/01-coding-agents.md +226 -0
- package/docs/03-guides/02-inspector.md +104 -0
- package/docs/03-guides/03-ci.md +251 -0
- package/docs/03-guides/04-triage.md +233 -0
- package/docs/03-guides/05-agent-factory.md +142 -0
- package/docs/04-reference/01-cli.md +606 -0
- package/docs/04-reference/02-board.md +579 -0
- package/docs/04-reference/03-http.md +489 -0
- package/docs/04-reference/04-log.md +250 -0
- package/docs/04-reference/05-conformance.md +97 -0
- package/docs/index.md +186 -0
- package/package.json +168 -1
- package/sql/postgres/board.sql +145 -0
- package/sql/postgres/notifications.sql +24 -0
- 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.
|