@vercel/board 0.0.0 → 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.
- package/README.md +180 -0
- package/dist/a2-5EG8FkZ8.d.mts +3359 -0
- package/dist/a2-DYGYankn.mjs +3045 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +29 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-rdN4dDTU.mjs +223 -0
- package/dist/attempts-BQUp9MKO.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-wHFcAte3.d.mts +203 -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-8ex5g7Wv.d.mts +630 -0
- package/dist/backend-http-BsDSgmeo.mjs +613 -0
- package/dist/backend-http-Cv6xbEy9.mjs +469 -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-3EvQ8idk.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-BpgyEdh6.d.mts +4 -0
- package/dist/exports-CyY3_FjZ.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-BZ3lItjH.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-Ccbb2LSb.mjs +170 -0
- package/dist/server-Cxey38B-.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-ojFEXEyN.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-D9hbHLKB.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 +262 -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 +499 -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,499 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Access
|
|
3
|
+
description: "The board's code says what each caller may do in each channel: read, post, react, label, sync, or manage. Anything out of reach answers 404."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Who gets what
|
|
7
|
+
|
|
8
|
+
Every caller is an OIDC principal. `board()` verifies it (see
|
|
9
|
+
[Identity](./03-identity.md)) and says what it may do, `board({ authenticate,
|
|
10
|
+
access })`, then calls its backend with the caller it verified. The policy is
|
|
11
|
+
the board's on every backend, and the backend only stores. Some get everything
|
|
12
|
+
without asking; the rest get what `access` gives them:
|
|
13
|
+
|
|
14
|
+
| Caller | Default |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| A person: your `vercel login`, or `vb serve`'s local user | everything |
|
|
17
|
+
| The board's own deployments in the environment it runs in | everything |
|
|
18
|
+
| The board's own deployments in any other environment | nothing |
|
|
19
|
+
| Another Vercel project, a GitHub Actions workflow, any other OIDC issuer | nothing |
|
|
20
|
+
| The board's own code: watchers, sync, middleware, its [handle](./01-mounting.md#the-handle) | everything |
|
|
21
|
+
|
|
22
|
+
A caller no rule matches is refused with 403, whatever it asks.
|
|
23
|
+
|
|
24
|
+
The board's environment is the one Vercel runs it in (`VERCEL_TARGET_ENV`,
|
|
25
|
+
which names custom environments, or `VERCEL_ENV`). A production board trusts
|
|
26
|
+
its production and refuses its previews, which run any branch's code. A
|
|
27
|
+
preview board trusts its own previews, so a preview can call itself. Outside
|
|
28
|
+
Vercel, only people get in without a rule.
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
error: this token can't use this board: it runs in production, so its preview deployments need vercelProject({ project: 'self', environments: ['preview'], can })
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A token no rule matches is refused the same way, with the rule that would let
|
|
35
|
+
it in: `error: this token can't use this board: add githubActions({ repository:
|
|
36
|
+
'acme/www', can }) to access`.
|
|
37
|
+
|
|
38
|
+
## Configure it
|
|
39
|
+
|
|
40
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
41
|
+
import { board } from '@vercel/board'
|
|
42
|
+
import { githubActions, oidc, vercelProject } from '@vercel/board/access'
|
|
43
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
44
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
45
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
46
|
+
|
|
47
|
+
const backend = a2({
|
|
48
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
49
|
+
scheduler: vercelQueues({ topic: 'board' }),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const team = board({
|
|
53
|
+
backend,
|
|
54
|
+
access: [
|
|
55
|
+
// front's feedback intake posts and never reads.
|
|
56
|
+
vercelProject({ project: 'prj_front', can: { 'feedback/': ['post'] } }),
|
|
57
|
+
// CI reports in ci/ and waits for replies there.
|
|
58
|
+
githubActions({ repository: 'acme/web', workflow: '.github/workflows/ci.yml', can: { 'ci/': ['read', 'post'] } }),
|
|
59
|
+
// Any other OIDC issuer: pinned here, matched on exact claims.
|
|
60
|
+
oidc({ issuer: 'https://gitlab.com', claims: { project_path: 'acme/api' }, can: { 'ci/': ['post'] } }),
|
|
61
|
+
// On a production board, this project's previews read dev/ and nothing else.
|
|
62
|
+
vercelProject({ project: 'self', environments: ['preview'], can: { 'dev/': ['read'] } }),
|
|
63
|
+
],
|
|
64
|
+
})
|
|
65
|
+
export const GET = team.fetch, POST = team.fetch
|
|
66
|
+
export const { scheduler } = backend
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- **The first rule that matches decides.** Order them from most specific to
|
|
70
|
+
least.
|
|
71
|
+
- **`vercelProject({ project, team?, environments?, can })`** matches a
|
|
72
|
+
project's deployments by name or ID. `project: 'self'` is the board's own
|
|
73
|
+
project. A rule for the board's own environment replaces its default of
|
|
74
|
+
everything, and one for any other environment lets it in. Its development
|
|
75
|
+
tokens are people, which no rule changes. Since `self` means the board's
|
|
76
|
+
project, name a project that's really called `self` by its ID.
|
|
77
|
+
- **`githubActions({ repository, event?, workflow?, ref?, can })`** matches a
|
|
78
|
+
workflow run. See [CI](../03-guides/03-ci.md) for what a `pull_request` run's
|
|
79
|
+
token carries.
|
|
80
|
+
- **`oidc({ issuer, claims, can })`** matches any other issuer's tokens:
|
|
81
|
+
GitLab, Buildkite, a cloud's workload identity. The issuer is an https URL
|
|
82
|
+
pinned in code, and the board fetches keys only for issuers a rule names.
|
|
83
|
+
Every claim you list must match exactly (a list is any of its values), and
|
|
84
|
+
you list at least one, since an issuer signs tokens for everyone it serves.
|
|
85
|
+
A claim with no value, such as an unset environment variable, throws when
|
|
86
|
+
the rule is built rather than matching everyone. The principal is
|
|
87
|
+
`oidc:<issuer host and path>:<sub>`, with a `:` or `%` in the issuer part
|
|
88
|
+
percent-encoded (`oidc:ci.test%3A8443:<sub>`). Two rules whose issuers
|
|
89
|
+
differ only by a trailing slash, the host's case, or a default port would
|
|
90
|
+
name the same principals, so the board throws when it starts. `board()`
|
|
91
|
+
verifies these issuers' tokens because its rules name them.
|
|
92
|
+
- **A function is a rule of your own:** `(principal) => can` gives what a
|
|
93
|
+
rule's `can` takes (`'all'`, `['read']`, `{ 'dev/': ['post'] }`), null for
|
|
94
|
+
nothing, or undefined to leave the principal to the next rule. The token
|
|
95
|
+
rules match only tokens, so a function is how principals your own verifier
|
|
96
|
+
returns get anything (see [below](#a-hosts-own-verifier)). It's asked again
|
|
97
|
+
whenever a principal acts, a schedule's send and a registered watcher's
|
|
98
|
+
match included. The defaults come first: it's never asked about a person, or
|
|
99
|
+
the board's own deployments in its environment.
|
|
100
|
+
- **It holds outside requests too.** A schedule sends only while its creator
|
|
101
|
+
may post there, a watcher registered from another board matches only what
|
|
102
|
+
its registrant may read at each event, and the middleware sees every write
|
|
103
|
+
that reaches the backend, its own code's included.
|
|
104
|
+
|
|
105
|
+
## A board on `http()`
|
|
106
|
+
|
|
107
|
+
A board on `http()` keeps no log, and with no `access` of its own it serves no
|
|
108
|
+
requests but its wake-ups, webhooks, and its sync's sign-ins. With one, it
|
|
109
|
+
serves its own callers as a gateway to the board it uses: it verifies and
|
|
110
|
+
limits them, then acts on that board as its project.
|
|
111
|
+
|
|
112
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
113
|
+
import { board } from '@vercel/board'
|
|
114
|
+
import { http } from '@vercel/board/backend-http'
|
|
115
|
+
import type { CallerRequest } from '@vercel/board/access'
|
|
116
|
+
|
|
117
|
+
/** Whoever the app's session cookie names, or null for a request without one. */
|
|
118
|
+
async function session(request: CallerRequest) {
|
|
119
|
+
// const user = await readSession(request.headers.get('cookie')) // yours
|
|
120
|
+
const user = null as { id: string; username: string } | null
|
|
121
|
+
return user ? ({ id: `support:user:${user.id}`, kind: 'person', handle: user.username } as const) : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const gateway = board({
|
|
125
|
+
backend: http(process.env.TEAM_BOARD_URL),
|
|
126
|
+
authenticate: session,
|
|
127
|
+
access: (principal) => (principal.kind === 'person' ? { 'support/': ['read', 'post'] } : null),
|
|
128
|
+
})
|
|
129
|
+
export const GET = gateway.fetch
|
|
130
|
+
// A cookie rides along on other sites' requests: see the next section.
|
|
131
|
+
export const POST = async (request: Request) => {
|
|
132
|
+
if (request.headers.has('cookie') && request.headers.get('origin') !== 'https://support.example.com') return new Response('forbidden', { status: 403 })
|
|
133
|
+
return gateway.fetch(request)
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The policies stack: yours decides what your users may do, and the team's board
|
|
138
|
+
decides what your project may do, so a user gets only what both allow. The
|
|
139
|
+
team's board sees the project, labeled with who it was: Ada via support.
|
|
140
|
+
|
|
141
|
+
- **It never vouches for a person.** Each call goes with the project's
|
|
142
|
+
credentials, with the caller's handle as its profile (`ada`, shown as
|
|
143
|
+
`support/ada`), a label under the project and never who it is there, and its
|
|
144
|
+
access as [`x-board-scope`](#narrowing-a-token). A handle that's one of this
|
|
145
|
+
board's watchers' IDs gets a number (`fix-2`), since a watcher's own writes
|
|
146
|
+
never wake it.
|
|
147
|
+
- **What's the project's own there, its callers can't do.** The team's board
|
|
148
|
+
sees one principal, so editing, deleting, scheduling, and uploading, which
|
|
149
|
+
go by it (a file is its uploader's until a message carries it), and the
|
|
150
|
+
project's registration, sync, and links answer 403. Posts, replies,
|
|
151
|
+
reactions, labels, and reads go through, files in messages a caller can read
|
|
152
|
+
included. A post's `id` is its caller's own.
|
|
153
|
+
- **`GET /me` answers who the caller is here,** as this board verified it.
|
|
154
|
+
|
|
155
|
+
## A host's own verifier
|
|
156
|
+
|
|
157
|
+
`board({ authenticate })` says who a request is. It's one function,
|
|
158
|
+
`oidcTokens()` from `@vercel/board/access` by default, and composing verifiers is
|
|
159
|
+
plain code. Each answers null when the request carries nothing it recognizes,
|
|
160
|
+
so the next one gets it, and a 401 when it carries a credential that fails. A
|
|
161
|
+
board on an app's own site can take the app's session cookie, from its staff
|
|
162
|
+
and its customers, and `vb`'s tokens, from people, agents, and deployments:
|
|
163
|
+
|
|
164
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
165
|
+
import { board } from '@vercel/board'
|
|
166
|
+
import { vercelProject, oidcTokens, type CallerRequest } from '@vercel/board/access'
|
|
167
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
168
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
169
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
170
|
+
|
|
171
|
+
const tokens = oidcTokens()
|
|
172
|
+
|
|
173
|
+
/** Whoever the app's session cookie names, or null for a request without one. */
|
|
174
|
+
async function session(request: CallerRequest) {
|
|
175
|
+
// const user = await readSession(request.headers.get('cookie')) // yours
|
|
176
|
+
const user = null as { id: string; username: string; staff: boolean } | null
|
|
177
|
+
if (!user) return null
|
|
178
|
+
return user.staff
|
|
179
|
+
? ({ id: `vercel:user:${user.id}`, kind: 'person', handle: user.username } as const)
|
|
180
|
+
: ({ id: `support:customer:${user.id}`, kind: 'external', handle: user.username } as const)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const backend = a2({
|
|
184
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
185
|
+
scheduler: vercelQueues({ topic: 'board' }),
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
export const team = board({
|
|
189
|
+
backend,
|
|
190
|
+
authenticate: async (request) => (await session(request)) ?? (await tokens(request)),
|
|
191
|
+
access: [
|
|
192
|
+
vercelProject({ project: 'prj_front', can: { 'feedback/': ['post'] } }),
|
|
193
|
+
// Customers read and write in support/, and nowhere else.
|
|
194
|
+
(principal) => (principal.id.startsWith('support:customer:') ? { 'support/': ['read', 'post'] } : undefined),
|
|
195
|
+
],
|
|
196
|
+
})
|
|
197
|
+
export const GET = team.fetch
|
|
198
|
+
export const POST = async (request: Request) => {
|
|
199
|
+
if (request.headers.has('cookie') && request.headers.get('origin') !== 'https://support.example.com') return new Response('forbidden', { status: 403 })
|
|
200
|
+
return team.fetch(request)
|
|
201
|
+
}
|
|
202
|
+
export const { scheduler } = backend
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A browser sends the cookie with requests that other sites' pages make, so the
|
|
206
|
+
route checks the `Origin` of every write that carries one. A bearer token
|
|
207
|
+
doesn't ride along like that, so `vb`, deployments, and webhooks need no such
|
|
208
|
+
check. board ships no helper for it: it's the route's to say which pages may
|
|
209
|
+
write.
|
|
210
|
+
|
|
211
|
+
- **A verifier gets what the request said:** `credential`, its bearer token or
|
|
212
|
+
null, `headers`, for a cookie, `audience`, the board's URL, `claims`, what it
|
|
213
|
+
claims for itself, and `board`, what the board knows that verifying needs
|
|
214
|
+
(its project, and the issuers its rules trust), which `oidcTokens()` reads.
|
|
215
|
+
- **A host's own returns the principal:** `id` in the board's scheme
|
|
216
|
+
(`vercel:user:<id>`, `vercel:project:<id>:<env>`, …), `kind`, `handle`, and
|
|
217
|
+
optionally `expiresAt`. The handle, name, profile, and `x-board-scope` the
|
|
218
|
+
caller sends still apply on top of it.
|
|
219
|
+
- **`kind: 'person'` is a full member of the board:** it may do everything,
|
|
220
|
+
as the board's own people do, whatever the rules say. So your verifier
|
|
221
|
+
decides who counts as one. Staff are people here; customers are `external`.
|
|
222
|
+
- **Anyone else gets what the first rule that matches gives,** and only a
|
|
223
|
+
function matches a host's IDs, like `support:customer:99`. One that no rule
|
|
224
|
+
matches is refused with 403. One whose `expiresAt` has passed is refused,
|
|
225
|
+
and a schedule it made is canceled `revoked`.
|
|
226
|
+
- **Name your principals in a scheme no sync registers.** A sync writes as
|
|
227
|
+
the users of the platforms it registers, `support:…` for a platform named
|
|
228
|
+
`support`, and the board tells a host's principal from them only once that
|
|
229
|
+
principal has written or registered watchers. So if a sync could register
|
|
230
|
+
platform `support`, `support:customer:99` is one of its users until that
|
|
231
|
+
customer first writes. Give `sync` carefully: see
|
|
232
|
+
[what `sync` grants](../04-reference/03-http.md#what-sync-grants).
|
|
233
|
+
- **To reject, throw an `HttpError` or return or throw a `Response`.** The
|
|
234
|
+
board sends that response as is. Anything else thrown is a 500, so turn a
|
|
235
|
+
bad credential into a 401 yourself.
|
|
236
|
+
- **It's asked once per request,** however many times a stream polls, and a
|
|
237
|
+
token `oidcTokens()` verified is kept until it expires. What `access` gives
|
|
238
|
+
is worked out again for each page a stream sends.
|
|
239
|
+
|
|
240
|
+
A host that serves many teams' boards from one deployment can't use the
|
|
241
|
+
built-in verifier at all: its callers can't access the host's project, and
|
|
242
|
+
every team's board would trust the host's own deployments. Its verifier
|
|
243
|
+
replaces `oidcTokens()`, and a function as `access` says what each principal
|
|
244
|
+
may do:
|
|
245
|
+
|
|
246
|
+
```ts lib/boards.ts
|
|
247
|
+
import { board } from '@vercel/board'
|
|
248
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
249
|
+
import { vercelBlob } from '@vercel/board/backend-a2/blob-vercel'
|
|
250
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
251
|
+
import { redis } from '@vercel/board/backend-a2/store-redis'
|
|
252
|
+
|
|
253
|
+
/** One team's board. Make each once, and cache it. */
|
|
254
|
+
export function teamBoard(teamId: string) {
|
|
255
|
+
return board({
|
|
256
|
+
backend: a2({
|
|
257
|
+
store: redis({ url: process.env.REDIS_URL!, keyPrefix: `board:${teamId}:` }),
|
|
258
|
+
scheduler: vercelQueues({ topic: `board-${teamId}` }),
|
|
259
|
+
blob: vercelBlob({ prefix: teamId }),
|
|
260
|
+
}),
|
|
261
|
+
authenticate: async (request) => {
|
|
262
|
+
if (request.credential === null) return null
|
|
263
|
+
// const caller = await verifyPlatformToken(request.credential) // yours
|
|
264
|
+
const caller = null as { teamId: string; userId: string; username: string; expiresAt: Date } | null
|
|
265
|
+
if (caller?.teamId !== teamId) return new Response(null, { status: 404 })
|
|
266
|
+
return { id: `vercel:user:${caller.userId}`, kind: 'person', handle: caller.username, expiresAt: caller.expiresAt }
|
|
267
|
+
},
|
|
268
|
+
// access: async (principal) => canOf(teamId, principal.id), // yours
|
|
269
|
+
access: async (principal) => (principal.kind === 'person' ? 'all' : null),
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- **A function as `access` decides for everyone** it's asked about, people
|
|
275
|
+
and the board's own deployments too, as a rule's `can` (`'all'`, `['read']`,
|
|
276
|
+
`{ 'dev/': ['post'] }`), or null for nothing. It's asked again whenever a
|
|
277
|
+
principal acts, a schedule's send included, so what it says now is what
|
|
278
|
+
holds.
|
|
279
|
+
- **Nothing else is trusted.** Without `oidcTokens()` in its verifier, no
|
|
280
|
+
people, own-project deployments, audience check, or platform token header
|
|
281
|
+
count. A board whose verifier is `oidcTokens()` alone takes no host's
|
|
282
|
+
principals: schedules one made before the board switched are revoked. The
|
|
283
|
+
board's own code's (its handle's, watchers') keep sending.
|
|
284
|
+
- **One board per team.** Create a `board()` for each team and cache them.
|
|
285
|
+
Give each its own store prefix, scheduler topic, and attachments prefix, so
|
|
286
|
+
no team's board reads another's log or files. On a scheduler like
|
|
287
|
+
`vercelQueues()`, a board holds no timers between requests, so dropping an
|
|
288
|
+
evicted one is enough once you close its store (`close()`), which ends the
|
|
289
|
+
connections it opened.
|
|
290
|
+
|
|
291
|
+
## A backend of your own
|
|
292
|
+
|
|
293
|
+
`board()` hands every backend a caller it verified, `{ identity, origin }`, or
|
|
294
|
+
its own code, `{ self, origin }`. A backend of your own trusts the first, since
|
|
295
|
+
only `board()` makes one, and honors what it may do in each channel:
|
|
296
|
+
|
|
297
|
+
```ts lib/backend.ts
|
|
298
|
+
import { HttpError, type Caller } from '@vercel/board'
|
|
299
|
+
|
|
300
|
+
/** What a caller may read: every method that shows messages asks. */
|
|
301
|
+
export function readable(caller: Caller, channel: string) {
|
|
302
|
+
// This deployment's own code: yours to decide.
|
|
303
|
+
if (!('identity' in caller)) return true
|
|
304
|
+
return caller.identity.principal.access.readable(channel)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function mayPost(caller: Caller, channel: string) {
|
|
308
|
+
if ('identity' in caller && !caller.identity.principal.access.can('post', channel)) throw new HttpError(403, 'forbidden', `this token can't post in ${channel}`)
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`identity` is the principal with its `access`, the author record its writes
|
|
313
|
+
carry, and the actor it writes as. What a backend stores for later, a
|
|
314
|
+
schedule's creator or a registrant, it asks `board()` about again through its
|
|
315
|
+
binding's `accessNow(principal)`, and it writes for someone outside a request
|
|
316
|
+
through the binding's `handle(caller)`, so the middleware sees it.
|
|
317
|
+
`@vercel/board/access` has the pieces `board()` uses:
|
|
318
|
+
|
|
319
|
+
| Export | What it does |
|
|
320
|
+
| --- | --- |
|
|
321
|
+
| `vercelProject()`, `githubActions()`, `oidc()` | The rules, as above |
|
|
322
|
+
| `oidcTokens(options?)` | An `authenticate`: the OIDC tokens made out to this board. It verifies a request's bearer token for its audience, from Vercel, GitHub Actions, an issuer `board.issuers` names, or `local` (`vb serve`'s). Null with no token, a 401 for a bad one. Without `board`, the project is the environment's. `board()`'s default |
|
|
323
|
+
| `resolveActor(principal, claims, audience)` | The actor a caller writes as: `vercel:user:u_123+claude` for a profile, a person's handle and name from their claims |
|
|
324
|
+
| `compileRules(rules, { project })` | What a principal may do under `rules`, past the defaults, narrowed by its scope: `can(permission, channel)`, `readable(channel)`, `everywhere(permission)`, and `summary()` for `me`. A 401 or 403 that names the rule that would let it in |
|
|
325
|
+
|
|
326
|
+
## Permissions
|
|
327
|
+
|
|
328
|
+
| Permission | Allows | Implies |
|
|
329
|
+
| --- | --- | --- |
|
|
330
|
+
| `read` | messages, threads, `/events` (tail, wait, poll), schedules, the channel in `vb channels`, attachments on readable messages | |
|
|
331
|
+
| `post` | start a thread; edit and delete your own messages; upload; schedule a root | |
|
|
332
|
+
| `react` | add and remove reactions | `read` |
|
|
333
|
+
| `label` | set and unset labels, including labels on a post | `read` |
|
|
334
|
+
| `sync` | attach a thread to a Slack or GitHub thread, including `--sync` on a post; speak for platform users there, through [sync's routes](../04-reference/03-http.md#syncs-routes); in every channel, the board's own inbox | `read` |
|
|
335
|
+
| `manage` | cancel anyone's schedule there; in every channel, source reads | everything |
|
|
336
|
+
|
|
337
|
+
- **A reply needs `post` and `read`** in its thread's channel. Addressing a
|
|
338
|
+
thread you can't read would confirm it exists. So `['post']` alone starts
|
|
339
|
+
threads and fixes what it wrote, which is what a feedback intake or a CI
|
|
340
|
+
reporter does.
|
|
341
|
+
- **The board's bookkeeping needs every channel:** a source read needs
|
|
342
|
+
`manage` in all of them, since the app's token reads past any one, and its
|
|
343
|
+
inbox needs `sync` in all of them, since raw events span them.
|
|
344
|
+
`{ '*': 'all', 'security/': [] }` can do neither.
|
|
345
|
+
- **`sync` speaks for platform users.** A caller with it in a channel can write
|
|
346
|
+
there as any Slack, GitHub, or WhatsApp user it names, which is what a board
|
|
347
|
+
on `http()` that syncs needs on the board it uses. Who those users are (name,
|
|
348
|
+
handle, avatar) shows in every channel, so only the board's own sync, or one
|
|
349
|
+
with `sync` in every channel, changes that: anyone else's imports name their
|
|
350
|
+
author and leave the record be. Give it only to the deployment that runs the
|
|
351
|
+
sync: see [what `sync` grants](../04-reference/03-http.md#what-sync-grants).
|
|
352
|
+
- **`sync` attaches only a platform thread no other board thread has.** Taking
|
|
353
|
+
one over would send its replies to a channel its readers may not be in, so
|
|
354
|
+
it's a 409 with `already_synced`. A caller that reads the other thread's
|
|
355
|
+
channel is told which thread it is. Imports and delivery receipts hold to it
|
|
356
|
+
too: neither can say a message came from, or landed in, a platform thread or
|
|
357
|
+
message another board thread or message has.
|
|
358
|
+
|
|
359
|
+
## Channels
|
|
360
|
+
|
|
361
|
+
`can` takes `'all'`, a list, or a map from pattern to permissions:
|
|
362
|
+
|
|
363
|
+
| Pattern | Covers |
|
|
364
|
+
| --- | --- |
|
|
365
|
+
| `*` | every channel, including ones that don't exist yet |
|
|
366
|
+
| `feedback` | the channel `feedback` |
|
|
367
|
+
| `feedback/` | `feedback` and everything under it, like `vb tail feedback/` |
|
|
368
|
+
|
|
369
|
+
- **The most specific pattern decides:** an exact channel, then the longest
|
|
370
|
+
subtree, then `*`. `{ '*': 'all', 'security/': [] }` hides one subtree.
|
|
371
|
+
- **A list means every channel:** `can: ['post']` is `{ '*': ['post'] }`.
|
|
372
|
+
- **Any other `*` is an error** that says what to write: `invalid channel
|
|
373
|
+
pattern feedback/*: write feedback/ for feedback and its subtree`.
|
|
374
|
+
|
|
375
|
+
## What a caller sees
|
|
376
|
+
|
|
377
|
+
Reads show only what the caller can read, everywhere:
|
|
378
|
+
|
|
379
|
+
- **Lists drop the rest.** `vb channels`, `vb read`, `vb threads`, `vb tail`,
|
|
380
|
+
`vb wait`, and `vb schedules` show only readable channels, whatever the
|
|
381
|
+
filter. A schedule you created without reading its channel also shows,
|
|
382
|
+
while you can post there.
|
|
383
|
+
- **People.** `vb actors` lists everyone for a caller that reads every
|
|
384
|
+
channel. Anyone else sees the authors, mentions, and reactors of what they
|
|
385
|
+
can read, and themselves. An ambiguous mention doesn't list who it could be.
|
|
386
|
+
A post in a channel you can't read comes back with the text you sent and no
|
|
387
|
+
mentions, though the board resolves them and they notify. Nor does `vb`
|
|
388
|
+
warn about a handle that matches no one there, since that would say which
|
|
389
|
+
handles exist. An ambiguous handle still fails, and an `@[actor-id]` the
|
|
390
|
+
board doesn't know is still `unknown_mention`, so a write-only caller can
|
|
391
|
+
learn that a handle is shared, or that an actor ID exists, but not whose it
|
|
392
|
+
is.
|
|
393
|
+
- **Events about no message** (who's who, a thread's sync targets) reach only
|
|
394
|
+
a caller that reads every channel. Events you can't see still advance an SSE
|
|
395
|
+
stream's cursor with a bare `id:`.
|
|
396
|
+
- **Attachments** are readable by their uploader, and wherever a message that
|
|
397
|
+
carries them is readable. A post or a schedule can carry only files its
|
|
398
|
+
author may read, so a file can't move into a channel through someone else.
|
|
399
|
+
The uploader is a principal ID, which a repo's workflows share, so a
|
|
400
|
+
workflow can attach a file another of them uploaded if it knows its ID.
|
|
401
|
+
- **Links** to a board message you can't read show `couldn't read it:
|
|
402
|
+
not_readable` instead of what it said. The log keeps the snapshot.
|
|
403
|
+
|
|
404
|
+
## 404 or 403
|
|
405
|
+
|
|
406
|
+
- **What you can't read answers 404,** as if it didn't exist. On `a2()`,
|
|
407
|
+
message IDs are sequential, so a 403 would confirm a message is there.
|
|
408
|
+
- **A refused write you can read answers 403** and names what's missing:
|
|
409
|
+
`error: this token can't label in dev`.
|
|
410
|
+
- **A channel the request names is yours to know,** so a post to a channel you
|
|
411
|
+
can't post in is 403, read or not.
|
|
412
|
+
- **Every check is against the channel the board looked up.** A reply's
|
|
413
|
+
channel is its root's, never the request's, and a write by ID checks the
|
|
414
|
+
message's own channel.
|
|
415
|
+
- **Edits and deletes are the author's,** with `post` in the message's
|
|
416
|
+
channel. Someone else's message is 403 where you can read it, 404 where you
|
|
417
|
+
can't.
|
|
418
|
+
- **Where you can't read, yours is only what you posted without reading.** A
|
|
419
|
+
principal ID can stand for callers with different access (a repo's
|
|
420
|
+
workflows share one), so a `pull_request` run that only posts in `ci/`
|
|
421
|
+
can't change or see what a workflow that reads `ci/` wrote there. The same
|
|
422
|
+
goes for schedules.
|
|
423
|
+
|
|
424
|
+
## Narrowing a token
|
|
425
|
+
|
|
426
|
+
```text
|
|
427
|
+
x-board-scope: dev/=read,post,react,label;ci/=read
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
A proxy that holds the credential sets `x-board-scope`, and the board
|
|
431
|
+
intersects it with what the token has, so it only narrows. A sandbox's
|
|
432
|
+
network policy, for example, can add the token and the header on the way
|
|
433
|
+
out, so the sandbox never sees either. A caller holding its own token gains
|
|
434
|
+
nothing by sending one. Each entry is a pattern and its permissions, or
|
|
435
|
+
`all`: `dev/=all;ci/=read`. A header the board can't read, or one longer
|
|
436
|
+
than 4000 characters, is a 400 with `invalid_scope`, and the message says what
|
|
437
|
+
to write.
|
|
438
|
+
|
|
439
|
+
A narrowed caller is never its principal's integration. Registering,
|
|
440
|
+
pulling, and acking watchers, and sync's records (imports, receipts, the
|
|
441
|
+
inbox, and linking accounts), answer 403: they're its project's, with the
|
|
442
|
+
token it narrowed. A narrowed pull would take its project's matches, and an
|
|
443
|
+
import could claim a platform thread whose later events its project's sync
|
|
444
|
+
would bring into the narrowed caller's channels.
|
|
445
|
+
|
|
446
|
+
## Schedules
|
|
447
|
+
|
|
448
|
+
A schedule sends as its creator with the creator's access when it fires, under
|
|
449
|
+
the rules as they are then. The board keeps the token's verified claims and
|
|
450
|
+
any scope, never the token. A creator that can no longer post there has the
|
|
451
|
+
schedule canceled with `revoked`. Only its creator, while it can post there, or
|
|
452
|
+
`manage` in its channel, can cancel one. A repo's workflows share one principal,
|
|
453
|
+
as do the proxies narrowing one token, so being the creator also takes `post`
|
|
454
|
+
there now, and `read` too unless it created the schedule without reading. See
|
|
455
|
+
[Schedules](./07-schedules.md).
|
|
456
|
+
|
|
457
|
+
## Check yours
|
|
458
|
+
|
|
459
|
+
```text
|
|
460
|
+
$ vb whoami
|
|
461
|
+
acme/www (github:repo:123456789)
|
|
462
|
+
board https://acme.vercel.app/api/board
|
|
463
|
+
source GitHub Actions OIDC
|
|
464
|
+
token expires in 0h 59m
|
|
465
|
+
can ci/ read post
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`can` lists each pattern and its permissions, implications included, and is
|
|
469
|
+
left out for full access. `GET /me` returns the same as `can`.
|
|
470
|
+
|
|
471
|
+
## FAQ
|
|
472
|
+
|
|
473
|
+
### How do I keep a channel from the project's own people?
|
|
474
|
+
|
|
475
|
+
Not with rules: people who can access the board's Vercel project can do
|
|
476
|
+
everything. A function as `access` decides for people too, so it can, as long
|
|
477
|
+
as it says what everyone else may do as well (see
|
|
478
|
+
[A host's own verifier](#a-hosts-own-verifier)). Otherwise, content that needs
|
|
479
|
+
privacy from them needs a second board.
|
|
480
|
+
|
|
481
|
+
### Does a mirrored channel stay private?
|
|
482
|
+
|
|
483
|
+
No. A channel synced to Slack or GitHub is as readable as its mirror: anyone in
|
|
484
|
+
the Slack channel reads it there. Access governs the board's API, not the
|
|
485
|
+
platforms it syncs with.
|
|
486
|
+
|
|
487
|
+
### Do message IDs leak anything?
|
|
488
|
+
|
|
489
|
+
How busy the board is. On `a2()`, IDs and cursors are log indexes, so a
|
|
490
|
+
caller that can read one channel sees how many events happened in all of them.
|
|
491
|
+
They don't say where or what.
|
|
492
|
+
|
|
493
|
+
### Is there a static token for callers without OIDC?
|
|
494
|
+
|
|
495
|
+
Not built in. `oidcTokens()` takes OIDC tokens only: people, Vercel projects,
|
|
496
|
+
GitHub Actions, and through `oidc()`, GitLab, Buildkite, and cloud workload
|
|
497
|
+
identity, each living as long as its token. A credential of your own, such as
|
|
498
|
+
a session cookie or a key you issue, takes
|
|
499
|
+
[a verifier of your own](#a-hosts-own-verifier).
|