@vercel/board 0.0.0-stage → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +179 -2
  2. package/dist/a2-5EG8FkZ8.d.mts +3359 -0
  3. package/dist/a2-DYGYankn.mjs +3045 -0
  4. package/dist/access-CJU2-vaa.mjs +226 -0
  5. package/dist/access-CV96SYgr.d.mts +32 -0
  6. package/dist/access.d.mts +29 -0
  7. package/dist/access.mjs +4 -0
  8. package/dist/actors-rdN4dDTU.mjs +223 -0
  9. package/dist/attempts-BQUp9MKO.mjs +919 -0
  10. package/dist/auth-COzc09sn.mjs +276 -0
  11. package/dist/auth-wHFcAte3.d.mts +203 -0
  12. package/dist/backend-a2/blob-vercel.d.mts +8 -0
  13. package/dist/backend-a2/blob-vercel.mjs +81 -0
  14. package/dist/backend-a2/contract.d.mts +18 -0
  15. package/dist/backend-a2/contract.mjs +5 -0
  16. package/dist/backend-a2/scheduler-local.d.mts +11 -0
  17. package/dist/backend-a2/scheduler-local.mjs +2 -0
  18. package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
  19. package/dist/backend-a2/scheduler-qstash.mjs +2 -0
  20. package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
  21. package/dist/backend-a2/scheduler-vercel.mjs +2 -0
  22. package/dist/backend-a2/store-memory.d.mts +11 -0
  23. package/dist/backend-a2/store-memory.mjs +19 -0
  24. package/dist/backend-a2/store-postgres.d.mts +38 -0
  25. package/dist/backend-a2/store-postgres.mjs +110 -0
  26. package/dist/backend-a2/store-redis-http.d.mts +17 -0
  27. package/dist/backend-a2/store-redis-http.mjs +32 -0
  28. package/dist/backend-a2/store-redis.d.mts +23 -0
  29. package/dist/backend-a2/store-redis.mjs +39 -0
  30. package/dist/backend-a2/store-sqlite.d.mts +12 -0
  31. package/dist/backend-a2/store-sqlite.mjs +31 -0
  32. package/dist/backend-a2.d.mts +4 -0
  33. package/dist/backend-a2.mjs +2 -0
  34. package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
  35. package/dist/backend-http-BsDSgmeo.mjs +613 -0
  36. package/dist/backend-http-Cv6xbEy9.mjs +469 -0
  37. package/dist/backend-http.d.mts +10 -0
  38. package/dist/backend-http.mjs +2 -0
  39. package/dist/backend-http.node.d.mts +10 -0
  40. package/dist/backend-http.node.mjs +2 -0
  41. package/dist/blob-C0J3wbGv.d.mts +39 -0
  42. package/dist/bots-CGkO76qH.mjs +67 -0
  43. package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
  44. package/dist/chat-provider-jFSeyNmB.mjs +389 -0
  45. package/dist/cli.d.mts +1 -0
  46. package/dist/cli.mjs +2252 -0
  47. package/dist/client-DGDPaN7U.mjs +380 -0
  48. package/dist/conformance-DRR23YNy.mjs +512 -0
  49. package/dist/conformance.d.mts +30 -0
  50. package/dist/conformance.mjs +2 -0
  51. package/dist/contract-CuJmlrhw.mjs +566 -0
  52. package/dist/emoji-CTLpvH-A.mjs +445 -0
  53. package/dist/engine-3EvQ8idk.mjs +1449 -0
  54. package/dist/errors-DX3hpwWt.mjs +64 -0
  55. package/dist/exports-BpgyEdh6.d.mts +4 -0
  56. package/dist/exports-CyY3_FjZ.mjs +1285 -0
  57. package/dist/files-SniSmeEr.mjs +47 -0
  58. package/dist/index.d.mts +13 -0
  59. package/dist/index.default-BZ3lItjH.mjs +12 -0
  60. package/dist/index.mjs +19 -0
  61. package/dist/index.node.d.mts +14 -0
  62. package/dist/index.node.mjs +5 -0
  63. package/dist/inspect/app.js +416 -0
  64. package/dist/inspect/engine.js +5 -0
  65. package/dist/local-Cr_dSjvs.mjs +44 -0
  66. package/dist/own-OwgPWdmh.mjs +15 -0
  67. package/dist/project-CFiArraX.mjs +124 -0
  68. package/dist/provider-BGjz-mDa.d.mts +2473 -0
  69. package/dist/provider-CuewcDNZ.mjs +205 -0
  70. package/dist/redis-tables-B35vv4CZ.mjs +842 -0
  71. package/dist/reducers-B_vUGQRp.d.mts +1172 -0
  72. package/dist/reducers-mUPFuFJV.mjs +293 -0
  73. package/dist/schemas-Be08nyfW.mjs +74 -0
  74. package/dist/schemas-CWxs3ECJ.d.mts +50 -0
  75. package/dist/serve-Ccbb2LSb.mjs +170 -0
  76. package/dist/server-Cxey38B-.mjs +17 -0
  77. package/dist/serving-BNbzc96L.mjs +2 -0
  78. package/dist/sql-DbiEnqM0.mjs +571 -0
  79. package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
  80. package/dist/sync-github.d.mts +473 -0
  81. package/dist/sync-github.mjs +603 -0
  82. package/dist/sync-kapso.d.mts +175 -0
  83. package/dist/sync-kapso.mjs +86 -0
  84. package/dist/sync-slack.d.mts +364 -0
  85. package/dist/sync-slack.mjs +728 -0
  86. package/dist/sync.chat.d.mts +2 -0
  87. package/dist/sync.chat.mjs +2 -0
  88. package/dist/sync.d.mts +3 -0
  89. package/dist/sync.mjs +4 -0
  90. package/dist/targets-ojFEXEyN.mjs +94 -0
  91. package/dist/tokens-DKLn5P9B.mjs +256 -0
  92. package/dist/types-BHCjTZdB.d.mts +104 -0
  93. package/dist/users-Dls7_ANV.mjs +42 -0
  94. package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
  95. package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
  96. package/docs/01-board/01-mounting.md +506 -0
  97. package/docs/01-board/02-database.md +258 -0
  98. package/docs/01-board/03-identity.md +262 -0
  99. package/docs/01-board/04-messages.md +192 -0
  100. package/docs/01-board/05-reading.md +177 -0
  101. package/docs/01-board/06-watchers.md +249 -0
  102. package/docs/01-board/07-schedules.md +133 -0
  103. package/docs/01-board/08-middleware.md +139 -0
  104. package/docs/01-board/09-access.md +499 -0
  105. package/docs/01-quickstart.md +196 -0
  106. package/docs/02-sync/01-overview.md +290 -0
  107. package/docs/02-sync/02-callbacks.md +265 -0
  108. package/docs/02-sync/03-slack.md +248 -0
  109. package/docs/02-sync/04-github.md +201 -0
  110. package/docs/02-sync/05-whatsapp.md +148 -0
  111. package/docs/02-sync/06-providers.md +228 -0
  112. package/docs/03-guides/01-coding-agents.md +226 -0
  113. package/docs/03-guides/02-inspector.md +104 -0
  114. package/docs/03-guides/03-ci.md +251 -0
  115. package/docs/03-guides/04-triage.md +233 -0
  116. package/docs/03-guides/05-agent-factory.md +142 -0
  117. package/docs/04-reference/01-cli.md +606 -0
  118. package/docs/04-reference/02-board.md +579 -0
  119. package/docs/04-reference/03-http.md +489 -0
  120. package/docs/04-reference/04-log.md +250 -0
  121. package/docs/04-reference/05-conformance.md +97 -0
  122. package/docs/index.md +186 -0
  123. package/package.json +165 -3
  124. package/sql/postgres/board.sql +145 -0
  125. package/sql/postgres/notifications.sql +24 -0
  126. package/sql/postgres/schema.sql +451 -0
@@ -0,0 +1,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).