@vercel/board 0.29.0 → 0.31.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 (156) hide show
  1. package/README.md +8 -17
  2. package/dist/{a2-HUbQJ0RD.mjs → a2-DngWY2Eb.mjs} +741 -415
  3. package/dist/access-CY5xVMIK.mjs +242 -0
  4. package/dist/{access-CV96SYgr.d.mts → access-DPcrKIH_.d.mts} +6 -1
  5. package/dist/access.d.mts +4 -4
  6. package/dist/access.mjs +5 -4
  7. package/dist/{actors-DcJhFbwJ.mjs → actors-Bm-ojWWT.mjs} +2 -2
  8. package/dist/{auth-JO22iIwr.mjs → auth-CyO2aw5n.mjs} +126 -236
  9. package/dist/{auth-CU56Q2Xh.d.mts → auth-DLYSt_-A.d.mts} +32 -4
  10. package/dist/{backend-CPC_vyOo.d.mts → backend-Bob-5CM7.d.mts} +93 -42
  11. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  12. package/dist/backend-a2/contract.d.mts +38 -98
  13. package/dist/backend-a2/contract.mjs +1 -1
  14. package/dist/backend-a2/scheduler-local.d.mts +1 -1
  15. package/dist/backend-a2/scheduler-local.mjs +1 -1
  16. package/dist/backend-a2/scheduler-qstash.d.mts +3 -3
  17. package/dist/backend-a2/scheduler-qstash.mjs +8 -8
  18. package/dist/backend-a2/scheduler-vercel.d.mts +3 -3
  19. package/dist/backend-a2/scheduler-vercel.mjs +6 -6
  20. package/dist/backend-a2/store-memory.d.mts +2 -2
  21. package/dist/backend-a2/store-memory.mjs +3 -3
  22. package/dist/backend-a2/store-postgres.d.mts +1 -1
  23. package/dist/backend-a2/store-postgres.mjs +34 -15
  24. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  25. package/dist/backend-a2/store-redis-http.mjs +6 -6
  26. package/dist/backend-a2/store-redis.d.mts +2 -2
  27. package/dist/backend-a2/store-redis.mjs +5 -5
  28. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  29. package/dist/backend-a2/store-sqlite.mjs +3 -3
  30. package/dist/backend-a2.d.mts +143 -242
  31. package/dist/backend-a2.mjs +2 -2
  32. package/dist/{backend-http-CqfKhrnn.d.mts → backend-http-BRrRbjRx.d.mts} +59 -45
  33. package/dist/{backend-http-CzIJMm58.mjs → backend-http-CvUKOn-m.mjs} +11 -10
  34. package/dist/{backend-http-Bmp0IgD3.mjs → backend-http-D8mUzuKQ.mjs} +2 -2
  35. package/dist/backend-http.d.mts +1 -1
  36. package/dist/backend-http.mjs +1 -1
  37. package/dist/backend-http.node.d.mts +1 -1
  38. package/dist/backend-http.node.mjs +1 -1
  39. package/dist/{backend-vercel-DQc_TaHp.mjs → backend-vercel-Ba8JDtqW.mjs} +3 -3
  40. package/dist/{backend-vercel-4tvcPKqX.d.mts → backend-vercel-BaSEUdwg.d.mts} +62 -47
  41. package/dist/{backend-vercel-BNauAs7w.mjs → backend-vercel-CmY9yZ9m.mjs} +279 -42
  42. package/dist/backend-vercel.d.mts +1 -1
  43. package/dist/backend-vercel.mjs +1 -1
  44. package/dist/backend-vercel.node.d.mts +1 -1
  45. package/dist/backend-vercel.node.mjs +1 -1
  46. package/dist/{blob-DmiQjrYy.d.mts → blob-Bx8QPWEd.d.mts} +2 -2
  47. package/dist/{blob-DdbpJqN1.mjs → blob-WbB2DJKn.mjs} +30 -11
  48. package/dist/{chat-provider-BWHbChcQ.d.mts → chat-provider-BChYrrA7.d.mts} +32 -28
  49. package/dist/{chat-provider-COtsU5jB.mjs → chat-provider-DkFp2hrh.mjs} +105 -60
  50. package/dist/cli-commands.d.mts +1 -1
  51. package/dist/cli-commands.mjs +1 -1
  52. package/dist/cli-embedded.d.mts +2 -2
  53. package/dist/cli-embedded.mjs +1 -1
  54. package/dist/cli.mjs +4 -4
  55. package/dist/{client-BqAjk0Q4.d.mts → client-COfYrgfV.d.mts} +240 -227
  56. package/dist/{commands-D6gvrOh3.d.mts → commands-DK4W3HCJ.d.mts} +47 -10
  57. package/dist/{commands-C3R1AWgm.mjs → commands-L1JSGz2Z.mjs} +212 -54
  58. package/dist/conformance-DK43HiHf.mjs +1000 -0
  59. package/dist/conformance.d.mts +8 -2
  60. package/dist/conformance.mjs +1 -1
  61. package/dist/{engine-DQxXk7dX.mjs → engine-DurYIRt8.mjs} +29 -210
  62. package/dist/{errors-DDktVeUa.d.mts → errors-2vGs49Db.d.mts} +1 -1
  63. package/dist/{client-D0Px7KzF.mjs → errors-CJXhhzvT.mjs} +132 -27
  64. package/dist/{provider-BaVmi4Hi.mjs → events-lujPA3gQ.mjs} +80 -17
  65. package/dist/{exports-BG-0Wyn-.mjs → exports-CWIdN0Ub.mjs} +195 -103
  66. package/dist/exports-FBOQWT7O.d.mts +5 -0
  67. package/dist/{folds-BDMSxBUf.mjs → folds-Cu9Q9zyQ.mjs} +3 -22
  68. package/dist/{folds-ZkHM4M-v.d.mts → folds-DLUzfibJ.d.mts} +15 -40
  69. package/dist/{index-D3VvNfh_.d.mts → index-Ho-ayyQL.d.mts} +2 -2
  70. package/dist/index.d.mts +7 -7
  71. package/dist/{index.default-rB8sJ9RX.mjs → index.default-BUcwDaAe.mjs} +1 -1
  72. package/dist/index.mjs +5 -6
  73. package/dist/index.node.d.mts +7 -7
  74. package/dist/index.node.mjs +3 -4
  75. package/dist/inspect/app.js +62 -61
  76. package/dist/inspect/engine.js +3 -3
  77. package/dist/{internal-CvsrprnU-CRY89I-b.mjs → internal-CvsrprnU-zZMpDiRY.mjs} +1 -1
  78. package/dist/{local-DhkrAqPP.mjs → local-C76Fy5ad.mjs} +1 -1
  79. package/dist/{main-CVBqe3VO.mjs → main-Dz-8komG.mjs} +251 -127
  80. package/dist/{middleware-CXjEeef4.mjs → middleware-BavYSDUa.mjs} +1 -1
  81. package/dist/{node-Zd_C-gxP.mjs → node-BgeGcwX0.mjs} +7 -3
  82. package/dist/{platform-B4TnJtWu-CwcTyKg2.mjs → platform-B4TnJtWu-Do3agKr5.mjs} +1 -1
  83. package/dist/{project-CBXlLftU.mjs → project-CQ4VUibA.mjs} +1 -1
  84. package/dist/{provider-DmSTp9lI.d.mts → provider-gynDUoyW.d.mts} +13 -43
  85. package/dist/{redis-tables-BJQVUspu.mjs → redis-tables-DHf85ixI.mjs} +40 -42
  86. package/dist/{reducer-CjmvZSAb-D_OSISmj.mjs → reducer-Bswr11ar-P324o9vn.mjs} +2 -2
  87. package/dist/{reducer-CYPHsSmd-BQIfozGi.d.mts → reducer-CYPHsSmd-BXjGiptm.d.mts} +1 -1
  88. package/dist/{reducers-DJzVpXwM.mjs → reducers-DcENwtwZ.mjs} +74 -77
  89. package/dist/{retryable-lazy-DZWmHpii-Bye-W8q7.mjs → retryable-lazy-DZWmHpii-DpTc5PIP.mjs} +1 -1
  90. package/dist/{scheduler-task-X1SToDjP-BVoWuInI.mjs → scheduler-task-X1SToDjP-D2Il2rln.mjs} +1 -1
  91. package/dist/{serve-G9RuaDSr.mjs → serve-CTmow-YH.mjs} +7 -7
  92. package/dist/{server-qgez0Xny-D5mLfqFQ.d.mts → server-B9gu0qi1-DI1WvcVK.d.mts} +10 -4
  93. package/dist/{server-BixV_byP.mjs → server-DG8D6VV0.mjs} +4 -4
  94. package/dist/{server-DtFrWxi3-Boa6gdWO.mjs → server-DX7mM6nm-D7N_7bug.mjs} +32 -23
  95. package/dist/{serving-B75Y4BF-.mjs → serving-DBFRo6U_.mjs} +1 -1
  96. package/dist/{session-clear-wHd5GCpZ-BnKSxReK.mjs → session-clear-XJYpmUpg-2q8l_FJR.mjs} +14 -2
  97. package/dist/{sql-D4Z_efJX.mjs → sql-CT5xVuGr.mjs} +2 -8
  98. package/dist/{sqlite-db-Bz2JkfCU.mjs → sqlite-db-0WeSOEZN.mjs} +1 -1
  99. package/dist/{store-D0MhBvg6-4Z2L0Kqd.d.mts → store-DEzcHZOA-B-5vhxef.d.mts} +12 -2
  100. package/dist/store-N8PXxDAS-DWWgv-Vz.mjs +5 -0
  101. package/dist/{store-codec-DwrWlgQx-CYxGW0aD.mjs → store-codec-pAG2oPgQ-SqDXlPBR.mjs} +4 -4
  102. package/dist/store-memory-4IBl0Ztu.mjs +2 -0
  103. package/dist/{store-memory-Bas9Uq5C.mjs → store-memory-B0PkVSuQ.mjs} +14 -7
  104. package/dist/{store-polling-BZt2Rf3D-BiEM6_HB.mjs → store-polling-BZt2Rf3D-Div-bJ_D.mjs} +2 -2
  105. package/dist/{store-presence-polling-JuAy2Ck2-CYpuK75X.mjs → store-presence-polling-JuAy2Ck2-Dh-g_KYM.mjs} +3 -3
  106. package/dist/{store-sqlite-CxxPCcCT.mjs → store-sqlite-D85t3tE7.mjs} +14 -7
  107. package/dist/store-sqlite-iBTYYKBj.mjs +2 -0
  108. package/dist/{stream-buffer-DrTyiIuo-BKy-vI14.mjs → stream-buffer-DrTyiIuo-Be10yOBR.mjs} +2 -2
  109. package/dist/{sync-kapso.d.mts → sync-chat-sdk.d.mts} +33 -37
  110. package/dist/sync-chat-sdk.mjs +44 -0
  111. package/dist/sync-github.d.mts +5 -18
  112. package/dist/sync-github.mjs +4 -70
  113. package/dist/sync-linear.d.mts +3 -3
  114. package/dist/sync-linear.mjs +6 -5
  115. package/dist/sync-slack.d.mts +7 -7
  116. package/dist/sync-slack.mjs +33 -54
  117. package/dist/sync.d.mts +3 -3
  118. package/dist/sync.mjs +2 -4
  119. package/dist/{tokens-CC3izSNr.mjs → tokens-Dnyd_y1n.mjs} +15 -6
  120. package/dist/{types-No0WdhE5.d.mts → types-CmrFjpml.d.mts} +2 -4
  121. package/dist/{watchers-hash-BDhLVkV5.mjs → watchers-hash-BjwlnGD9.mjs} +124 -115
  122. package/docs/01-board/01-mounting.md +2 -2
  123. package/docs/01-board/03-identity.md +5 -6
  124. package/docs/01-board/04-messages.md +96 -2
  125. package/docs/01-board/05-reading.md +2 -1
  126. package/docs/01-board/06-watchers.md +8 -6
  127. package/docs/01-board/08-middleware.md +6 -3
  128. package/docs/01-board/09-access.md +145 -14
  129. package/docs/02-sync/01-overview.md +19 -26
  130. package/docs/02-sync/02-functions.md +8 -9
  131. package/docs/02-sync/03-slack.md +5 -5
  132. package/docs/02-sync/04-github.md +2 -2
  133. package/docs/02-sync/06-providers.md +91 -62
  134. package/docs/03-guides/01-coding-agents.md +1 -1
  135. package/docs/03-guides/03-ci.md +7 -4
  136. package/docs/04-reference/01-cli.md +109 -51
  137. package/docs/04-reference/02-board.md +76 -28
  138. package/docs/04-reference/03-http.md +76 -38
  139. package/docs/04-reference/04-log.md +8 -11
  140. package/docs/04-reference/05-conformance.md +47 -7
  141. package/docs/index.md +2 -2
  142. package/package.json +7 -10
  143. package/sql/postgres/board.sql +1 -1
  144. package/sql/postgres/schema.sql +1 -1
  145. package/dist/conformance-DlnfuzEB.mjs +0 -556
  146. package/dist/errors-CusN4x2l.mjs +0 -88
  147. package/dist/events-qf5XG5VQ.mjs +0 -75
  148. package/dist/exports-mhAYiU4i.d.mts +0 -5
  149. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +0 -5
  150. package/dist/store-memory-ZLiZbH3R.mjs +0 -2
  151. package/dist/store-sqlite-BmwL3pUR.mjs +0 -2
  152. package/dist/sync-kapso.mjs +0 -89
  153. package/dist/sync.chat.d.mts +0 -2
  154. package/dist/sync.chat.mjs +0 -2
  155. package/dist/users-yryHKFMo.mjs +0 -42
  156. package/docs/02-sync/05-whatsapp.md +0 -152
@@ -1,229 +1,14 @@
1
- import { c as ChannelPath, v as SCOPE_MAX } from "./schemas-DBmUO6pr.mjs";
2
- import { a as forbidden, l as unauthorized } from "./errors-CusN4x2l.mjs";
3
- //#region src/server/access.ts
4
- /**
5
- * What a principal may do in a channel. `read` covers messages, threads,
6
- * `/events`, and schedules; `post` starts threads and edits your own messages;
7
- * `manage` is the board's own bookkeeping (inbox, sources).
8
- */
9
- const PERMISSIONS = [
10
- "read",
11
- "post",
12
- "react",
13
- "label",
14
- "sync",
15
- "manage"
16
- ];
17
- /**
18
- * One hop of implication, kept as data like Vercel's git broker
19
- * (`ACTION_IMPLICATIONS`). Each verb that addresses a message by ID implies
20
- * `read`, so none of them is a blind existence oracle. `post` implies nothing:
21
- * write-only is `['post']`.
22
- */
23
- const IMPLIES = {
24
- read: [],
25
- post: [],
26
- react: ["read"],
27
- label: ["read"],
28
- sync: ["read"],
29
- manage: [
30
- "read",
31
- "post",
32
- "react",
33
- "label",
34
- "sync"
35
- ]
36
- };
37
- const ORDER = new Map(PERMISSIONS.map((permission, index) => [permission, index]));
38
- const SHAPE = "write *, a channel, or a channel and its subtree (feedback/)";
39
- /** A pattern's parts; any other `*` throws, and says what to type instead. */
40
- function parsePattern(pattern) {
41
- if (pattern === "*") return { kind: "all" };
42
- if (pattern.includes("*")) {
43
- const before = pattern.slice(0, pattern.indexOf("*")).replace(/\/+$/, "");
44
- const hint = before === "" ? "write * for every channel" : ChannelPath.safeParse(before).success ? `write ${before}/ for ${before} and its subtree` : SHAPE;
45
- throw new Error(`invalid channel pattern ${pattern}: ${hint}`);
46
- }
47
- const subtree = pattern.endsWith("/");
48
- const path = subtree ? pattern.slice(0, -1) : pattern;
49
- if (!ChannelPath.safeParse(path).success) throw new Error(`invalid channel pattern ${pattern || "(empty)"}: ${SHAPE}`);
50
- return subtree ? {
51
- kind: "subtree",
52
- path
53
- } : {
54
- kind: "exact",
55
- path
56
- };
57
- }
58
- const key = (pattern) => pattern.kind === "all" ? "*" : pattern.kind === "subtree" ? `${pattern.path}/` : pattern.path;
59
- /** A grant with its implications, closed: `['label']` is `read` and `label`. */
60
- function expand(grant) {
61
- const out = /* @__PURE__ */ new Set();
62
- const add = (permission) => {
63
- if (!ORDER.has(permission)) throw new Error(`unknown permission ${String(permission)}: use ${PERMISSIONS.join(", ")}`);
64
- if (out.has(permission)) return;
65
- out.add(permission);
66
- for (const implied of IMPLIES[permission]) add(implied);
67
- };
68
- for (const permission of grant === "all" ? PERMISSIONS : grant) add(permission);
69
- return out;
70
- }
71
- const NOTHING = /* @__PURE__ */ new Set();
72
- function compile(entries) {
73
- let all = null;
74
- const exact = /* @__PURE__ */ new Map();
75
- const subtrees = [];
76
- for (const [pattern, permissions] of entries) if (pattern.kind === "all") all = permissions;
77
- else if (pattern.kind === "exact") exact.set(pattern.path, permissions);
78
- else subtrees.push([pattern.path, permissions]);
79
- subtrees.sort((a, b) => b[0].length - a[0].length);
80
- return {
81
- all,
82
- exact,
83
- subtrees
84
- };
85
- }
86
- const under = (channel, path) => channel === path || channel.startsWith(`${path}/`);
87
- /** What the most specific pattern covering `channel` gives; with `subtreeOnly`, ignoring an exact rule for it. */
88
- function resolve(rules, channel, subtreeOnly = false) {
89
- if (!subtreeOnly) {
90
- const exact = rules.exact.get(channel);
91
- if (exact) return exact;
92
- }
93
- for (const [path, permissions] of rules.subtrees) if (under(channel, path)) return permissions;
94
- return rules.all ?? NOTHING;
95
- }
96
- /**
97
- * What `rules` gives every channel `pattern` governs where nothing more
98
- * specific does. The patterns matching one channel form a chain, so that's one
99
- * answer: an exact channel resolves as itself, a subtree as whatever covers the
100
- * subtree's root, ignoring an exact rule for the root.
101
- */
102
- const at = (rules, pattern) => pattern.kind === "all" ? rules.all ?? NOTHING : resolve(rules, pattern.path, pattern.kind === "subtree");
103
- const equal = (a, b) => a.size === b.size && [...a].every((permission) => b.has(permission));
104
- /** The answer of the pattern next less specific than `pattern` in `rules`. */
105
- function parent(rules, pattern) {
106
- if (pattern.kind === "exact") return resolve(rules, pattern.path, true);
107
- if (pattern.kind === "subtree") {
108
- for (const [path, permissions] of rules.subtrees) if (path !== pattern.path && under(pattern.path, path)) return permissions;
109
- }
110
- return rules.all ?? NOTHING;
111
- }
112
- /** Drops rules that say what their parent says, and an empty `*`, so equal access has one summary. */
113
- function canonical(entries) {
114
- const rules = compile(entries);
115
- return compile(entries.filter(([pattern, permissions]) => pattern.kind === "all" ? permissions.size > 0 : !equal(permissions, parent(rules, pattern))));
116
- }
117
- function entriesOf(can) {
118
- if (can === "all" || Array.isArray(can)) return [[{ kind: "all" }, expand(can)]];
119
- return Object.entries(can).map(([pattern, grant]) => [parsePattern(pattern), expand(grant)]);
120
- }
121
- const entriesIn = (rules) => [
122
- ...rules.all ? [[{ kind: "all" }, rules.all]] : [],
123
- ...rules.subtrees.map(([path, permissions]) => [{
124
- kind: "subtree",
125
- path
126
- }, permissions]),
127
- ...[...rules.exact].map(([path, permissions]) => [{
128
- kind: "exact",
129
- path
130
- }, permissions])
131
- ];
132
- const RULES = Symbol("board.access");
133
- const isAccess = (value) => typeof value === "object" && RULES in value;
134
- function fromRules(rules) {
135
- const entries = entriesIn(rules);
136
- const everywhere = (permission) => (rules.all?.has(permission) ?? false) && entries.every(([, permissions]) => permissions.has(permission));
137
- const readable = /* @__PURE__ */ new Map();
138
- return {
139
- [RULES]: rules,
140
- can: (permission, channel) => resolve(rules, channel).has(permission),
141
- readable(channel) {
142
- let found = readable.get(channel);
143
- if (found === void 0) readable.set(channel, found = resolve(rules, channel).has("read"));
144
- return found;
145
- },
146
- readsAll: everywhere("read"),
147
- everywhere,
148
- anywhere: (permission) => entries.some(([, permissions]) => permissions.has(permission)),
149
- narrow(scope) {
150
- const other = isAccess(scope) ? scope[RULES] : canonical(entriesOf(scope));
151
- const patterns = /* @__PURE__ */ new Map([["*", { kind: "all" }]]);
152
- for (const [pattern] of [...entries, ...entriesIn(other)]) patterns.set(key(pattern), pattern);
153
- return fromRules(canonical([...patterns.values()].map((pattern) => {
154
- const theirs = at(other, pattern);
155
- return [pattern, new Set([...at(rules, pattern)].filter((permission) => theirs.has(permission)))];
156
- })));
157
- },
158
- summary() {
159
- const named = entries.map(([pattern, permissions]) => [key(pattern), permissions]);
160
- named.sort(([a], [b]) => a === "*" ? -1 : b === "*" ? 1 : a.localeCompare(b));
161
- const out = {};
162
- for (const [name, permissions] of named) out[name] = [...permissions].sort((a, b) => ORDER.get(a) - ORDER.get(b));
163
- return out;
164
- }
165
- };
166
- }
167
- /** Access from config: `'all'`, a list for every channel, or a map from pattern to grant. Throws on a bad pattern or permission. */
168
- function compileAccess(can) {
169
- return fromRules(canonical(entriesOf(can)));
170
- }
171
- const FULL_ACCESS = compileAccess("all");
172
- /**
173
- * `x-board-scope`: `dev/=read,post,react,label;ci/=read`. Each entry is a
174
- * pattern and its permissions (or `all`). It's meant for a proxy that holds the
175
- * credential, like Pi's sandbox: the board intersects it with the principal's
176
- * access, so it can only narrow, and a caller holding its own token gains
177
- * nothing by sending it.
178
- */
179
- function parseScope(header) {
180
- if (header.length > 4e3) throw new Error(`x-board-scope is longer than ${SCOPE_MAX} characters`);
181
- const out = {};
182
- const entries = header.split(";").map((entry) => entry.trim());
183
- if (entries.every((entry) => entry === "")) throw new Error("x-board-scope is empty: write pattern=permissions, like dev/=read,post");
184
- for (const entry of entries) {
185
- if (entry === "") continue;
186
- const split = entry.indexOf("=");
187
- if (split < 0) throw new Error(`x-board-scope entry ${entry} needs =: write pattern=permissions, like dev/=read,post`);
188
- const pattern = entry.slice(0, split).trim();
189
- const list = entry.slice(split + 1).trim();
190
- if (Object.hasOwn(out, pattern)) throw new Error(`x-board-scope lists ${pattern} twice`);
191
- parsePattern(pattern);
192
- const permissions = list === "" ? [] : list.split(",").map((permission) => permission.trim());
193
- if (permissions.length === 1 && permissions[0] === "all") {
194
- out[pattern] = "all";
195
- continue;
196
- }
197
- for (const permission of permissions) if (!ORDER.has(permission)) throw new Error(`x-board-scope: unknown permission ${permission || "(empty)"}: use ${PERMISSIONS.join(", ")}, or all`);
198
- out[pattern] = permissions;
199
- }
200
- return out;
201
- }
202
- const PHRASE = {
203
- read: (channel) => `read ${channel}`,
204
- post: (channel) => `post in ${channel}`,
205
- react: (channel) => `react in ${channel}`,
206
- label: (channel) => `label in ${channel}`,
207
- sync: (channel) => `sync in ${channel}`,
208
- manage: (channel) => `manage ${channel}`
209
- };
1
+ import { a as forbidden, l as unauthorized, r as badRequest } from "./errors-CJXhhzvT.mjs";
2
+ import { i as parseScope, o as union, r as compileAccess, t as FULL_ACCESS } from "./access-CY5xVMIK.mjs";
3
+ //#region src/server/auth.ts
210
4
  /**
211
- * A proof that `access` allows `permission` in `channel`, or a 403 naming what's
212
- * missing: `this token can't label in dev`. Pass the channel the board looked
213
- * up, never one the request named. What the caller can't read should answer as
214
- * if it didn't exist: pass that error as `hidden`, and it's thrown instead
215
- * whenever `channel` isn't readable.
5
+ * Whether a caller wrote what `by` says, as far as changing it goes (editing, deleting, canceling a
6
+ * schedule): its principal did, and when a board token pins the profile it writes as, as that
7
+ * profile. A token acts as its minter, so the principal alone would let one agent's token change
8
+ * what its minter or another agent wrote. Another token that pins the same profile writes as the
9
+ * same author, so a renewed one picks up where the last left off.
216
10
  */
217
- function authorize(access, permission, channel, hidden) {
218
- if (hidden && !access.readable(channel)) throw hidden();
219
- if (!access.can(permission, channel)) throw forbidden(`this token can't ${PHRASE[permission](channel)}`);
220
- return Object.freeze({
221
- permission,
222
- channel
223
- });
224
- }
225
- //#endregion
226
- //#region src/server/auth.ts
11
+ const wroteAs = (caller, by) => by.principal === caller.principal.id && ((caller.principal.pinned?.profile ?? null) === null || by.actor === caller.actor.id);
227
12
  /**
228
13
  * Principal IDs a board authenticates: its people, deployments, workflows, and its own code. No
229
14
  * platform actor sync writes as may have one, or an import could pass for someone signed in.
@@ -322,11 +107,101 @@ function trustedIssuers(issuers) {
322
107
  }
323
108
  /** The OIDC issuers rules name: the only ones whose tokens are verified for them. */
324
109
  const issuersOf = (rules) => [...new Set(rules.flatMap((rule) => typeof rule !== "function" && rule.issuer === "oidc" ? [rule.url] : []))];
110
+ /** What a board token starts with, so board() knows its own before it tries any other verifier. */
111
+ const BOARD_TOKEN_PREFIX = "board_";
112
+ /**
113
+ * A board token's SHA-256, hex: what the board keeps instead of the secret, both when it mints one
114
+ * and when a request carries one. A token is 32 random bytes, so no salt or key is needed.
115
+ */
116
+ async function tokenHash(secret) {
117
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret)));
118
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
119
+ }
120
+ /** What a grant or a token left without `can` gives: what Vercel's API gives a project it lets use a board. */
121
+ const DEFAULT_CAN = { "*": [
122
+ "read",
123
+ "post",
124
+ "react",
125
+ "label"
126
+ ] };
127
+ const GRANT_CLAIMS_MAX = 32;
128
+ const GRANT_VALUES_MAX = 32;
129
+ const GRANT_VALUE_MAX = 1e3;
130
+ const invalidGrant = (message) => badRequest(message, "invalid_grant");
131
+ /** Whether `host` names something on the board's own network rather than on the internet. */
132
+ function privateHost(host) {
133
+ const name = host.toLowerCase().replace(/\.$/, "");
134
+ if (name.startsWith("[") || /^\d+(\.\d+){3}$/.test(name) || /^\d+$/.test(name)) return true;
135
+ if (!name.includes(".") || name === "localhost") return true;
136
+ return /\.(localhost|local|internal|home|lan|corp|intranet)$/.test(name);
137
+ }
138
+ /**
139
+ * A grant checked and put in one form: its issuer, its claims with keys and lists sorted, its
140
+ * `can`, and the key that says it's the same grant as another. A 400 `invalid_grant` that says
141
+ * what to write. `trusted`: the issuers the board's rules and other grants name, which no two may
142
+ * share principals between.
143
+ */
144
+ function checkGrant(spec, trusted) {
145
+ const { issuer } = spec;
146
+ if (typeof issuer !== "string" || issuer === "") throw invalidGrant("a grant needs an issuer: vercel, github, or an https URL");
147
+ if (issuer !== "vercel" && issuer !== "github") {
148
+ let url;
149
+ try {
150
+ url = new URL(issuer);
151
+ } catch {
152
+ throw invalidGrant(`a grant's issuer is vercel, github, or an https URL, got ${issuer}`);
153
+ }
154
+ if (VERCEL_ISSUER.test(issuer)) throw invalidGrant("write the issuer as vercel, and another team's projects with owner_id");
155
+ if (issuer === "https://token.actions.githubusercontent.com") throw invalidGrant("write the issuer as github");
156
+ if (url.protocol !== "https:" || url.search || url.hash || url.username || url.password) throw invalidGrant(`a grant's issuer is an https URL with no query or credentials, got ${issuer}`);
157
+ if (privateHost(url.hostname)) throw invalidGrant(`a grant's issuer is on the internet, not ${url.hostname}`);
158
+ const name = issuerName(issuer);
159
+ const other = trusted.find((candidate) => candidate !== issuer && /^https:/.test(candidate) && issuerName(candidate) === name);
160
+ if (other !== void 0) throw invalidGrant(`issuers ${other} and ${issuer} name the same principals, oidc:${name}:<sub>: use ${other}`);
161
+ }
162
+ const entries = typeof spec.claims === "object" && spec.claims !== null ? Object.entries(spec.claims) : [];
163
+ if (entries.length === 0) throw invalidGrant(`name at least one claim for ${issuer}: its issuer signs tokens for everyone it serves`);
164
+ if (entries.length > GRANT_CLAIMS_MAX) throw invalidGrant(`a grant names at most ${GRANT_CLAIMS_MAX} claims`);
165
+ const claims = {};
166
+ for (const [name, value] of entries.sort(([a], [b]) => a.localeCompare(b))) {
167
+ const values = typeof value === "string" ? [value] : Array.isArray(value) ? [...new Set(value)] : [];
168
+ if (values.length === 0 || values.some((one) => typeof one !== "string" || one === "")) throw invalidGrant(`claim ${name} has no value to match: give a string, or a list of them`);
169
+ if (values.length > GRANT_VALUES_MAX || values.some((one) => one.length > GRANT_VALUE_MAX)) throw invalidGrant(`claim ${name} lists at most ${GRANT_VALUES_MAX} values of up to ${GRANT_VALUE_MAX} characters`);
170
+ const sorted = values.sort();
171
+ claims[name] = sorted.length === 1 ? sorted[0] : sorted;
172
+ }
173
+ if (issuer === "vercel" && (claims.project_id === void 0 || claims.environment === void 0)) throw invalidGrant("a Vercel grant names project_id and environment: a preview runs any branch, so it says outright whether they get in");
174
+ if (issuer === "github" && claims.repository === void 0 && claims.repository_id === void 0) throw invalidGrant("a GitHub grant names repository or repository_id: GitHub signs tokens for every repository");
175
+ const can = spec.can ?? DEFAULT_CAN;
176
+ try {
177
+ compileAccess(can);
178
+ } catch (error) {
179
+ throw invalidGrant(error.message);
180
+ }
181
+ return {
182
+ issuer,
183
+ claims,
184
+ can,
185
+ key: JSON.stringify([issuer, claims])
186
+ };
187
+ }
188
+ /** Whether a verified token's facts are a grant's: its issuer, and every claim it names. */
189
+ function grantMatches(grant, facts) {
190
+ if (!(grant.issuer === "vercel" || grant.issuer === "github" ? facts.issuer === grant.issuer : facts.issuer === "oidc" && facts.iss === grant.issuer)) return false;
191
+ return Object.entries(grant.claims).every(([name, allowed]) => {
192
+ const value = facts.claims[name];
193
+ if (value === void 0) return false;
194
+ const text = String(value);
195
+ return typeof allowed === "string" ? text === allowed : allowed.includes(text);
196
+ });
197
+ }
198
+ /** The OIDC issuers grants name past Vercel and GitHub: their tokens are verified too. */
199
+ const grantIssuers = (grants) => [...new Set(grants.flatMap((grant) => grant.issuer === "vercel" || grant.issuer === "github" ? [] : [grant.issuer]))];
325
200
  /**
326
201
  * What principals may do, under a board's rules: `vercelProject()`, `githubActions()`, `oidc()`,
327
- * and functions of your own, the first that matches deciding, past the defaults (people, and the
328
- * board's own deployments in its environment, may do everything). Or a host's own function of the
329
- * principal.
202
+ * and functions of your own, the first that matches deciding, with the board's grants adding to it,
203
+ * past the defaults (people, and the board's own deployments in its environment, may do
204
+ * everything). Or a host's own function of the principal.
330
205
  */
331
206
  function createAuthority(options) {
332
207
  const rules = typeof options.access === "function" ? [] : options.access;
@@ -334,13 +209,14 @@ function createAuthority(options) {
334
209
  const oidcIssuers = trustedIssuers(issuersOf(rules));
335
210
  /**
336
211
  * What the rules give a principal, in order: a token's rule when its facts match, a function when
337
- * it answers. The board's own deployments in its environment get everything unless a token's rule
338
- * says otherwise, and no function is asked about them. A 401 or 403 that says which rule would let
339
- * it in, when none does.
212
+ * it answers. Every grant that matches its token adds to that, so a grant never goes unused
213
+ * because a rule matched first. The board's own deployments in its environment get everything
214
+ * unless a token's rule says otherwise, no function is asked about them, and no grant is theirs;
215
+ * a function that answers null refuses, grants or not. A 401 or 403 that says which rule would
216
+ * let it in, when nothing does.
340
217
  */
341
218
  async function byRules(principal, facts) {
342
219
  if (facts?.issuer === "host") throw forbidden("this token can't use this board: its board no longer takes a host's principals");
343
- if (facts?.issuer === "oidc" && !oidcIssuers.has(facts.iss)) throw unauthorized(`untrusted issuer: ${facts.iss}`);
344
220
  const claims = facts?.claims ?? {};
345
221
  const own = facts?.issuer === "vercel" ? await options.project() : null;
346
222
  const context = own ? {
@@ -354,18 +230,32 @@ function createAuthority(options) {
354
230
  const here = boardEnvironment();
355
231
  const ownDeployment = own !== null && own.id !== null && claims.project_id === own.id;
356
232
  const ownHere = ownDeployment && here !== null && environment === here;
233
+ let ruled = null;
357
234
  for (const rule of rules) {
358
235
  if (typeof rule === "function") {
359
236
  if (ownHere) continue;
360
237
  const can = await rule(principal);
361
238
  if (can === void 0) continue;
362
239
  if (can === null) throw forbidden("this token can't use this board");
363
- return compileAccess(can);
240
+ ruled = compileAccess(can);
241
+ break;
364
242
  }
365
- if (facts && rule.issuer === facts.issuer && (rule.issuer !== "oidc" || rule.url === facts.iss) && rule.matches(claims, context)) return rule.access;
243
+ if (facts && rule.issuer === facts.issuer && (rule.issuer !== "oidc" || rule.url === facts.iss) && rule.matches(claims, context)) {
244
+ ruled = rule.access;
245
+ break;
246
+ }
247
+ }
248
+ if (ownHere) return ruled ?? FULL_ACCESS;
249
+ if (!facts) {
250
+ if (ruled) return ruled;
251
+ throw forbidden("this token can't use this board");
366
252
  }
367
- if (ownHere) return FULL_ACCESS;
368
- if (!facts) throw forbidden("this token can't use this board");
253
+ const grants = options.grants ? await options.grants() : [];
254
+ const granted = grants.filter((grant) => grantMatches(grant, facts)).map((grant) => compileAccess(grant.can));
255
+ if (ruled) return granted.length > 0 ? union([ruled, ...granted]) : ruled;
256
+ if (granted.length > 0) return union(granted);
257
+ if (facts.issuer === "oidc" && !oidcIssuers.has(facts.iss) && !grants.some((grant) => grant.issuer === facts.iss)) throw unauthorized(`untrusted issuer: ${facts.iss}`);
258
+ if (facts.issuer === "oidc" && !oidcIssuers.has(facts.iss)) throw forbidden("this token can't use this board: no grant lets in its claims");
369
259
  if (facts.issuer === "oidc") throw notInAccess(`oidc({ issuer: '${facts.iss}', claims, can })`);
370
260
  if (facts.issuer === "github") throw notInAccess(`githubActions({ repository: '${String(claims.repository)}', can })`);
371
261
  if (ownDeployment) throw forbidden(`this token can't use this board: it runs in ${here ?? "no Vercel environment"}, so its ${environment || "unnamed"} deployments need vercelProject({ project: 'self', environments: ['${environment}'], can })`);
@@ -496,4 +386,4 @@ function hostPrincipal(value) {
496
386
  };
497
387
  }
498
388
  //#endregion
499
- export { FULL_ACCESS as _, compileRules as a, githubActions as c, issuersOf as d, markVerified as f, verifiesTokens as g, vercelProject as h, authenticates as i, hostPrincipal as l, trustedIssuers as m, TOKENS as n, createAuthority as o, oidc as p, VERCEL_ISSUER as r, fromTokens as s, GITHUB_ISSUER as t, issuerName as u, authorize as v, parseScope as y };
389
+ export { wroteAs as C, verifiesTokens as S, oidc as _, VERCEL_ISSUER as a, trustedIssuers as b, compileRules as c, githubActions as d, grantIssuers as f, markVerified as g, issuersOf as h, TOKENS as i, createAuthority as l, issuerName as m, DEFAULT_CAN as n, authenticates as o, hostPrincipal as p, GITHUB_ISSUER as r, checkGrant as s, BOARD_TOKEN_PREFIX as t, fromTokens as u, privateHost as v, vercelProject as x, tokenHash as y };
@@ -1,4 +1,4 @@
1
- import { r as Can, t as Access } from "./access-CV96SYgr.mjs";
1
+ import { r as Can, t as Access } from "./access-DPcrKIH_.mjs";
2
2
  import { JWTPayload, JWTVerifyGetKey } from "jose";
3
3
  //#region src/server/auth.d.ts
4
4
  /**
@@ -46,6 +46,22 @@ type Principal = {
46
46
  facts?: TokenFacts;
47
47
  /** The `x-board-scope` a proxy sent, which narrows whatever it may do. */
48
48
  scope?: string;
49
+ /**
50
+ * The board token it came with (its ID), kept with what it stores: a schedule or a registration
51
+ * made with one stops when the token does.
52
+ */
53
+ token?: string;
54
+ /** What a board token pins for every request with it: the profile, avatar, and session it writes as, and a person's handle and name. */
55
+ pinned?: Pinned;
56
+ };
57
+ /** What a board token says about who writes, beside its principal: labels, but the board's, so a request can't change them. */
58
+ type Pinned = {
59
+ profile: string | null;
60
+ avatar: string | null;
61
+ session: string | null;
62
+ /** A person's handle and display name as they were when they minted it: whoever holds the token sends none. */
63
+ handle: string | null;
64
+ name: string | null;
49
65
  };
50
66
  /** A principal and what it may do in each channel, as board()'s `access` worked it out. */
51
67
  type Granted = Principal & {
@@ -154,8 +170,20 @@ type AccessFunction = (principal: Known) => Can | null | undefined | Promise<Can
154
170
  * person or the board's own deployments in its environment, which the defaults decide first.
155
171
  */
156
172
  type RuleFunction = (principal: Known) => Can | null | undefined | Promise<Can | null | undefined>;
157
- /** A principal as the board keeps it to work its access out again: who it is, its token's facts, and its scope. */
158
- type Known = Pick<Principal, 'id' | 'kind' | 'handle' | 'facts' | 'scope'>;
173
+ /** A principal as the board keeps it to work its access out again: who it is, its token's facts, its scope, and the board token it came with. */
174
+ type Known = Pick<Principal, 'id' | 'kind' | 'handle' | 'facts' | 'scope' | 'token'>;
175
+ /** A grant the board keeps: its ID, its issuer and claims as the board checked them, and who set it when. */
176
+ type StoredGrant = {
177
+ id: string;
178
+ issuer: string;
179
+ claims: Readonly<Record<string, string | readonly string[]>>;
180
+ can: Can;
181
+ /** The actor that last set it, and when. */
182
+ by: string;
183
+ at: string;
184
+ };
185
+ /** What a grant or a token left without `can` gives: what Vercel's API gives a project it lets use a board. */
186
+ declare const DEFAULT_CAN: Can;
159
187
  /**
160
188
  * What a principal may do under `rules` (`vercelProject()`, `githubActions()`, `oidc()`, and
161
189
  * functions of your own), the first that matches deciding, past the defaults: people, and the
@@ -201,4 +229,4 @@ type HostPrincipal = {
201
229
  expiresAt?: Date | null;
202
230
  };
203
231
  //#endregion
204
- export { oidc as _, CallerRequest as a, HostPrincipal as c, OidcTokensOptions as d, Principal as f, githubActions as g, compileRules as h, AuthenticateContext as i, IssuerConfig as l, RuleFunction as m, AccessRule as n, Claims as o, PrincipalKind as p, Authenticate as r, Granted as s, AccessFunction as t, Known as u, vercelProject as v };
232
+ export { StoredGrant as _, CallerRequest as a, oidc as b, Granted as c, Known as d, OidcTokensOptions as f, RuleFunction as g, PrincipalKind as h, AuthenticateContext as i, HostPrincipal as l, Principal as m, AccessRule as n, Claims as o, Pinned as p, Authenticate as r, DEFAULT_CAN as s, AccessFunction as t, IssuerConfig as u, compileRules as v, vercelProject as x, githubActions as y };