@vercel/board 0.30.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 (125) hide show
  1. package/README.md +8 -17
  2. package/dist/{a2-D_28Pdip.mjs → a2-DngWY2Eb.mjs} +573 -361
  3. package/dist/{access-CQ39gqEV.mjs → access-CY5xVMIK.mjs} +19 -3
  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-xPiamLWk.mjs → actors-Bm-ojWWT.mjs} +2 -2
  8. package/dist/{auth-BPinwfN0.mjs → auth-CyO2aw5n.mjs} +127 -14
  9. package/dist/{auth-CU56Q2Xh.d.mts → auth-DLYSt_-A.d.mts} +32 -4
  10. package/dist/{backend-n3hajfrD.d.mts → backend-Bob-5CM7.d.mts} +86 -41
  11. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  12. package/dist/backend-a2/contract.d.mts +37 -97
  13. package/dist/backend-a2/contract.mjs +1 -1
  14. package/dist/backend-a2/store-memory.d.mts +1 -1
  15. package/dist/backend-a2/store-memory.mjs +2 -2
  16. package/dist/backend-a2/store-postgres.d.mts +1 -1
  17. package/dist/backend-a2/store-postgres.mjs +1 -1
  18. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  19. package/dist/backend-a2/store-redis-http.mjs +1 -1
  20. package/dist/backend-a2/store-redis.d.mts +1 -1
  21. package/dist/backend-a2/store-redis.mjs +1 -1
  22. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  23. package/dist/backend-a2/store-sqlite.mjs +2 -2
  24. package/dist/backend-a2.d.mts +131 -240
  25. package/dist/backend-a2.mjs +2 -2
  26. package/dist/{backend-http-3PoBYQoo.d.mts → backend-http-BRrRbjRx.d.mts} +49 -45
  27. package/dist/{backend-http-BPaNYLWj.mjs → backend-http-CvUKOn-m.mjs} +11 -9
  28. package/dist/{backend-http-16SbvnAK.mjs → backend-http-D8mUzuKQ.mjs} +2 -2
  29. package/dist/backend-http.d.mts +1 -1
  30. package/dist/backend-http.mjs +1 -1
  31. package/dist/backend-http.node.d.mts +1 -1
  32. package/dist/backend-http.node.mjs +1 -1
  33. package/dist/{backend-vercel-Kd8nvW5A.mjs → backend-vercel-Ba8JDtqW.mjs} +3 -3
  34. package/dist/{backend-vercel-qznuDxnN.d.mts → backend-vercel-BaSEUdwg.d.mts} +49 -45
  35. package/dist/{backend-vercel-C4Kee2-K.mjs → backend-vercel-CmY9yZ9m.mjs} +80 -15
  36. package/dist/backend-vercel.d.mts +1 -1
  37. package/dist/backend-vercel.mjs +1 -1
  38. package/dist/backend-vercel.node.d.mts +1 -1
  39. package/dist/backend-vercel.node.mjs +1 -1
  40. package/dist/{blob-CqILvdmr.d.mts → blob-Bx8QPWEd.d.mts} +2 -2
  41. package/dist/{blob-DQK5prXT.mjs → blob-WbB2DJKn.mjs} +30 -11
  42. package/dist/{chat-provider-D6jVT4wS.d.mts → chat-provider-BChYrrA7.d.mts} +32 -28
  43. package/dist/{chat-provider-COtsU5jB.mjs → chat-provider-DkFp2hrh.mjs} +105 -60
  44. package/dist/cli-commands.d.mts +1 -1
  45. package/dist/cli-commands.mjs +1 -1
  46. package/dist/cli-embedded.d.mts +2 -2
  47. package/dist/cli-embedded.mjs +1 -1
  48. package/dist/cli.mjs +4 -4
  49. package/dist/{client-CjAatpV4.d.mts → client-COfYrgfV.d.mts} +184 -221
  50. package/dist/{commands-BtH69yqt.d.mts → commands-DK4W3HCJ.d.mts} +30 -10
  51. package/dist/{commands-B5DHjZKK.mjs → commands-L1JSGz2Z.mjs} +162 -45
  52. package/dist/{conformance-C7fwIvmN.mjs → conformance-DK43HiHf.mjs} +224 -20
  53. package/dist/conformance.d.mts +8 -2
  54. package/dist/conformance.mjs +1 -1
  55. package/dist/{engine-DkdkXQEy.mjs → engine-DurYIRt8.mjs} +29 -210
  56. package/dist/{errors-87TVtXeb.d.mts → errors-2vGs49Db.d.mts} +1 -1
  57. package/dist/{errors-C_5rB8Iz.mjs → errors-CJXhhzvT.mjs} +28 -23
  58. package/dist/{provider-BaVmi4Hi.mjs → events-lujPA3gQ.mjs} +80 -17
  59. package/dist/{exports-CG0P8Qi4.mjs → exports-CWIdN0Ub.mjs} +146 -80
  60. package/dist/exports-FBOQWT7O.d.mts +5 -0
  61. package/dist/{folds-DGYkMW19.mjs → folds-Cu9Q9zyQ.mjs} +2 -22
  62. package/dist/{folds-8wRRXjAt.d.mts → folds-DLUzfibJ.d.mts} +13 -40
  63. package/dist/{index-BygAXP7t.d.mts → index-Ho-ayyQL.d.mts} +2 -2
  64. package/dist/index.d.mts +7 -7
  65. package/dist/{index.default-DhkgpfR2.mjs → index.default-BUcwDaAe.mjs} +1 -1
  66. package/dist/index.mjs +5 -5
  67. package/dist/index.node.d.mts +7 -7
  68. package/dist/index.node.mjs +3 -3
  69. package/dist/inspect/app.js +62 -61
  70. package/dist/inspect/engine.js +3 -3
  71. package/dist/{main-D2s-QBOK.mjs → main-Dz-8komG.mjs} +189 -112
  72. package/dist/{middleware-DlJQYiwV.mjs → middleware-BavYSDUa.mjs} +1 -1
  73. package/dist/{node-D8Rtg1su.mjs → node-BgeGcwX0.mjs} +7 -3
  74. package/dist/{project-C_2xs57L.mjs → project-CQ4VUibA.mjs} +1 -1
  75. package/dist/{provider-Bm46LAdp.d.mts → provider-gynDUoyW.d.mts} +13 -43
  76. package/dist/{redis-tables-BWJZHUGg.mjs → redis-tables-DHf85ixI.mjs} +1 -19
  77. package/dist/{reducers-8jqLewgw.mjs → reducers-DcENwtwZ.mjs} +72 -75
  78. package/dist/{serve-lPoBDEds.mjs → serve-CTmow-YH.mjs} +6 -6
  79. package/dist/{server-D82a4uMe.mjs → server-DG8D6VV0.mjs} +4 -4
  80. package/dist/{serving-DKny38LK.mjs → serving-DBFRo6U_.mjs} +1 -1
  81. package/dist/{sql-Bj0fk6dn.mjs → sql-CT5xVuGr.mjs} +2 -8
  82. package/dist/{sqlite-db-Dc8G78XV.mjs → sqlite-db-0WeSOEZN.mjs} +1 -1
  83. package/dist/{sync-kapso.d.mts → sync-chat-sdk.d.mts} +33 -37
  84. package/dist/sync-chat-sdk.mjs +44 -0
  85. package/dist/sync-github.d.mts +5 -18
  86. package/dist/sync-github.mjs +4 -70
  87. package/dist/sync-linear.d.mts +3 -3
  88. package/dist/sync-linear.mjs +2 -3
  89. package/dist/sync-slack.d.mts +7 -7
  90. package/dist/sync-slack.mjs +33 -54
  91. package/dist/sync.d.mts +3 -3
  92. package/dist/sync.mjs +2 -4
  93. package/dist/{tokens-C5_yGRlL.mjs → tokens-Dnyd_y1n.mjs} +15 -6
  94. package/dist/{types-shZmGZ-q.d.mts → types-CmrFjpml.d.mts} +1 -3
  95. package/dist/{watchers-hash-B_5b0-ZG.mjs → watchers-hash-BjwlnGD9.mjs} +85 -105
  96. package/docs/01-board/01-mounting.md +2 -2
  97. package/docs/01-board/03-identity.md +5 -6
  98. package/docs/01-board/04-messages.md +3 -2
  99. package/docs/01-board/05-reading.md +2 -1
  100. package/docs/01-board/06-watchers.md +8 -6
  101. package/docs/01-board/08-middleware.md +1 -1
  102. package/docs/01-board/09-access.md +145 -14
  103. package/docs/02-sync/01-overview.md +19 -26
  104. package/docs/02-sync/02-functions.md +8 -9
  105. package/docs/02-sync/03-slack.md +5 -5
  106. package/docs/02-sync/04-github.md +2 -2
  107. package/docs/02-sync/06-providers.md +91 -62
  108. package/docs/03-guides/01-coding-agents.md +1 -1
  109. package/docs/03-guides/03-ci.md +7 -4
  110. package/docs/04-reference/01-cli.md +86 -41
  111. package/docs/04-reference/02-board.md +56 -23
  112. package/docs/04-reference/03-http.md +59 -32
  113. package/docs/04-reference/04-log.md +8 -11
  114. package/docs/04-reference/05-conformance.md +35 -6
  115. package/docs/index.md +2 -2
  116. package/package.json +6 -9
  117. package/sql/postgres/board.sql +1 -1
  118. package/sql/postgres/schema.sql +1 -1
  119. package/dist/events-qf5XG5VQ.mjs +0 -75
  120. package/dist/exports-CrGA5_yN.d.mts +0 -5
  121. package/dist/sync-kapso.mjs +0 -89
  122. package/dist/sync.chat.d.mts +0 -2
  123. package/dist/sync.chat.mjs +0 -2
  124. package/dist/users-yryHKFMo.mjs +0 -42
  125. package/docs/02-sync/05-whatsapp.md +0 -152
@@ -1,24 +1,24 @@
1
1
  import { r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
- import { _ as RemoteWatcherId, b as actorKind, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
2
+ import { _ as RemoteWatcherId, b as actorKind, v as SCOPE_MAX, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
3
3
  import "./external-D3p5T57R.mjs";
4
- import { a as METHODS, d as VERSION, f as SYNC, n as compileWatcher, r as hasCriteria, t as canonicalWatchers } from "./watchers-hash-B_5b0-ZG.mjs";
5
- import { a as forbidden, c as toErrorResponse, f as duration, i as duplicateId, r as badRequest, s as notFound, t as HttpError, u as BoardError } from "./errors-C_5rB8Iz.mjs";
4
+ import { a as METHODS, d as VERSION, f as SYNC, n as compileWatcher, r as hasCriteria, t as canonicalWatchers } from "./watchers-hash-BjwlnGD9.mjs";
5
+ import { a as forbidden, c as toErrorResponse, f as duration, i as duplicateId, l as unauthorized, r as badRequest, s as notFound, t as HttpError, u as BoardError } from "./errors-CJXhhzvT.mjs";
6
6
  import { i as isKeyIdOf, r as derivedId, t as waitUntil } from "./wait-until-Dw4XrIDQ.mjs";
7
+ import { a as scopeOf, i as parseScope, n as authorize, r as compileAccess, t as FULL_ACCESS } from "./access-CY5xVMIK.mjs";
7
8
  import { t as own } from "./own-OwgPWdmh.mjs";
8
9
  import { n as replaceMentions } from "./mentions-CB6ShY4R.mjs";
9
- import { n as authorize, t as FULL_ACCESS } from "./access-CQ39gqEV.mjs";
10
- import { a as resolveMentions, n as actorSeen } from "./actors-xPiamLWk.mjs";
11
- import { i as authenticates } from "./auth-BPinwfN0.mjs";
12
- import { n as MIDDLEWARE_ACTOR, t as DroppedError } from "./middleware-DlJQYiwV.mjs";
10
+ import { a as resolveMentions, n as actorSeen } from "./actors-Bm-ojWWT.mjs";
11
+ import { C as wroteAs, f as grantIssuers, n as DEFAULT_CAN, o as authenticates, s as checkGrant, t as BOARD_TOKEN_PREFIX, y as tokenHash } from "./auth-CyO2aw5n.mjs";
12
+ import { n as MIDDLEWARE_ACTOR, t as DroppedError } from "./middleware-BavYSDUa.mjs";
13
13
  import { n as syncTarget, r as withEmailMentions } from "./targets-Cgjs29T0.mjs";
14
14
  import { t as A2Error } from "./session-clear-XJYpmUpg-2q8l_FJR.mjs";
15
15
  import { a as reducer } from "./reducer-Bswr11ar-P324o9vn.mjs";
16
- import { _ as BOARD_SESSION, a as IDENTITY_SESSION, c as SCHEDULES_SESSION, d as inboxContract, f as inboxSession, g as watchSession, h as watchContract, l as boardContract, m as schedulesContract, n as channelsReducer, o as INBOXES_SESSION, p as registryContract, r as identityCodesReducer, s as REGISTRY_SESSION, t as actorsReducer, u as identityContract } from "./reducers-8jqLewgw.mjs";
16
+ import { a as INBOXES_SESSION, c as accessContract, d as inboxSession, f as registryContract, g as BOARD_SESSION, h as watchSession, i as ACCESS_SESSION, l as boardContract, m as watchContract, n as channelsReducer, o as REGISTRY_SESSION, p as schedulesContract, s as SCHEDULES_SESSION, t as actorsReducer, u as inboxContract } from "./reducers-DcENwtwZ.mjs";
17
17
  import { n as createServer, t as consumer } from "./server-DX7mM6nm-D7N_7bug.mjs";
18
- import { a as linkedTo, i as iso, n as foldMessages, o as personOf, t as foldActors } from "./folds-DGYkMW19.mjs";
19
- import { c as readUpload, i as finishUpload, l as startUpload, n as attachmentNotFound, t as attachmentBody, u as writeUpload } from "./blob-DQK5prXT.mjs";
20
- import { t as concernedMessageId } from "./project-C_2xs57L.mjs";
21
- import { t as TABLES_CONSUMER } from "./sql-Bj0fk6dn.mjs";
18
+ import { i as iso, n as foldMessages, t as foldActors } from "./folds-Cu9Q9zyQ.mjs";
19
+ import { c as ownUpload, d as writeUpload, i as finishUpload, l as readUpload, n as attachmentNotFound, t as attachmentBody, u as startUpload } from "./blob-WbB2DJKn.mjs";
20
+ import { t as concernedMessageId } from "./project-CQ4VUibA.mjs";
21
+ import { t as TABLES_CONSUMER } from "./sql-CT5xVuGr.mjs";
22
22
  import { t as afterRetry } from "./attempts-HXcskN1w.mjs";
23
23
  import { createHash, randomBytes } from "node:crypto";
24
24
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -35,6 +35,350 @@ async function idempotentId(...parts) {
35
35
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
36
36
  }
37
37
  //#endregion
38
+ //#region src/server/trust.ts
39
+ /** How long a token lives when its minter doesn't say, and at most: npm caps its write tokens at 90 days. */
40
+ const TOKEN_DEFAULT_MS = 2592e6;
41
+ /** How deep tokens minted with tokens go: each request walks the chain to its root. */
42
+ const TOKEN_DEPTH_MAX = 8;
43
+ const DAY_MS = 864e5;
44
+ /** What makes two grants one: the issuer, and the claims with their names sorted. */
45
+ const grantKey = (issuer, claims) => JSON.stringify([issuer, Object.keys(claims).sort().map((name) => [name, claims[name]])]);
46
+ function dropToken(state, id) {
47
+ const row = state.tokens[id];
48
+ if (!row) return;
49
+ delete state.hashes[row.hash];
50
+ delete state.tokens[id];
51
+ }
52
+ /** Whether token `id` is token `ancestor`, or was minted with it at any depth. */
53
+ function mintedWith(state, id, ancestor) {
54
+ let at = id;
55
+ for (let depth = 0; at !== null && depth <= TOKEN_DEPTH_MAX; depth++) {
56
+ if (at === ancestor) return true;
57
+ at = state.tokens[at]?.parent ?? null;
58
+ }
59
+ return false;
60
+ }
61
+ /**
62
+ * Whether `identity` may see and revoke token `row`: anyone's with `manage` in every channel. A
63
+ * token acts as its minter, so its principal alone would hand it every token its minter has: with
64
+ * a token, it's that token and those minted with it; without one, those its principal minted.
65
+ */
66
+ function manages(state, { principal }, row) {
67
+ if (principal.access.everywhere("manage")) return true;
68
+ if (row.minter.id !== principal.id) return false;
69
+ if (principal.token === void 0) return true;
70
+ const held = indexOf$1(principal.token, "t");
71
+ return held !== null && mintedWith(state, row.id, held);
72
+ }
73
+ /**
74
+ * The access session's fold. It prunes by the events' own times, so every instance folds the same
75
+ * state, and each event costs a day's expired tokens at most. A child whose parent is gone counts
76
+ * as dead (`liveIn`), and lives no longer than its parent, so pruning never brings one back.
77
+ */
78
+ function foldAccess(state, event) {
79
+ const at = new Date(event.createdAt).getTime();
80
+ const day = Math.floor(at / DAY_MS);
81
+ if (state.pruned === null) state.pruned = day;
82
+ for (; state.pruned < day; state.pruned++) {
83
+ for (const id of state.expiring[state.pruned] ?? []) dropToken(state, id);
84
+ delete state.expiring[state.pruned];
85
+ }
86
+ switch (event.type) {
87
+ case "grant.set": {
88
+ const { issuer, claims, can, by } = event.payload;
89
+ const key = grantKey(issuer, claims);
90
+ const existing = state.grants[key];
91
+ if (existing) Object.assign(existing, {
92
+ can,
93
+ by,
94
+ at
95
+ });
96
+ else {
97
+ state.grants[key] = {
98
+ id: event.index,
99
+ issuer,
100
+ claims,
101
+ can,
102
+ by,
103
+ at
104
+ };
105
+ state.grantKeys[event.index] = key;
106
+ }
107
+ break;
108
+ }
109
+ case "grant.revoked": {
110
+ const key = state.grantKeys[event.payload.id];
111
+ if (key !== void 0) {
112
+ delete state.grants[key];
113
+ delete state.grantKeys[event.payload.id];
114
+ }
115
+ break;
116
+ }
117
+ case "token.minted": {
118
+ const { payload } = event;
119
+ if (state.hashes[payload.hash] !== void 0) break;
120
+ state.tokens[event.index] = {
121
+ id: event.index,
122
+ ...payload,
123
+ minter: payload.minter,
124
+ at
125
+ };
126
+ state.hashes[payload.hash] = event.index;
127
+ (state.expiring[Math.floor(payload.expiresAt / DAY_MS)] ??= []).push(event.index);
128
+ break;
129
+ }
130
+ case "token.revoked": dropToken(state, event.payload.id);
131
+ }
132
+ return state;
133
+ }
134
+ const accessReducer = reducer({
135
+ contract: accessContract,
136
+ name: "board.access.v1",
137
+ initialState: {
138
+ grants: {},
139
+ grantKeys: {},
140
+ tokens: {},
141
+ hashes: {},
142
+ expiring: {},
143
+ pruned: null
144
+ }
145
+ }).fold(foldAccess);
146
+ const grantId = (id) => `g${id}`;
147
+ const tokenId = (id) => `t${id}`;
148
+ /** The index an ID names: `g12` or `t12` for 12. Null for one that names none. */
149
+ function indexOf$1(id, prefix) {
150
+ const found = new RegExp(`^${prefix}([1-9]\\d{0,15})$`).exec(id);
151
+ return found ? Number(found[1]) : null;
152
+ }
153
+ const grantView = (row) => ({
154
+ id: grantId(row.id),
155
+ issuer: row.issuer,
156
+ claims: row.claims,
157
+ can: compileAccess(row.can).summary(),
158
+ by: row.by,
159
+ at: new Date(row.at).toISOString()
160
+ });
161
+ /** A token as the protocol shows it, with `can` what it may do now (`accessNow`'s, for its minter). */
162
+ const tokenView = (row, can) => ({
163
+ id: tokenId(row.id),
164
+ name: row.name,
165
+ can: can === null ? {} : can.summary(),
166
+ profile: row.profile,
167
+ session: row.session,
168
+ by: row.by,
169
+ at: new Date(row.at).toISOString(),
170
+ expiresAt: new Date(row.expiresAt).toISOString(),
171
+ parent: row.parent === null ? null : tokenId(row.parent)
172
+ });
173
+ /** A new token's secret: the prefix and 32 random bytes, base64url. */
174
+ function newSecret() {
175
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
176
+ return `${BOARD_TOKEN_PREFIX}${btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
177
+ }
178
+ const needsManage = (identity, what) => {
179
+ if (!identity.principal.access.everywhere("manage")) throw forbidden(`this token can't ${what}: it needs manage in every channel`);
180
+ };
181
+ /**
182
+ * The board's grants and tokens, in session `access` of its log: what `a2()` answers for them, and
183
+ * what board() checks requests against. `issuers`: the OIDC issuers the board's rules name, which no
184
+ * grant's may share principals with. `accessNow`: what a principal may do now, under board()'s
185
+ * rules and grants, which a token's listing shows.
186
+ */
187
+ function createTrust(options) {
188
+ const session = createServer({
189
+ contract: accessContract,
190
+ store: options.store
191
+ }).session(ACCESS_SESSION);
192
+ const read = async () => (await session.state(accessReducer)).state;
193
+ /**
194
+ * What each of `rows` may do now: its minter's access, narrowed by its scope. A minter's is worked
195
+ * out once, however many tokens it has.
196
+ */
197
+ async function viewsOf(rows) {
198
+ const minters = /* @__PURE__ */ new Map();
199
+ return Promise.all(rows.map(async (row) => {
200
+ const { scope, ...minter } = row.minter;
201
+ const key = JSON.stringify(minter);
202
+ let access = minters.get(key);
203
+ if (!access) minters.set(key, access = options.accessNow(minter));
204
+ const now = await access;
205
+ return tokenView(row, now === null || scope === void 0 ? now : now.narrow(parseScope(scope)));
206
+ }));
207
+ }
208
+ /** Whether token `id` and every token it was minted with are live now. */
209
+ function liveIn(state, id) {
210
+ let at = id;
211
+ for (let depth = 0; at !== null; depth++) {
212
+ const row = state.tokens[at];
213
+ if (!row || row.expiresAt <= Date.now() || depth > TOKEN_DEPTH_MAX) return false;
214
+ at = row.parent;
215
+ }
216
+ return true;
217
+ }
218
+ return {
219
+ trust: {
220
+ async grants() {
221
+ return Object.values((await read()).grants).map((row) => ({
222
+ ...grantView(row),
223
+ can: row.can
224
+ }));
225
+ },
226
+ async token(hash) {
227
+ const state = await read();
228
+ const id = state.hashes[hash];
229
+ if (id === void 0 || !liveIn(state, id)) return null;
230
+ const row = state.tokens[id];
231
+ return {
232
+ id: tokenId(row.id),
233
+ name: row.name,
234
+ minter: row.minter,
235
+ pinned: {
236
+ profile: row.profile,
237
+ avatar: row.avatar,
238
+ session: row.session,
239
+ handle: row.handle,
240
+ name: row.personName
241
+ },
242
+ expiresAt: new Date(row.expiresAt)
243
+ };
244
+ },
245
+ async live(id) {
246
+ const index = indexOf$1(id, "t");
247
+ return index !== null && liveIn(await read(), index);
248
+ }
249
+ },
250
+ async grants(identity) {
251
+ needsManage(identity, "list grants");
252
+ return { grants: Object.values((await read()).grants).sort((a, b) => a.id - b.id).map(grantView) };
253
+ },
254
+ async createGrant(identity, body) {
255
+ needsManage(identity, "set grants");
256
+ const before = await read();
257
+ const checked = checkGrant(body, [...options.issuers(), ...grantIssuers(Object.values(before.grants))]);
258
+ const key = grantKey(checked.issuer, checked.claims);
259
+ const created = before.grants[key] === void 0;
260
+ await session.append({
261
+ type: "grant.set",
262
+ payload: {
263
+ issuer: checked.issuer,
264
+ claims: checked.claims,
265
+ can: checked.can,
266
+ by: identity.actor.id
267
+ }
268
+ });
269
+ const row = (await read()).grants[key];
270
+ return {
271
+ grant: grantView(row),
272
+ created
273
+ };
274
+ },
275
+ async revokeGrant(identity, { id }) {
276
+ needsManage(identity, "revoke grants");
277
+ const index = indexOf$1(id, "g");
278
+ if (index === null || (await read()).grantKeys[index] === void 0) return { revoked: false };
279
+ await session.append({
280
+ type: "grant.revoked",
281
+ payload: {
282
+ id: index,
283
+ by: identity.actor.id
284
+ }
285
+ });
286
+ return { revoked: true };
287
+ },
288
+ async tokens(identity) {
289
+ const state = await read();
290
+ return { tokens: await viewsOf(Object.values(state.tokens).filter((row) => liveIn(state, row.id) && manages(state, identity, row)).sort((a, b) => a.id - b.id)) };
291
+ },
292
+ /** The board tokens `principal` came with: its own, then each it was minted with. Undefined with none. */
293
+ async chain(principal) {
294
+ if (principal.token === void 0) return void 0;
295
+ const state = await read();
296
+ const chain = [];
297
+ for (let at = indexOf$1(principal.token, "t"); at !== null && chain.length <= TOKEN_DEPTH_MAX; at = state.tokens[at]?.parent ?? null) chain.push(tokenId(at));
298
+ return chain;
299
+ },
300
+ async createToken(identity, body) {
301
+ const { principal, author, actor } = identity;
302
+ const state = await read();
303
+ const now = Date.now();
304
+ const asked = body.expires === void 0 ? TOKEN_DEFAULT_MS : duration(body.expires);
305
+ if (asked <= 0 || asked > 7776e6) throw badRequest("a token lives up to 90 days: pass expires as 90d or less", "invalid_expires");
306
+ let expiresAt = now + asked;
307
+ let parent = null;
308
+ if (principal.token !== void 0) {
309
+ const index = indexOf$1(principal.token, "t");
310
+ parent = index === null ? null : state.tokens[index] ?? null;
311
+ if (!parent || !liveIn(state, parent.id)) throw unauthorized("invalid token: revoked, expired, or not this board's");
312
+ let depth = 0;
313
+ for (let at = parent; at; at = at.parent === null ? void 0 : state.tokens[at.parent]) depth++;
314
+ if (depth >= TOKEN_DEPTH_MAX) throw badRequest(`tokens minted with tokens go ${TOKEN_DEPTH_MAX} deep at most`, "invalid_token");
315
+ expiresAt = Math.min(expiresAt, parent.expiresAt);
316
+ }
317
+ let can;
318
+ try {
319
+ can = compileAccess(body.can ?? DEFAULT_CAN);
320
+ } catch (error) {
321
+ throw badRequest(error.message, "invalid_scope");
322
+ }
323
+ const scope = scopeOf((principal.scope === void 0 ? FULL_ACCESS : compileAccess(parseScope(principal.scope))).narrow(can));
324
+ if (scope.length > 4e3) throw badRequest(`a token's can is ${SCOPE_MAX} characters at most as a scope: name fewer channels`, "invalid_scope");
325
+ const minter = {
326
+ id: principal.id,
327
+ kind: principal.kind,
328
+ handle: principal.handle,
329
+ ...principal.facts ? { facts: principal.facts } : {},
330
+ scope
331
+ };
332
+ const pinned = principal.pinned;
333
+ const profile = pinned?.profile ?? body.profile ?? null;
334
+ const avatar = pinned?.profile ? pinned.avatar : profile !== null ? body.avatar ?? null : null;
335
+ const pinnedSession = pinned?.session ?? body.session ?? null;
336
+ const person = principal.kind === "person";
337
+ const base = author.profile === null ? actor.handle : actor.handle.slice(0, Math.max(0, actor.handle.length - author.profile.length - 1)) || principal.handle;
338
+ const handle = pinned?.handle ?? (person ? base : null);
339
+ const personName = pinned?.name ?? (person && author.profile === null ? actor.name : null);
340
+ const secret = newSecret();
341
+ const [row] = await session.append({
342
+ type: "token.minted",
343
+ payload: {
344
+ hash: await tokenHash(secret),
345
+ name: body.name,
346
+ minter,
347
+ profile,
348
+ avatar,
349
+ session: pinnedSession,
350
+ handle,
351
+ personName,
352
+ expiresAt,
353
+ parent: parent?.id ?? null,
354
+ by: actor.id
355
+ }
356
+ });
357
+ const minted = row && (await read()).tokens[row.index];
358
+ if (!minted) throw new Error("a token was minted but its log has no record of it");
359
+ const [view] = await viewsOf([minted]);
360
+ return {
361
+ token: view,
362
+ secret
363
+ };
364
+ },
365
+ async revokeToken(identity, { id }) {
366
+ const state = await read();
367
+ const index = indexOf$1(id, "t");
368
+ const row = index === null ? void 0 : state.tokens[index];
369
+ if (!row || !liveIn(state, row.id) || !manages(state, identity, row)) return { revoked: false };
370
+ await session.append({
371
+ type: "token.revoked",
372
+ payload: {
373
+ id: row.id,
374
+ by: identity.actor.id
375
+ }
376
+ });
377
+ return { revoked: true };
378
+ }
379
+ };
380
+ }
381
+ //#endregion
38
382
  //#region src/server/feed.ts
39
383
  /** Bookkeeping events, shown only with `all`: who's who, and delivery receipts. */
40
384
  const HIDDEN = /* @__PURE__ */ new Set([
@@ -42,27 +386,14 @@ const HIDDEN = /* @__PURE__ */ new Set([
42
386
  "message.synced",
43
387
  "message.files_synced"
44
388
  ]);
45
- /** The person a linked platform actor is, as views show it. */
46
- function personView(actors, actor) {
47
- const link = own(actors.links, actor);
48
- const person = link ? own(actors.byId, link.to) : void 0;
49
- return link ? {
50
- id: link.to,
51
- handle: person?.handle ?? link.to,
52
- name: person?.name ?? link.to,
53
- avatar: person?.avatar ?? null
54
- } : void 0;
55
- }
56
389
  function actorView(actors, id) {
57
390
  const author = own(actors.byId, id);
58
391
  if (!author) return null;
59
- const person = personView(actors, id);
60
392
  return {
61
393
  kind: author.kind,
62
394
  handle: author.handle,
63
395
  name: author.name,
64
- avatar: author.avatar,
65
- ...person ? { person } : {}
396
+ avatar: author.avatar
66
397
  };
67
398
  }
68
399
  /** The board's own code, which reads every channel: it needs no messages to say so. */
@@ -150,30 +481,17 @@ function view(message, actors, reader) {
150
481
  author: actorView(actors, message.by.actor)
151
482
  };
152
483
  }
153
- /** Whether accounts are linked to `id`: then it's a person, even one who never wrote on the board. */
154
- const hasLinks = (actors, id) => Object.values(actors.links).some((link) => link.to === id);
155
- const actorSet = (value, actors) => own(actors.byId, value) || own(actors.links, value) || hasLinks(actors, value) ? /* @__PURE__ */ new Set([value]) : new Set(own(actors.byHandle, value.toLowerCase()) ?? []);
484
+ const actorSet = (value, actors) => own(actors.byId, value) ? /* @__PURE__ */ new Set([value]) : new Set(own(actors.byHandle, value.toLowerCase()) ?? []);
156
485
  /**
157
- * Who a `--by` or `--mentions` value covers. A person (`julian`, or one of their
158
- * linked accounts, `@julian` on Slack) is everyone they are: their own actor,
159
- * their agents (`julian/claude`), and every account linked to them. An agent
160
- * or an unlinked platform actor is just itself.
486
+ * Who a `--by` or `--mentions` value covers. A person (`julian`) is everyone they
487
+ * are: their own actor, and their agents (`julian/claude`). An agent or a platform
488
+ * actor is just itself.
161
489
  */
162
490
  function whoIs(value, actors) {
163
491
  const ids = actorSet(value, actors);
164
- const out = new Set(ids);
165
- const principals = /* @__PURE__ */ new Set();
166
- for (const id of ids) {
167
- const kind = own(actors.byId, id)?.kind;
168
- const person = own(actors.links, id) ? personOf(actors, id) : kind === "person" || hasLinks(actors, id) ? id : null;
169
- if (person === null) continue;
170
- principals.add(person);
171
- out.add(person);
172
- for (const linked of linkedTo(actors, person)) out.add(linked);
173
- }
174
492
  return {
175
- actors: out,
176
- principals
493
+ actors: ids,
494
+ principals: new Set([...ids].filter((id) => own(actors.byId, id)?.kind === "person"))
177
495
  };
178
496
  }
179
497
  /** Whether `author` is one of `who`: its actor, or an agent of one of its people. */
@@ -194,7 +512,7 @@ function compileFilter(filters, context) {
194
512
  const subtree = channel?.endsWith("/") ? channel.slice(0, -1) : null;
195
513
  const by = filters.by === void 0 ? null : whoIs(filters.by, context.actors);
196
514
  const re = filters.re === void 0 ? void 0 : indexOf(filters.re) ?? -1;
197
- const mentions = filters.mentions === void 0 ? null : filters.mentions === "me" ? new Set(context.me === null ? [] : [context.me, ...linkedTo(context.actors, context.me)]) : whoIs(filters.mentions, context.actors).actors;
515
+ const mentions = filters.mentions === void 0 ? null : filters.mentions === "me" ? new Set(context.me === null ? [] : [context.me]) : whoIs(filters.mentions, context.actors).actors;
198
516
  return (message) => {
199
517
  if (!message || !access.readable(message.channel)) return false;
200
518
  if (channel !== void 0) {
@@ -247,7 +565,7 @@ function toMessageFilter(filters, context) {
247
565
  principals: [...who.principals]
248
566
  };
249
567
  }
250
- if (filters.mentions !== void 0) out.mentions = filters.mentions === "me" ? context.me === null ? [] : [context.me, ...linkedTo(context.actors, context.me)] : [...whoIs(filters.mentions, context.actors).actors];
568
+ if (filters.mentions !== void 0) out.mentions = filters.mentions === "me" ? context.me === null ? [] : [context.me] : [...whoIs(filters.mentions, context.actors).actors];
251
569
  if (filters.from !== void 0) out.from = filters.from;
252
570
  if (filters.text !== void 0) out.text = filters.text.toLowerCase();
253
571
  if (filters.label?.length) out.labels = filters.label.map(([key, values]) => ({
@@ -684,13 +1002,15 @@ async function authorizeMessage(ctx, access, permission, id) {
684
1002
  * read, it's yours only if it was posted without reading too: a write-only
685
1003
  * source fixes what it wrote, but a principal ID it shares with a caller that
686
1004
  * reads (a repo's workflows share one) doesn't reach that caller's messages.
1005
+ * A board token that pins a profile owns only what was written as it (`wroteAs`).
687
1006
  * Someone else's answers 404 where you can't read it, and 403 where you can.
688
1007
  */
689
- async function authorizeOwn(ctx, principal, id, verb) {
1008
+ async function authorizeOwn(ctx, identity, id, verb) {
1009
+ const { principal } = identity;
690
1010
  const message = await ctx.reads.message(id);
691
1011
  if (!message) throw messageNotFound(id)();
692
1012
  const readable = principal.access.readable(message.channel);
693
- if (message.by.principal !== principal.id || !readable && !message.blind) {
1013
+ if (!wroteAs(identity, message.by) || !readable && !message.blind) {
694
1014
  if (!readable) throw messageNotFound(id)();
695
1015
  throw forbidden(`only the author can ${verb} a message`);
696
1016
  }
@@ -961,16 +1281,22 @@ const LANDED_WAIT_MS = 1e4;
961
1281
  * anywhere on the board, moves it, and it looks again from there.
962
1282
  */
963
1283
  async function appendWhile(ctx, holds, events) {
964
- for (let attempt = 1;; attempt++) {
1284
+ return whileHeadMoves(async () => {
965
1285
  await ctx.reads.ensure();
966
- const position = await holds();
967
- try {
968
- return await appendIfLast(ctx.session, position, events);
969
- } catch (error) {
970
- if (!headMoved(error)) throw error;
971
- if (attempt === CONDITIONAL_TRIES) throw new HttpError(503, "unavailable", "the board kept changing while it checked: try again");
972
- await new Promise((resolve) => setTimeout(resolve, Math.random() * 5 * attempt));
973
- }
1286
+ return appendIfLast(ctx.session, await holds(), events);
1287
+ });
1288
+ }
1289
+ /**
1290
+ * Runs `attempt`, a look and a conditional append, again each time the board moves on in
1291
+ * between, up to `CONDITIONAL_TRIES` times: how every conditional write here looks again.
1292
+ */
1293
+ async function whileHeadMoves(attempt) {
1294
+ for (let tries = 1;; tries++) try {
1295
+ return await attempt();
1296
+ } catch (error) {
1297
+ if (!headMoved(error)) throw error;
1298
+ if (tries === CONDITIONAL_TRIES) throw new HttpError(503, "unavailable", "the board kept changing while it checked: try again");
1299
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 5 * tries));
974
1300
  }
975
1301
  }
976
1302
  /** How many times a conditional write looks again, as the board moves on, before it gives up. */
@@ -1131,11 +1457,136 @@ async function writeAttach(ctx, input) {
1131
1457
  }));
1132
1458
  return { attached: true };
1133
1459
  }
1460
+ /**
1461
+ * A channel made before anything posts to it, or the one that's there, made by a post or another
1462
+ * create, which `created: false` says. It needs `read` and `post` there, refused alike whether it's
1463
+ * there or not: the answer says whether it was, and shows what it holds.
1464
+ */
1465
+ async function writeChannel(ctx, input) {
1466
+ const { access } = input.identity.principal;
1467
+ authorize(access, "read", input.path);
1468
+ authorize(access, "post", input.path);
1469
+ const seen = seenFor(input.identity, (await ctx.session.state(actorsReducer)).state);
1470
+ const events = [...seen ? [seen] : [], {
1471
+ type: "channel.created",
1472
+ payload: {
1473
+ channel: input.path,
1474
+ by: input.identity.author
1475
+ }
1476
+ }];
1477
+ const made = await whileHeadMoves(async () => {
1478
+ const { state, index } = await ctx.session.state(channelsReducer, { through: "latest" });
1479
+ const found = own(state, input.path);
1480
+ return found ? { found } : { rows: await appendIfLast(ctx.session, index, events) };
1481
+ });
1482
+ if ("found" in made) return {
1483
+ channel: made.found,
1484
+ created: false
1485
+ };
1486
+ await untilApplied(ctx, made.rows);
1487
+ return {
1488
+ channel: {
1489
+ path: input.path,
1490
+ lastIndex: 0,
1491
+ messages: 0,
1492
+ threads: 0
1493
+ },
1494
+ created: true
1495
+ };
1496
+ }
1134
1497
  /** The batch, only while `index` is still the session's last: A2's conditional append. */
1135
1498
  const appendIfLast = (session, index, events) => session.append.ifLastIndex(index, ...events);
1136
1499
  /** What A2 throws when the session's last index isn't the one a conditional append named. */
1137
1500
  const headMoved = (error) => error?.code === "LAST_INDEX_MISMATCH";
1138
1501
  //#endregion
1502
+ //#region src/server/registry.ts
1503
+ /** A principal as a registration keeps it: enough to work out its access again at each match. */
1504
+ const storedPrincipal = (principal) => ({
1505
+ id: principal.id,
1506
+ kind: principal.kind,
1507
+ handle: principal.handle,
1508
+ ...principal.facts ? { facts: principal.facts } : {},
1509
+ ...principal.scope !== void 0 ? { scope: principal.scope } : {},
1510
+ ...principal.token !== void 0 ? { token: principal.token } : {}
1511
+ });
1512
+ /** What a registration shows: the principal's token facts stay on the board. */
1513
+ const viewOf = ({ principal: _principal, ...view }) => view;
1514
+ /**
1515
+ * The same on both ends: a board on `http()` hashes its watchers, and whether its sync subscribes,
1516
+ * and the board it uses hashes what it holds.
1517
+ */
1518
+ function watchersHash(watch, sync = []) {
1519
+ return createHash("sha256").update(canonicalWatchers(watch, sync)).digest("hex").slice(0, 32);
1520
+ }
1521
+ function foldRegistry(state, event) {
1522
+ if (event.type === "watchers.registered") {
1523
+ const { owner, principal, url, watch, sync, hash } = event.payload;
1524
+ const copied = Object.fromEntries(Object.entries(watch).map(([id, spec]) => [id, {
1525
+ tail: spec.tail,
1526
+ when: spec.when
1527
+ }]));
1528
+ state.byOwner[owner] = {
1529
+ owner,
1530
+ principal: structuredClone(principal),
1531
+ url,
1532
+ watch: copied,
1533
+ sync: (sync ?? []).map((entry) => ({ ...entry })),
1534
+ hash
1535
+ };
1536
+ }
1537
+ return state;
1538
+ }
1539
+ const registryReducer = reducer({
1540
+ contract: registryContract,
1541
+ name: "board.registry.v1",
1542
+ initialState: { byOwner: {} }
1543
+ }).fold(foldRegistry);
1544
+ /** Watchers registered with this board by boards on `http()`. */
1545
+ function createRegistry(options) {
1546
+ const server = createServer({
1547
+ contract: registryContract,
1548
+ ...options.store ? { store: options.store } : {},
1549
+ ...options.scheduler ? { scheduler: options.scheduler } : {}
1550
+ });
1551
+ const session = server.session(REGISTRY_SESSION);
1552
+ const state = async () => (await session.state(registryReducer)).state;
1553
+ return {
1554
+ server,
1555
+ state,
1556
+ /**
1557
+ * Replaces `principal`'s registration. One that changes nothing writes nothing, so asking
1558
+ * again is free, unless the principal's token facts changed: those decide what it may read.
1559
+ */
1560
+ async register(principal, input) {
1561
+ const owner = principal.id;
1562
+ const hash = watchersHash(input.watch, input.sync);
1563
+ const current = (await state()).byOwner[owner];
1564
+ if (current && current.url === input.url && current.hash === hash && JSON.stringify(current.principal) === JSON.stringify(principal)) return viewOf(current);
1565
+ await session.append({
1566
+ type: "watchers.registered",
1567
+ payload: {
1568
+ owner,
1569
+ principal,
1570
+ url: input.url,
1571
+ watch: input.watch,
1572
+ ...input.sync.length > 0 ? { sync: input.sync } : {},
1573
+ hash
1574
+ }
1575
+ });
1576
+ return {
1577
+ owner,
1578
+ url: input.url,
1579
+ watch: input.watch,
1580
+ sync: input.sync,
1581
+ hash
1582
+ };
1583
+ },
1584
+ async list() {
1585
+ return Object.values((await state()).byOwner).sort((a, b) => a.owner.localeCompare(b.owner)).map(viewOf);
1586
+ }
1587
+ };
1588
+ }
1589
+ //#endregion
1139
1590
  //#region src/server/schedules.ts
1140
1591
  /** Vercel Queues delays at most 6 days: a send further out than this goes in hops. */
1141
1592
  const MAX_HOP_MS = 432e6;
@@ -1230,8 +1681,12 @@ function nextSend(schedule, n, at, now) {
1230
1681
  * needs `post` there now, as it would to create it. Where it can't read, the
1231
1682
  * schedule is its own only if it was created without reading too, so a
1232
1683
  * principal ID shared with a caller that reads doesn't reach that caller's.
1684
+ * A board token that pins a profile owns only what was scheduled as it (`wroteAs`).
1233
1685
  */
1234
- const createdBy = (schedule, principal) => schedule.as.principal.id === principal.id && principal.access.can("post", schedule.channel) && (schedule.blind || principal.access.readable(schedule.channel));
1686
+ const createdBy = (schedule, identity) => wroteAs(identity, {
1687
+ principal: schedule.as.principal.id,
1688
+ actor: schedule.as.actor.id
1689
+ }) && identity.principal.access.can("post", schedule.channel) && (schedule.blind || identity.principal.access.readable(schedule.channel));
1235
1690
  const scheduleView = (schedule, actors) => {
1236
1691
  const author = actors.byId[schedule.as.actor.id] ?? schedule.as.actor;
1237
1692
  return {
@@ -1506,7 +1961,10 @@ function createSchedules(options) {
1506
1961
  if (every !== null && every > 316224e5) throw badRequest("`every` is at most a year");
1507
1962
  const first = body.at !== void 0 ? Date.parse(body.at) : now + duration(body.in ?? body.every);
1508
1963
  if (first < now - 6e4) throw badRequest("`at` is in the past");
1509
- const until = body.until !== void 0 ? Date.parse(body.until) : body.for !== void 0 ? first + duration(body.for) : null;
1964
+ const asked = body.until !== void 0 ? Date.parse(body.until) : body.for !== void 0 ? first + duration(body.for) : null;
1965
+ const expires = identity.principal.token !== void 0 ? identity.principal.expiresAt?.getTime() ?? null : null;
1966
+ if (expires !== null && first > expires) throw badRequest(`this token expires ${new Date(expires).toISOString()}, before the schedule's first send: schedule it with a credential that lasts`, "token_expires_first");
1967
+ const until = expires !== null && (asked === null || asked > expires) ? expires : asked;
1510
1968
  if (until !== null && until < first) throw badRequest("the schedule stops before its first send");
1511
1969
  if (first > now + 316224e5 || until !== null && until > now + 316224e5) throw badRequest("a schedule reaches at most a year out: its first send and its stop");
1512
1970
  const { state: known } = await writes.session.state(actorsReducer);
@@ -1535,13 +1993,7 @@ function createSchedules(options) {
1535
1993
  times: body.times ?? null,
1536
1994
  as: {
1537
1995
  author: identity.author,
1538
- principal: {
1539
- id: identity.principal.id,
1540
- kind: identity.principal.kind,
1541
- handle: identity.principal.handle,
1542
- ...identity.principal.facts ? { facts: identity.principal.facts } : {},
1543
- ...identity.principal.scope !== void 0 ? { scope: identity.principal.scope } : {}
1544
- },
1996
+ principal: storedPrincipal(identity.principal),
1545
1997
  actor: identity.actor
1546
1998
  },
1547
1999
  ...identity.principal.access.readable(channel) ? {} : { blind: true }
@@ -1568,7 +2020,7 @@ function createSchedules(options) {
1568
2020
  const schedule = state.byId[id];
1569
2021
  const missing = () => notFound(`schedule not found: s${id}`, "schedule_not_found");
1570
2022
  if (!schedule) throw missing();
1571
- if (!createdBy(schedule, identity.principal)) authorize(identity.principal.access, "manage", schedule.channel, missing);
2023
+ if (!createdBy(schedule, identity)) authorize(identity.principal.access, "manage", schedule.channel, missing);
1572
2024
  const canceled = schedule.next !== null;
1573
2025
  if (canceled) await session.append({
1574
2026
  type: "schedule.canceled",
@@ -2244,93 +2696,6 @@ function createWatchers(options) {
2244
2696
  };
2245
2697
  }
2246
2698
  //#endregion
2247
- //#region src/server/registry.ts
2248
- /** A principal as a registration keeps it: enough to work out its access again at each match. */
2249
- const storedPrincipal = (principal) => ({
2250
- id: principal.id,
2251
- kind: principal.kind,
2252
- handle: principal.handle,
2253
- ...principal.facts ? { facts: principal.facts } : {},
2254
- ...principal.scope !== void 0 ? { scope: principal.scope } : {}
2255
- });
2256
- /** What a registration shows: the principal's token facts stay on the board. */
2257
- const viewOf = ({ principal: _principal, ...view }) => view;
2258
- /**
2259
- * The same on both ends: a board on `http()` hashes its watchers, and whether its sync subscribes,
2260
- * and the board it uses hashes what it holds.
2261
- */
2262
- function watchersHash(watch, sync = []) {
2263
- return createHash("sha256").update(canonicalWatchers(watch, sync)).digest("hex").slice(0, 32);
2264
- }
2265
- function foldRegistry(state, event) {
2266
- if (event.type === "watchers.registered") {
2267
- const { owner, principal, url, watch, sync, hash } = event.payload;
2268
- const copied = Object.fromEntries(Object.entries(watch).map(([id, spec]) => [id, {
2269
- tail: spec.tail,
2270
- when: spec.when
2271
- }]));
2272
- state.byOwner[owner] = {
2273
- owner,
2274
- principal: structuredClone(principal),
2275
- url,
2276
- watch: copied,
2277
- sync: (sync ?? []).map((entry) => ({ ...entry })),
2278
- hash
2279
- };
2280
- }
2281
- return state;
2282
- }
2283
- const registryReducer = reducer({
2284
- contract: registryContract,
2285
- name: "board.registry.v1",
2286
- initialState: { byOwner: {} }
2287
- }).fold(foldRegistry);
2288
- /** Watchers registered with this board by boards on `http()`. */
2289
- function createRegistry(options) {
2290
- const server = createServer({
2291
- contract: registryContract,
2292
- ...options.store ? { store: options.store } : {},
2293
- ...options.scheduler ? { scheduler: options.scheduler } : {}
2294
- });
2295
- const session = server.session(REGISTRY_SESSION);
2296
- const state = async () => (await session.state(registryReducer)).state;
2297
- return {
2298
- server,
2299
- state,
2300
- /**
2301
- * Replaces `principal`'s registration. One that changes nothing writes nothing, so asking
2302
- * again is free, unless the principal's token facts changed: those decide what it may read.
2303
- */
2304
- async register(principal, input) {
2305
- const owner = principal.id;
2306
- const hash = watchersHash(input.watch, input.sync);
2307
- const current = (await state()).byOwner[owner];
2308
- if (current && current.url === input.url && current.hash === hash && JSON.stringify(current.principal) === JSON.stringify(principal)) return viewOf(current);
2309
- await session.append({
2310
- type: "watchers.registered",
2311
- payload: {
2312
- owner,
2313
- principal,
2314
- url: input.url,
2315
- watch: input.watch,
2316
- ...input.sync.length > 0 ? { sync: input.sync } : {},
2317
- hash
2318
- }
2319
- });
2320
- return {
2321
- owner,
2322
- url: input.url,
2323
- watch: input.watch,
2324
- sync: input.sync,
2325
- hash
2326
- };
2327
- },
2328
- async list() {
2329
- return Object.values((await state()).byOwner).sort((a, b) => a.owner.localeCompare(b.owner)).map(viewOf);
2330
- }
2331
- };
2332
- }
2333
- //#endregion
2334
2699
  //#region src/server/transport.ts
2335
2700
  /** Addresses a wake-up never reaches: this machine, private networks, link-local, and the rest that aren't the internet. */
2336
2701
  const NOT_PUBLIC = new BlockList();
@@ -2532,17 +2897,6 @@ function externalIdentity(author, records) {
2532
2897
  ...records ? {} : { records: false }
2533
2898
  };
2534
2899
  }
2535
- /**
2536
- * A one-time code for linking an account: `vb-` and eight letters and digits that
2537
- * can't be misread. Only its hash is kept.
2538
- */
2539
- const CODE_LETTERS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
2540
- const CODE_TTL_MS = 6e5;
2541
- const newCode = () => {
2542
- const letters = [...randomBytes(8)].map((byte) => CODE_LETTERS[byte % 31]).join("");
2543
- return `vb-${letters.slice(0, 4)}-${letters.slice(4)}`;
2544
- };
2545
- const hashCode = (code) => createHash("sha256").update(code.toUpperCase()).digest("base64url");
2546
2900
  /** The board events sync delivers: what a platform shows, and a thread's new targets. */
2547
2901
  const DELIVERED = [
2548
2902
  "thread.synced",
@@ -2695,10 +3049,11 @@ function build(options, binding) {
2695
3049
  ...options.scheduler ? { scheduler: options.scheduler } : {},
2696
3050
  consumers: [inbound]
2697
3051
  });
2698
- const codes = createServer({
2699
- contract: identityContract,
2700
- store: log
2701
- }).session(IDENTITY_SESSION);
3052
+ const access = createTrust({
3053
+ store: log,
3054
+ issuers: () => binding.issuers(),
3055
+ accessNow: (principal) => binding.accessNow(principal)
3056
+ });
2702
3057
  const inboxes = inboxServer.session(INBOXES_SESSION);
2703
3058
  /** The inboxes this instance knows have an `inbox.opened`, so most pushes write nothing more. */
2704
3059
  const opened = /* @__PURE__ */ new Set();
@@ -2712,8 +3067,8 @@ function build(options, binding) {
2712
3067
  * Whether `principal` may have a file: one it uploaded, or one a message it
2713
3068
  * can read carries. A deleted message drops its files, so it no longer counts.
2714
3069
  */
2715
- async function attachmentReadable(principal, id, uploadedBy) {
2716
- if (principal.access.readsAll || uploadedBy === principal.id) return true;
3070
+ async function attachmentReadable(principal, id, uploader) {
3071
+ if (principal.access.readsAll || ownUpload(uploader, principal)) return true;
2717
3072
  await reads.ensure();
2718
3073
  return (await reads.tables.withAttachment(id)).some((message) => principal.access.readable(message.channel));
2719
3074
  }
@@ -2754,8 +3109,8 @@ function build(options, binding) {
2754
3109
  }
2755
3110
  /** Files a write carries, as the board stored them: each one the caller may have, or 404 as if it didn't exist. */
2756
3111
  const attachmentsFor = (principal, ids) => Promise.all([...new Set(ids ?? [])].map(async (id) => {
2757
- const { attachment, uploadedBy } = await readUpload(blob, id);
2758
- if (!await attachmentReadable(principal, id, uploadedBy)) throw attachmentNotFound(id);
3112
+ const { attachment, ...uploader } = await readUpload(blob, id);
3113
+ if (!await attachmentReadable(principal, id, uploader)) throw attachmentNotFound(id);
2759
3114
  return attachment;
2760
3115
  }));
2761
3116
  /**
@@ -2856,10 +3211,7 @@ function build(options, binding) {
2856
3211
  audience: url,
2857
3212
  team_id: teamId,
2858
3213
  project_id: id,
2859
- sync: (await linkable()).map(({ key, platform }) => ({
2860
- key,
2861
- platform
2862
- }))
3214
+ sync: await syncEntries()
2863
3215
  };
2864
3216
  },
2865
3217
  async me(caller) {
@@ -2885,6 +3237,12 @@ function build(options, binding) {
2885
3237
  cursor: ref(index)
2886
3238
  };
2887
3239
  },
3240
+ async createChannel(caller, { path }) {
3241
+ return writeChannel(writes, {
3242
+ identity: await identityOf(caller),
3243
+ path
3244
+ });
3245
+ },
2888
3246
  async actors(caller) {
2889
3247
  const { principal, actor } = await identityOf(caller);
2890
3248
  const { state, index } = await session.state(actorsReducer);
@@ -2895,31 +3253,7 @@ function build(options, binding) {
2895
3253
  seen = (id) => ids.has(id);
2896
3254
  }
2897
3255
  return {
2898
- actors: Object.values(state.byId).filter((a) => seen === null || seen(a.id)).sort((a, b) => a.handle.localeCompare(b.handle)).map(({ host: _host, ...a }) => {
2899
- const person = personView(state, a.id);
2900
- return person ? {
2901
- ...a,
2902
- person
2903
- } : a;
2904
- }),
2905
- cursor: ref(index)
2906
- };
2907
- },
2908
- async identity(caller) {
2909
- const { principal } = await identityOf(caller);
2910
- const { state, index } = await session.state(actorsReducer);
2911
- const links = linkedTo(state, principal.id).map((id) => ({
2912
- actor: id,
2913
- handle: state.byId[id]?.handle ?? null,
2914
- name: state.byId[id]?.name ?? null,
2915
- method: state.links[id].method,
2916
- at: state.links[id].at
2917
- }));
2918
- const unlinked = (await linkable()).filter((entry) => !links.some((link) => link.actor.startsWith(`${entry.platform}:user:`))).map((entry) => entry.key);
2919
- return {
2920
- principal: principal.id,
2921
- links,
2922
- unlinked,
3256
+ actors: Object.values(state.byId).filter((a) => seen === null || seen(a.id)).sort((a, b) => a.handle.localeCompare(b.handle)).map(({ host: _host, ...a }) => a),
2923
3257
  cursor: ref(index)
2924
3258
  };
2925
3259
  },
@@ -2989,7 +3323,7 @@ function build(options, binding) {
2989
3323
  },
2990
3324
  async edit(caller, { id, text, revision }) {
2991
3325
  const identity = await identityOf(caller);
2992
- const authorized = await authorizeOwn(writes, identity.principal, messageAt(id), "edit");
3326
+ const authorized = await authorizeOwn(writes, identity, messageAt(id), "edit");
2993
3327
  const { unknownMentions } = await writeEdit(writes, {
2994
3328
  identity,
2995
3329
  authorized,
@@ -3003,7 +3337,7 @@ function build(options, binding) {
3003
3337
  },
3004
3338
  async delete(caller, { id, revision }) {
3005
3339
  const identity = await identityOf(caller);
3006
- const authorized = await authorizeOwn(writes, identity.principal, messageAt(id), "delete");
3340
+ const authorized = await authorizeOwn(writes, identity, messageAt(id), "delete");
3007
3341
  await writeDelete(writes, {
3008
3342
  identity,
3009
3343
  authorized,
@@ -3051,7 +3385,10 @@ function build(options, binding) {
3051
3385
  const at = messageAt(query.re);
3052
3386
  root = (await authorizeMessage(writes, principal.access, "read", at)).message.re ?? at;
3053
3387
  }
3054
- const visible = (schedule) => principal.access.readable(schedule.channel) || createdBy(schedule, principal);
3388
+ const visible = (schedule) => principal.access.readable(schedule.channel) || createdBy(schedule, {
3389
+ principal,
3390
+ actor
3391
+ });
3055
3392
  return { schedules: (await schedules.list({
3056
3393
  ...query,
3057
3394
  ...root !== void 0 ? { root } : {}
@@ -3065,6 +3402,12 @@ function build(options, binding) {
3065
3402
  schedule: blindSchedule(answer.schedule, identity.principal.access, identity.actor.id)
3066
3403
  };
3067
3404
  },
3405
+ grants: async (caller) => access.grants(await identityOf(caller)),
3406
+ createGrant: async (caller, body) => access.createGrant(await identityOf(caller), body),
3407
+ revokeGrant: async (caller, input) => access.revokeGrant(await identityOf(caller), input),
3408
+ tokens: async (caller) => access.tokens(await identityOf(caller)),
3409
+ createToken: async (caller, body) => access.createToken(await identityOf(caller), body),
3410
+ revokeToken: async (caller, input) => access.revokeToken(await identityOf(caller), input),
3068
3411
  async upload(caller, { data, name, type }) {
3069
3412
  const { principal } = await identityOf(caller);
3070
3413
  if (!principal.access.anywhere("post") && !principal.access.anywhere("sync")) throw forbidden("this token can't post anywhere, so it can't upload");
@@ -3073,7 +3416,8 @@ function build(options, binding) {
3073
3416
  data,
3074
3417
  name,
3075
3418
  type,
3076
- uploadedBy: principal.id
3419
+ uploadedBy: principal.id,
3420
+ uploadedWith: await access.chain(principal)
3077
3421
  }, max) };
3078
3422
  },
3079
3423
  async uploadStart(caller, { name, type, size }) {
@@ -3083,17 +3427,18 @@ function build(options, binding) {
3083
3427
  name: name ?? null,
3084
3428
  type: type ?? null,
3085
3429
  size,
3086
- uploadedBy: principal.id
3430
+ uploadedBy: principal.id,
3431
+ uploadedWith: await access.chain(principal)
3087
3432
  });
3088
3433
  },
3089
3434
  async uploadFinish(caller, { id }) {
3090
3435
  const { principal } = await identityOf(caller);
3091
- return { attachment: await finishUpload(blob, id, principal.id) };
3436
+ return { attachment: await finishUpload(blob, id, principal) };
3092
3437
  },
3093
3438
  async attachment(caller, { id }) {
3094
3439
  const { principal } = await identityOf(caller);
3095
- const { attachment, uploadedBy } = await readUpload(blob, id);
3096
- if (!await attachmentReadable(principal, attachment.id, uploadedBy)) throw attachmentNotFound(attachment.id);
3440
+ const { attachment, ...uploader } = await readUpload(blob, id);
3441
+ if (!await attachmentReadable(principal, attachment.id, uploader)) throw attachmentNotFound(attachment.id);
3097
3442
  return {
3098
3443
  attachment,
3099
3444
  data: await attachmentBody(blob, attachment.id)
@@ -3451,12 +3796,6 @@ function build(options, binding) {
3451
3796
  const { tables } = store;
3452
3797
  let thread = null;
3453
3798
  let message = null;
3454
- let holder = null;
3455
- if (query.code !== void 0) {
3456
- const issued = (await codes.state(identityCodesReducer)).state[hashCode(query.code)];
3457
- const keys = new Set((await entriesOf(caller, principal.id)).map((entry) => entry.key));
3458
- if (issued && issued.usedBy === null && Date.parse(issued.expiresAt) >= Date.now() && keys.has(issued.sync)) holder = issued.principal;
3459
- }
3460
3799
  if (query.post !== void 0) {
3461
3800
  const landed = await reads.byEventId(importId(caller, principal.id, query.post), signal);
3462
3801
  if (landed && principal.access.can("sync", landed.channel)) message = ref(landed.id);
@@ -3475,22 +3814,9 @@ function build(options, binding) {
3475
3814
  if (found && principal.access.can("sync", found.channel)) message = ref(found.id);
3476
3815
  }
3477
3816
  }
3478
- const { state: actors } = query.account !== void 0 || query.person !== void 0 ? await session.state(actorsReducer) : { state: null };
3479
- const platforms = actors ? (await entriesOf(caller, principal.id)).map((entry) => `${entry.platform}:`) : [];
3480
- const ours = (account) => platforms.some((platform) => account.startsWith(platform));
3481
- const link = query.account !== void 0 && ours(query.account) ? actors?.links[query.account] : void 0;
3482
3817
  return {
3483
3818
  thread,
3484
- message,
3485
- person: link ? {
3486
- id: link.to,
3487
- method: link.method
3488
- } : null,
3489
- accounts: query.person === void 0 || !actors ? [] : linkedTo(actors, query.person).filter(ours).map((id) => ({
3490
- id,
3491
- method: actors.links[id].method
3492
- })),
3493
- holder
3819
+ message
3494
3820
  };
3495
3821
  },
3496
3822
  async syncCopies(caller, query) {
@@ -3508,14 +3834,6 @@ function build(options, binding) {
3508
3834
  }
3509
3835
  return { copies: out };
3510
3836
  },
3511
- async syncRoots(caller, { by, since }) {
3512
- const { principal } = await needSync(caller, "look up a sync");
3513
- await reads.ensure();
3514
- const roots = (await store.tables.recentRoots(by, since)).filter((root) => principal.access.can("sync", root.channel));
3515
- const { state: actors } = await session.state(actorsReducer);
3516
- const reader = await reads.reader(principal.access, roots);
3517
- return { roots: roots.map((root) => view(root, actors, reader)) };
3518
- },
3519
3837
  async inboxPush(caller, { key, events }) {
3520
3838
  const { principal } = await needSync(caller, "push to an inbox", true);
3521
3839
  for (const { event } of events) for (const location of [
@@ -3618,73 +3936,6 @@ function build(options, binding) {
3618
3936
  });
3619
3937
  }
3620
3938
  return { events: entries.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, limit ?? 50) };
3621
- },
3622
- async syncCode(caller, { sync }) {
3623
- const identity = await identityOf(caller);
3624
- if (identity.principal.kind !== "person") throw forbidden("only people link platform accounts");
3625
- const entry = (await linkable()).find((candidate) => candidate.key === sync);
3626
- if (!entry) throw notFound(`no sync entry named ${sync}`);
3627
- const principal = identity.principal.id;
3628
- const code = newCode();
3629
- const expiresAt = new Date(Date.now() + CODE_TTL_MS).toISOString();
3630
- await codes.append({
3631
- type: "identity.code_issued",
3632
- payload: {
3633
- hash: hashCode(code),
3634
- principal,
3635
- sync,
3636
- expiresAt
3637
- }
3638
- });
3639
- const signIn = entry.signIn && entry.url !== null ? `${entry.url}/identity/sign-in?${new URLSearchParams({
3640
- sync,
3641
- code
3642
- })}` : null;
3643
- return {
3644
- code,
3645
- expiresAt,
3646
- ...signIn ? { signIn } : {}
3647
- };
3648
- },
3649
- async syncAccount(caller, body) {
3650
- const { principal } = await needSync(caller, "link accounts", true);
3651
- const { account } = body;
3652
- await speaksFor(caller, principal.id, account, body.sync);
3653
- const hash = hashCode(body.code);
3654
- const issued = (await codes.state(identityCodesReducer)).state[hash];
3655
- const bad = () => notFound("that code isn't live: it expired, was used, or was issued for another sync entry", "bad_code");
3656
- if (!issued || issued.sync !== body.sync) throw bad();
3657
- const usedHere = (used) => used?.usedBy === account.id && used.usedIn === (body.message ?? null);
3658
- if (issued.usedBy === null ? Date.parse(issued.expiresAt) < Date.now() : !usedHere(issued)) throw bad();
3659
- await codes.append({
3660
- type: "identity.code_used",
3661
- id: `used:${hash}`,
3662
- payload: {
3663
- hash,
3664
- actor: account.id,
3665
- ...body.message !== void 0 ? { message: body.message } : {}
3666
- }
3667
- });
3668
- if (!usedHere((await codes.state(identityCodesReducer)).state[hash])) throw bad();
3669
- const linked = externalIdentity(account, recordsActors(caller, principal));
3670
- return {
3671
- person: issued.principal,
3672
- link: await link(linked, issued.principal, body.method ?? "code", `link:${hash}`)
3673
- };
3674
- },
3675
- async syncUnlink(caller, { account }) {
3676
- const { principal, author } = await identityOf(caller);
3677
- const { state } = await session.state(actorsReducer);
3678
- const found = state.links[account];
3679
- if (!found || found.to !== principal.id && !principal.access.everywhere("sync")) throw notFound(`${account} isn't linked`, "not_linked");
3680
- await session.append({
3681
- type: "actor.unlinked",
3682
- payload: {
3683
- actor: account,
3684
- by: author
3685
- }
3686
- });
3687
- return { ok: true };
3688
3939
  }
3689
3940
  };
3690
3941
  /**
@@ -3704,35 +3955,27 @@ function build(options, binding) {
3704
3955
  return ((await registry.state()).byOwner[principal]?.sync ?? []).filter((entry) => !binding.sync.some((own) => own.key === entry.key));
3705
3956
  }
3706
3957
  /**
3707
- * The sync entries this board links accounts on, by key, each with the URL where its sync runs,
3708
- * once that's known: its own sync's, then those of the syncs registered here that may still sync.
3709
- * A key its own sync has is its own. A key two registered syncs claim is neither's to sign people
3710
- * in for: a person links it by posting the code where either syncs.
3958
+ * The sync entries this board lists in its discovery: its own sync's, then those of the syncs
3959
+ * registered here that may still sync. A key its own sync has is its own.
3711
3960
  */
3712
- async function linkable() {
3961
+ async function syncEntries() {
3713
3962
  const found = /* @__PURE__ */ new Map();
3714
- for (const entry of binding.sync) found.set(entry.key, {
3715
- ...entry,
3716
- url: binding.url()
3963
+ for (const { key, platform } of binding.sync) found.set(key, {
3964
+ key,
3965
+ platform
3717
3966
  });
3718
- const claimed = /* @__PURE__ */ new Map();
3719
3967
  for (const registration of Object.values((await registry.state()).byOwner)) {
3720
3968
  if (registration.sync.length === 0) continue;
3721
3969
  if (!(await binding.accessNow(registration.principal))?.anywhere("sync")) continue;
3722
- for (const entry of registration.sync) if (!found.has(entry.key)) (claimed.get(entry.key) ?? claimed.set(entry.key, []).get(entry.key)).push({
3723
- ...entry,
3724
- url: registration.url
3970
+ for (const { key, platform } of registration.sync) if (!found.has(key)) found.set(key, {
3971
+ key,
3972
+ platform
3725
3973
  });
3726
3974
  }
3727
- for (const [key, claims] of claimed) found.set(key, claims.length === 1 ? claims[0] : {
3728
- ...claims[0],
3729
- signIn: false,
3730
- url: null
3731
- });
3732
3975
  return [...found.values()];
3733
3976
  }
3734
3977
  /**
3735
- * Whether a caller may write as, or link, `author`: a platform user, never anyone this board
3978
+ * Whether a caller may write as `author`: a platform user, never anyone this board
3736
3979
  * signs in, on a platform it syncs: the one of entry `from`, when it names one.
3737
3980
  */
3738
3981
  async function speaksFor(caller, principal, author, from) {
@@ -3901,43 +4144,6 @@ function build(options, binding) {
3901
4144
  });
3902
4145
  opened.add(channel);
3903
4146
  }
3904
- /** Records that platform user `identity` is `to`, unless it already is. `id` makes a retry a no-op. */
3905
- async function link(identity, to, method, id) {
3906
- const { actor: account, author: by } = identity;
3907
- const { state: actors } = await session.state(actorsReducer);
3908
- const seen = identity.records === false ? null : actorSeen(account, actors);
3909
- const existing = actors.links[account.id];
3910
- if (existing?.to === to && !id) {
3911
- if (seen) await session.append(seen);
3912
- return {
3913
- actor: account.id,
3914
- handle: account.handle,
3915
- name: account.name,
3916
- method: existing.method,
3917
- at: existing.at
3918
- };
3919
- }
3920
- await session.append(...seen ? [{
3921
- ...seen,
3922
- ...id ? { id: `${id}:seen` } : {}
3923
- }] : [], {
3924
- type: "actor.linked",
3925
- ...id ? { id } : {},
3926
- payload: {
3927
- actor: account.id,
3928
- to,
3929
- method,
3930
- by
3931
- }
3932
- });
3933
- return {
3934
- actor: account.id,
3935
- handle: account.handle,
3936
- name: account.name,
3937
- method,
3938
- at: (/* @__PURE__ */ new Date()).toISOString()
3939
- };
3940
- }
3941
4147
  const servers = [
3942
4148
  server,
3943
4149
  inboxServer,
@@ -3948,6 +4154,7 @@ function build(options, binding) {
3948
4154
  return {
3949
4155
  methods,
3950
4156
  schedules,
4157
+ trust: access.trust,
3951
4158
  /** The scheduler's callback, for every A2 server behind the board. */
3952
4159
  scheduler: options.scheduler ? options.scheduler.handler(...servers) : async (_request) => toErrorResponse(notFound("this board has no scheduler")),
3953
4160
  servers,
@@ -3993,6 +4200,11 @@ function a2(options = {}) {
3993
4200
  ...Object.fromEntries(METHODS.map((name) => [name, async (caller, input, signal) => (await ready()).methods[name](caller, input, signal)])),
3994
4201
  routes: [],
3995
4202
  keeps: true,
4203
+ trust: {
4204
+ grants: async () => (await ready()).trust.grants(),
4205
+ token: async (hash) => (await ready()).trust.token(hash),
4206
+ live: async (id) => (await ready()).trust.live(id)
4207
+ },
3996
4208
  bind(given) {
3997
4209
  if (binding) throw new Error("a2: this backend already serves a board: create one a2() per board()");
3998
4210
  binding = given;