@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,31 +1,31 @@
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-BDhLVkV5.mjs";
5
- import { r as duration, t as BoardError } from "./client-D0Px7KzF.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 forbidden, c as toErrorResponse, i as duplicateId, r as badRequest, s as notFound, t as HttpError } from "./errors-CusN4x2l.mjs";
7
+ import { a as scopeOf, i as parseScope, n as authorize, r as compileAccess, t as FULL_ACCESS } from "./access-CY5xVMIK.mjs";
8
8
  import { t as own } from "./own-OwgPWdmh.mjs";
9
9
  import { n as replaceMentions } from "./mentions-CB6ShY4R.mjs";
10
- import { _ as FULL_ACCESS, i as authenticates, v as authorize } from "./auth-JO22iIwr.mjs";
11
- import { a as resolveMentions, n as actorSeen } from "./actors-DcJhFbwJ.mjs";
12
- import { n as MIDDLEWARE_ACTOR, t as DroppedError } from "./middleware-CXjEeef4.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
- import { t as A2Error } from "./session-clear-wHd5GCpZ-BnKSxReK.mjs";
15
- import { a as reducer } from "./reducer-CjmvZSAb-D_OSISmj.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-DJzVpXwM.mjs";
17
- import { n as createServer, t as consumer } from "./server-DtFrWxi3-Boa6gdWO.mjs";
18
- import { a as linkedTo, i as iso, n as foldMessages, o as personOf, t as foldActors } from "./folds-BDMSxBUf.mjs";
19
- import { c as readUpload, i as finishUpload, l as startUpload, n as attachmentNotFound, t as attachmentBody, u as writeUpload } from "./blob-DdbpJqN1.mjs";
20
- import { t as concernedMessageId } from "./project-CBXlLftU.mjs";
21
- import { t as TABLES_CONSUMER } from "./sql-D4Z_efJX.mjs";
14
+ import { t as A2Error } from "./session-clear-XJYpmUpg-2q8l_FJR.mjs";
15
+ import { a as reducer } from "./reducer-Bswr11ar-P324o9vn.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
+ import { n as createServer, t as consumer } from "./server-DX7mM6nm-D7N_7bug.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";
25
25
  import { lookup } from "node:dns";
26
26
  import { request } from "node:https";
27
27
  import { BlockList, isIP } from "node:net";
28
- //#region ../../node_modules/.pnpm/experimental-a2@0.22.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__52060f00b21aecfab049c27fa3abd2f9/node_modules/experimental-a2/dist/idempotent-id-BRJVeylj.js
28
+ //#region ../../node_modules/.pnpm/experimental-a2@0.24.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__b1e2c243a963f2620a5d202817417c26/node_modules/experimental-a2/dist/idempotent-id-BRJVeylj.js
29
29
  async function idempotentId(...parts) {
30
30
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(parts)));
31
31
  const bytes = new Uint8Array(digest).slice(0, 16);
@@ -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. */
@@ -125,11 +456,13 @@ function viewedBy(view, reader) {
125
456
  }
126
457
  /** A message's or a cursor's ID as clients see it. On this board it's the log index, in decimal. */
127
458
  const ref = (index) => String(index);
459
+ /** A schedule's ID as clients see it: its index in session `schedules`, as `s12`, so it reads apart from message `12`. */
460
+ const scheduleRef = (index) => `s${index}`;
128
461
  /** The log index a ref names on this board, or null for a string that names nothing here. */
129
462
  const indexOf = (value) => /^(0|[1-9]\d{0,15})$/.test(value) ? Number(value) : null;
130
463
  /** A message as `reader` sees it. Every view names its reader, so none shows a link's snapshot by default. */
131
464
  function view(message, actors, reader) {
132
- const { blind, version, schedule, ...shown } = message;
465
+ const { blind, version, revision, schedule, ...shown } = message;
133
466
  return {
134
467
  ...shown,
135
468
  ...blind ? { blind } : {},
@@ -137,8 +470,9 @@ function view(message, actors, reader) {
137
470
  re: message.re === null ? null : ref(message.re),
138
471
  replyTo: message.replyTo === null ? null : ref(message.replyTo),
139
472
  version: version ?? message.id,
473
+ revision: revision ?? message.id,
140
474
  ...schedule ? { schedule: {
141
- id: ref(schedule.id),
475
+ id: scheduleRef(schedule.id),
142
476
  n: schedule.n
143
477
  } } : {},
144
478
  links: readableLinks(message.links, reader),
@@ -147,30 +481,17 @@ function view(message, actors, reader) {
147
481
  author: actorView(actors, message.by.actor)
148
482
  };
149
483
  }
150
- /** Whether accounts are linked to `id`: then it's a person, even one who never wrote on the board. */
151
- const hasLinks = (actors, id) => Object.values(actors.links).some((link) => link.to === id);
152
- 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()) ?? []);
153
485
  /**
154
- * Who a `--by` or `--mentions` value covers. A person (`julian`, or one of their
155
- * linked accounts, `@julian` on Slack) is everyone they are: their own actor,
156
- * their agents (`julian/claude`), and every account linked to them. An agent
157
- * 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.
158
489
  */
159
490
  function whoIs(value, actors) {
160
491
  const ids = actorSet(value, actors);
161
- const out = new Set(ids);
162
- const principals = /* @__PURE__ */ new Set();
163
- for (const id of ids) {
164
- const kind = own(actors.byId, id)?.kind;
165
- const person = own(actors.links, id) ? personOf(actors, id) : kind === "person" || hasLinks(actors, id) ? id : null;
166
- if (person === null) continue;
167
- principals.add(person);
168
- out.add(person);
169
- for (const linked of linkedTo(actors, person)) out.add(linked);
170
- }
171
492
  return {
172
- actors: out,
173
- principals
493
+ actors: ids,
494
+ principals: new Set([...ids].filter((id) => own(actors.byId, id)?.kind === "person"))
174
495
  };
175
496
  }
176
497
  /** Whether `author` is one of `who`: its actor, or an agent of one of its people. */
@@ -191,7 +512,7 @@ function compileFilter(filters, context) {
191
512
  const subtree = channel?.endsWith("/") ? channel.slice(0, -1) : null;
192
513
  const by = filters.by === void 0 ? null : whoIs(filters.by, context.actors);
193
514
  const re = filters.re === void 0 ? void 0 : indexOf(filters.re) ?? -1;
194
- 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;
195
516
  return (message) => {
196
517
  if (!message || !access.readable(message.channel)) return false;
197
518
  if (channel !== void 0) {
@@ -244,7 +565,7 @@ function toMessageFilter(filters, context) {
244
565
  principals: [...who.principals]
245
566
  };
246
567
  }
247
- 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];
248
569
  if (filters.from !== void 0) out.from = filters.from;
249
570
  if (filters.text !== void 0) out.text = filters.text.toLowerCase();
250
571
  if (filters.label?.length) out.labels = filters.label.map(([key, values]) => ({
@@ -325,7 +646,7 @@ function payloadView(payload, links) {
325
646
  ]) if (typeof shown[key] === "number") shown[key] = ref(shown[key]);
326
647
  const schedule = shown.schedule;
327
648
  if (schedule) shown.schedule = {
328
- id: ref(schedule.id),
649
+ id: scheduleRef(schedule.id),
329
650
  n: schedule.n
330
651
  };
331
652
  if (links) shown.links = links;
@@ -681,13 +1002,15 @@ async function authorizeMessage(ctx, access, permission, id) {
681
1002
  * read, it's yours only if it was posted without reading too: a write-only
682
1003
  * source fixes what it wrote, but a principal ID it shares with a caller that
683
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`).
684
1006
  * Someone else's answers 404 where you can't read it, and 403 where you can.
685
1007
  */
686
- async function authorizeOwn(ctx, principal, id, verb) {
1008
+ async function authorizeOwn(ctx, identity, id, verb) {
1009
+ const { principal } = identity;
687
1010
  const message = await ctx.reads.message(id);
688
1011
  if (!message) throw messageNotFound(id)();
689
1012
  const readable = principal.access.readable(message.channel);
690
- if (message.by.principal !== principal.id || !readable && !message.blind) {
1013
+ if (!wroteAs(identity, message.by) || !readable && !message.blind) {
691
1014
  if (!readable) throw messageNotFound(id)();
692
1015
  throw forbidden(`only the author can ${verb} a message`);
693
1016
  }
@@ -750,19 +1073,20 @@ async function writePost(ctx, input) {
750
1073
  if (input.sync?.length) throw badRequest("sync attaches whole threads: use `board sync <id> <target>`");
751
1074
  } else {
752
1075
  if (!channel) throw badRequest("a post needs a channel");
1076
+ if (input.after !== void 0) throw badRequest("`after` is for replies", "invalid_after");
753
1077
  thread = id;
754
1078
  }
755
1079
  spend(input.proofs.post, "post", { channel });
756
1080
  if (hasLabels(input.labels)) spend(input.proofs.label, "label", { channel });
757
1081
  if (input.sync?.length) spend(input.proofs.sync, "sync", { channel });
758
- if (input.id !== void 0) {
759
- const landed = await ctx.reads.byEventId(input.id);
760
- if (landed) {
761
- if (landed.channel !== channel || landed.re !== re || landed.replyTo !== replyTo) throw duplicateId();
762
- const shown = await landedFor(ctx, identity, landed);
763
- if (!shown) throw duplicateId();
764
- return shownTo(shown, identity.principal.access, sent);
765
- }
1082
+ const replayed = await replayOf(ctx, input, {
1083
+ channel,
1084
+ re,
1085
+ replyTo
1086
+ }, sent);
1087
+ if (replayed) return replayed;
1088
+ if (input.after !== void 0) {
1089
+ if ((await ctx.reads.message(input.after))?.thread !== thread) throw badRequest(`after names no message in #${re}'s thread`, "invalid_after");
766
1090
  }
767
1091
  return commitPost(ctx, input, {
768
1092
  channel,
@@ -773,6 +1097,16 @@ async function writePost(ctx, input) {
773
1097
  sent
774
1098
  });
775
1099
  }
1100
+ /** The post a caller's own ID already landed, as its retry gets it back: null when there's none. */
1101
+ async function replayOf(ctx, input, place, sent) {
1102
+ if (input.id === void 0) return null;
1103
+ const landed = await ctx.reads.byEventId(input.id);
1104
+ if (!landed) return null;
1105
+ if (landed.channel !== place.channel || landed.re !== place.re || landed.replyTo !== place.replyTo) throw duplicateId();
1106
+ const shown = await landedFor(ctx, input.identity, landed);
1107
+ if (!shown) throw duplicateId();
1108
+ return shownTo(shown, input.identity.principal.access, sent);
1109
+ }
776
1110
  /**
777
1111
  * A post that landed, as its author may have it back: only the same author's, and one in a
778
1112
  * channel it can't read only if it posted without reading too. A caller that can't read gets back
@@ -833,28 +1167,45 @@ async function commitPost(ctx, input, placed) {
833
1167
  by: identity.author
834
1168
  }
835
1169
  })));
836
- const rows = await ctx.session.append(...seen ? [seen] : [], ...named, {
837
- type: "message.posted",
838
- ...id ? { id } : {},
839
- payload: {
1170
+ const events = [
1171
+ ...seen ? [seen] : [],
1172
+ ...named,
1173
+ {
1174
+ type: "message.posted",
1175
+ ...id ? { id } : {},
1176
+ payload: {
1177
+ channel,
1178
+ text,
1179
+ re,
1180
+ replyTo,
1181
+ thread,
1182
+ labels: input.labels ?? {},
1183
+ noSync: input.noSync ?? false,
1184
+ mentions,
1185
+ by: identity.author,
1186
+ origin: input.origin ?? null,
1187
+ from: input.from ?? null,
1188
+ ...input.attachments?.length ? { attachments: input.attachments } : {},
1189
+ ...input.links?.length ? { links: input.links } : {},
1190
+ ...input.schedule ? { schedule: input.schedule } : {},
1191
+ ...identity.principal.access.readable(channel) ? {} : { blind: true },
1192
+ ...input.importedBy !== void 0 ? { importedBy: input.importedBy } : {}
1193
+ }
1194
+ },
1195
+ ...synced
1196
+ ];
1197
+ let rows;
1198
+ try {
1199
+ rows = input.after === void 0 || re === null ? await ctx.session.append(...events) : await appendWhile(ctx, nothingAfter(ctx, re, input.after), events);
1200
+ } catch (error) {
1201
+ const replayed = error instanceof HttpError && error.code === "newer_message" ? await replayOf(ctx, input, {
840
1202
  channel,
841
- text,
842
1203
  re,
843
- replyTo,
844
- thread,
845
- labels: input.labels ?? {},
846
- noSync: input.noSync ?? false,
847
- mentions,
848
- by: identity.author,
849
- origin: input.origin ?? null,
850
- from: input.from ?? null,
851
- ...input.attachments?.length ? { attachments: input.attachments } : {},
852
- ...input.links?.length ? { links: input.links } : {},
853
- ...input.schedule ? { schedule: input.schedule } : {},
854
- ...identity.principal.access.readable(channel) ? {} : { blind: true },
855
- ...input.importedBy !== void 0 ? { importedBy: input.importedBy } : {}
856
- }
857
- }, ...synced);
1204
+ replyTo
1205
+ }, sent) : null;
1206
+ if (replayed) return replayed;
1207
+ throw error;
1208
+ }
858
1209
  const posted = rows.find((row) => row.type === "message.posted");
859
1210
  await untilApplied(ctx, rows);
860
1211
  const message = foldMessages({
@@ -923,6 +1274,42 @@ async function untilApplied(ctx, rows) {
923
1274
  }
924
1275
  /** How long a write waits for the tables before it answers anyway. */
925
1276
  const LANDED_WAIT_MS = 1e4;
1277
+ /**
1278
+ * Appends `events` only while the board, as `holds` reads it, still allows them: `holds` checks
1279
+ * the tables, throws what refuses the write, and returns the index its reads reflect. The append
1280
+ * holds only while that index is still the log's last, so anything that lands in between,
1281
+ * anywhere on the board, moves it, and it looks again from there.
1282
+ */
1283
+ async function appendWhile(ctx, holds, events) {
1284
+ return whileHeadMoves(async () => {
1285
+ await ctx.reads.ensure();
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));
1300
+ }
1301
+ }
1302
+ /** How many times a conditional write looks again, as the board moves on, before it gives up. */
1303
+ const CONDITIONAL_TRIES = 10;
1304
+ /** A reply's `after`: nothing came after it in the thread, as of the index the read reflects. */
1305
+ const nothingAfter = (ctx, root, after) => async () => {
1306
+ const { messages: [newer], position } = await ctx.reads.tables.messages({ re: root }, {
1307
+ after,
1308
+ limit: 1
1309
+ });
1310
+ if (newer) throw new HttpError(409, "newer_message", `#${newer.id} came after #${after}`);
1311
+ return position;
1312
+ };
926
1313
  /** The message a by-ID write targets, as the board looked it up, once its proof is spent on the message's channel. */
927
1314
  function spent(authorized, permission) {
928
1315
  spend(authorized.proof, permission, authorized.message);
@@ -931,7 +1318,10 @@ function spent(authorized, permission) {
931
1318
  async function writeEdit(ctx, input) {
932
1319
  const message = spent(input.authorized, "post");
933
1320
  if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can edit a message");
934
- if (message.deleted) throw badRequest("message was deleted", "message_deleted");
1321
+ if (message.deleted) {
1322
+ if (input.revision !== void 0 && input.revision !== (message.revision ?? message.id)) throw new HttpError(409, "revision_conflict", `#${message.id} was deleted since revision ${input.revision}`);
1323
+ throw badRequest("message was deleted", "message_deleted");
1324
+ }
935
1325
  return commitEdit(ctx, input, message);
936
1326
  }
937
1327
  async function commitEdit(ctx, input, message) {
@@ -940,17 +1330,22 @@ async function commitEdit(ctx, input, message) {
940
1330
  const { text, found } = await withEmailMentions(input.text);
941
1331
  const named = found.map((actor) => actorSeen(actor, actors)).filter((event) => event !== null);
942
1332
  const { mentions, unknown } = resolveMentions(text, knownWith(actors, found), seen ? input.identity.actor : void 0, input.from != null, input.identity.principal.access.readsAll);
943
- await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], ...named, {
944
- type: "message.edited",
945
- payload: {
946
- id: message.id,
947
- thread: message.thread,
948
- text,
949
- mentions,
950
- by: input.identity.author,
951
- from: input.from ?? null
1333
+ const events = [
1334
+ ...seen ? [seen] : [],
1335
+ ...named,
1336
+ {
1337
+ type: "message.edited",
1338
+ payload: {
1339
+ id: message.id,
1340
+ thread: message.thread,
1341
+ text,
1342
+ mentions,
1343
+ by: input.identity.author,
1344
+ from: input.from ?? null
1345
+ }
952
1346
  }
953
- }));
1347
+ ];
1348
+ await untilApplied(ctx, input.revision === void 0 ? await ctx.session.append(...events) : await appendWhile(ctx, revisionIs(ctx, message.id, input.revision), events));
954
1349
  return unknown.length && input.identity.principal.access.readable(message.channel) ? { unknownMentions: unknown } : {};
955
1350
  }
956
1351
  /** A message's links, replaced: a platform edit or unfurl changed what it links to. */
@@ -971,8 +1366,11 @@ async function writeLinks(ctx, input) {
971
1366
  async function writeDelete(ctx, input) {
972
1367
  const message = spent(input.authorized, "post");
973
1368
  if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can delete a message");
974
- if (message.deleted) return;
975
- await untilApplied(ctx, await ctx.session.append({
1369
+ if (message.deleted) {
1370
+ if (input.revision !== void 0 && input.revision !== (message.revision ?? message.id)) throw new HttpError(409, "revision_conflict", `#${message.id} was deleted since revision ${input.revision}`);
1371
+ return;
1372
+ }
1373
+ const deleted = [{
976
1374
  type: "message.deleted",
977
1375
  payload: {
978
1376
  id: message.id,
@@ -980,8 +1378,17 @@ async function writeDelete(ctx, input) {
980
1378
  by: input.identity.author,
981
1379
  from: input.from ?? null
982
1380
  }
983
- }));
1381
+ }];
1382
+ await untilApplied(ctx, input.revision === void 0 ? await ctx.session.append(...deleted) : await appendWhile(ctx, revisionIs(ctx, message.id, input.revision), deleted));
984
1383
  }
1384
+ /** An edit's or a delete's `revision`: no edit or delete since, as of the index the read reflects. */
1385
+ const revisionIs = (ctx, id, revision) => async () => {
1386
+ const position = await ctx.reads.tables.position();
1387
+ const message = (await ctx.reads.tables.messagesAt([id], position)).get(id);
1388
+ const now = message ? message.revision ?? message.id : null;
1389
+ if (now !== revision) throw new HttpError(409, "revision_conflict", message?.deleted ? `#${id} was deleted since revision ${revision}` : `#${id} was edited since revision ${revision}: it's at ${now}`);
1390
+ return position;
1391
+ };
985
1392
  async function writeReaction(ctx, input) {
986
1393
  const message = spent(input.authorized, "react");
987
1394
  if (message.deleted) throw badRequest("message was deleted", "message_deleted");
@@ -1003,7 +1410,7 @@ async function writeLabels(ctx, input) {
1003
1410
  if (message.deleted) throw badRequest("message was deleted", "message_deleted");
1004
1411
  const { state: actors } = await ctx.session.state(actorsReducer);
1005
1412
  const seen = seenFor(input.identity, actors);
1006
- await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], {
1413
+ const events = [...seen ? [seen] : [], {
1007
1414
  type: "labels.changed",
1008
1415
  payload: {
1009
1416
  id: message.id,
@@ -1012,8 +1419,21 @@ async function writeLabels(ctx, input) {
1012
1419
  unset: input.unset ?? [],
1013
1420
  by: input.identity.author
1014
1421
  }
1015
- }));
1422
+ }];
1423
+ await untilApplied(ctx, input.if !== void 0 && Object.keys(input.if).length > 0 ? await appendWhile(ctx, labelsAre(ctx, message.id, input.if), events) : await ctx.session.append(...events));
1016
1424
  }
1425
+ /** A label write's `if`: each label is as it says, as of the index the read reflects. */
1426
+ const labelsAre = (ctx, id, expected) => async () => {
1427
+ const position = await ctx.reads.tables.position();
1428
+ const labels = (await ctx.reads.tables.messagesAt([id], position)).get(id)?.labels ?? {};
1429
+ for (const key of Object.keys(expected).sort()) {
1430
+ const now = own(labels, key);
1431
+ if (now === expected[key] || now === void 0 && expected[key] === null) continue;
1432
+ const wanted = expected[key];
1433
+ throw new HttpError(409, "label_conflict", wanted === null ? `${key} is set` : now === void 0 ? `${key} isn't set` : `${key} isn't ${wanted}`);
1434
+ }
1435
+ return position;
1436
+ };
1017
1437
  /**
1018
1438
  * Syncs a message's thread with a target, resolved by the sync that delivers it. Attaching twice
1019
1439
  * is a no-op; a platform thread another board thread is synced with is a 409.
@@ -1037,6 +1457,135 @@ async function writeAttach(ctx, input) {
1037
1457
  }));
1038
1458
  return { attached: true };
1039
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
+ }
1497
+ /** The batch, only while `index` is still the session's last: A2's conditional append. */
1498
+ const appendIfLast = (session, index, events) => session.append.ifLastIndex(index, ...events);
1499
+ /** What A2 throws when the session's last index isn't the one a conditional append named. */
1500
+ const headMoved = (error) => error?.code === "LAST_INDEX_MISMATCH";
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
+ }
1040
1589
  //#endregion
1041
1590
  //#region src/server/schedules.ts
1042
1591
  /** Vercel Queues delays at most 6 days: a send further out than this goes in hops. */
@@ -1132,12 +1681,16 @@ function nextSend(schedule, n, at, now) {
1132
1681
  * needs `post` there now, as it would to create it. Where it can't read, the
1133
1682
  * schedule is its own only if it was created without reading too, so a
1134
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`).
1135
1685
  */
1136
- 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));
1137
1690
  const scheduleView = (schedule, actors) => {
1138
1691
  const author = actors.byId[schedule.as.actor.id] ?? schedule.as.actor;
1139
1692
  return {
1140
- id: ref(schedule.id),
1693
+ id: scheduleRef(schedule.id),
1141
1694
  channel: schedule.channel,
1142
1695
  re: schedule.re === null ? null : ref(schedule.re),
1143
1696
  text: schedule.text,
@@ -1408,7 +1961,10 @@ function createSchedules(options) {
1408
1961
  if (every !== null && every > 316224e5) throw badRequest("`every` is at most a year");
1409
1962
  const first = body.at !== void 0 ? Date.parse(body.at) : now + duration(body.in ?? body.every);
1410
1963
  if (first < now - 6e4) throw badRequest("`at` is in the past");
1411
- 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;
1412
1968
  if (until !== null && until < first) throw badRequest("the schedule stops before its first send");
1413
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");
1414
1970
  const { state: known } = await writes.session.state(actorsReducer);
@@ -1437,13 +1993,7 @@ function createSchedules(options) {
1437
1993
  times: body.times ?? null,
1438
1994
  as: {
1439
1995
  author: identity.author,
1440
- principal: {
1441
- id: identity.principal.id,
1442
- kind: identity.principal.kind,
1443
- handle: identity.principal.handle,
1444
- ...identity.principal.facts ? { facts: identity.principal.facts } : {},
1445
- ...identity.principal.scope !== void 0 ? { scope: identity.principal.scope } : {}
1446
- },
1996
+ principal: storedPrincipal(identity.principal),
1447
1997
  actor: identity.actor
1448
1998
  },
1449
1999
  ...identity.principal.access.readable(channel) ? {} : { blind: true }
@@ -1470,7 +2020,7 @@ function createSchedules(options) {
1470
2020
  const schedule = state.byId[id];
1471
2021
  const missing = () => notFound(`schedule not found: s${id}`, "schedule_not_found");
1472
2022
  if (!schedule) throw missing();
1473
- 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);
1474
2024
  const canceled = schedule.next !== null;
1475
2025
  if (canceled) await session.append({
1476
2026
  type: "schedule.canceled",
@@ -2146,93 +2696,6 @@ function createWatchers(options) {
2146
2696
  };
2147
2697
  }
2148
2698
  //#endregion
2149
- //#region src/server/registry.ts
2150
- /** A principal as a registration keeps it: enough to work out its access again at each match. */
2151
- const storedPrincipal = (principal) => ({
2152
- id: principal.id,
2153
- kind: principal.kind,
2154
- handle: principal.handle,
2155
- ...principal.facts ? { facts: principal.facts } : {},
2156
- ...principal.scope !== void 0 ? { scope: principal.scope } : {}
2157
- });
2158
- /** What a registration shows: the principal's token facts stay on the board. */
2159
- const viewOf = ({ principal: _principal, ...view }) => view;
2160
- /**
2161
- * The same on both ends: a board on `http()` hashes its watchers, and whether its sync subscribes,
2162
- * and the board it uses hashes what it holds.
2163
- */
2164
- function watchersHash(watch, sync = []) {
2165
- return createHash("sha256").update(canonicalWatchers(watch, sync)).digest("hex").slice(0, 32);
2166
- }
2167
- function foldRegistry(state, event) {
2168
- if (event.type === "watchers.registered") {
2169
- const { owner, principal, url, watch, sync, hash } = event.payload;
2170
- const copied = Object.fromEntries(Object.entries(watch).map(([id, spec]) => [id, {
2171
- tail: spec.tail,
2172
- when: spec.when
2173
- }]));
2174
- state.byOwner[owner] = {
2175
- owner,
2176
- principal: structuredClone(principal),
2177
- url,
2178
- watch: copied,
2179
- sync: (sync ?? []).map((entry) => ({ ...entry })),
2180
- hash
2181
- };
2182
- }
2183
- return state;
2184
- }
2185
- const registryReducer = reducer({
2186
- contract: registryContract,
2187
- name: "board.registry.v1",
2188
- initialState: { byOwner: {} }
2189
- }).fold(foldRegistry);
2190
- /** Watchers registered with this board by boards on `http()`. */
2191
- function createRegistry(options) {
2192
- const server = createServer({
2193
- contract: registryContract,
2194
- ...options.store ? { store: options.store } : {},
2195
- ...options.scheduler ? { scheduler: options.scheduler } : {}
2196
- });
2197
- const session = server.session(REGISTRY_SESSION);
2198
- const state = async () => (await session.state(registryReducer)).state;
2199
- return {
2200
- server,
2201
- state,
2202
- /**
2203
- * Replaces `principal`'s registration. One that changes nothing writes nothing, so asking
2204
- * again is free, unless the principal's token facts changed: those decide what it may read.
2205
- */
2206
- async register(principal, input) {
2207
- const owner = principal.id;
2208
- const hash = watchersHash(input.watch, input.sync);
2209
- const current = (await state()).byOwner[owner];
2210
- if (current && current.url === input.url && current.hash === hash && JSON.stringify(current.principal) === JSON.stringify(principal)) return viewOf(current);
2211
- await session.append({
2212
- type: "watchers.registered",
2213
- payload: {
2214
- owner,
2215
- principal,
2216
- url: input.url,
2217
- watch: input.watch,
2218
- ...input.sync.length > 0 ? { sync: input.sync } : {},
2219
- hash
2220
- }
2221
- });
2222
- return {
2223
- owner,
2224
- url: input.url,
2225
- watch: input.watch,
2226
- sync: input.sync,
2227
- hash
2228
- };
2229
- },
2230
- async list() {
2231
- return Object.values((await state()).byOwner).sort((a, b) => a.owner.localeCompare(b.owner)).map(viewOf);
2232
- }
2233
- };
2234
- }
2235
- //#endregion
2236
2699
  //#region src/server/transport.ts
2237
2700
  /** Addresses a wake-up never reaches: this machine, private networks, link-local, and the rest that aren't the internet. */
2238
2701
  const NOT_PUBLIC = new BlockList();
@@ -2349,9 +2812,17 @@ function messageAt(value) {
2349
2812
  if (index === null || index === 0) throw notFound(`message not found: #${value}`, "message_not_found");
2350
2813
  return index;
2351
2814
  }
2352
- /** A schedule's ID, `12` or as the CLI shows it, `s12`, as its index in session `schedules`. */
2815
+ /**
2816
+ * A reply's `after`, as the index of a message. One that isn't one is 0, which names no message:
2817
+ * the post refuses it, after a retry that landed gets its reply back.
2818
+ */
2819
+ const afterAt = (value) => value === void 0 ? void 0 : indexOf(value) ?? 0;
2820
+ /**
2821
+ * A schedule's ID, `s12`, as its index in session `schedules`. `12` from a client that strips the
2822
+ * `s`, and `ss12` from an older CLI that adds one to what this board hands out, name it too.
2823
+ */
2353
2824
  function scheduleAt(value) {
2354
- const bare = value.replace(/^s/, "");
2825
+ const bare = value.replace(/^s{1,2}/, "");
2355
2826
  const index = indexOf(bare);
2356
2827
  if (index === null || index === 0) throw notFound(`schedule not found: s${bare}`, "schedule_not_found");
2357
2828
  return index;
@@ -2426,17 +2897,6 @@ function externalIdentity(author, records) {
2426
2897
  ...records ? {} : { records: false }
2427
2898
  };
2428
2899
  }
2429
- /**
2430
- * A one-time code for linking an account: `vb-` and eight letters and digits that
2431
- * can't be misread. Only its hash is kept.
2432
- */
2433
- const CODE_LETTERS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
2434
- const CODE_TTL_MS = 6e5;
2435
- const newCode = () => {
2436
- const letters = [...randomBytes(8)].map((byte) => CODE_LETTERS[byte % 31]).join("");
2437
- return `vb-${letters.slice(0, 4)}-${letters.slice(4)}`;
2438
- };
2439
- const hashCode = (code) => createHash("sha256").update(code.toUpperCase()).digest("base64url");
2440
2900
  /** The board events sync delivers: what a platform shows, and a thread's new targets. */
2441
2901
  const DELIVERED = [
2442
2902
  "thread.synced",
@@ -2589,10 +3049,11 @@ function build(options, binding) {
2589
3049
  ...options.scheduler ? { scheduler: options.scheduler } : {},
2590
3050
  consumers: [inbound]
2591
3051
  });
2592
- const codes = createServer({
2593
- contract: identityContract,
2594
- store: log
2595
- }).session(IDENTITY_SESSION);
3052
+ const access = createTrust({
3053
+ store: log,
3054
+ issuers: () => binding.issuers(),
3055
+ accessNow: (principal) => binding.accessNow(principal)
3056
+ });
2596
3057
  const inboxes = inboxServer.session(INBOXES_SESSION);
2597
3058
  /** The inboxes this instance knows have an `inbox.opened`, so most pushes write nothing more. */
2598
3059
  const opened = /* @__PURE__ */ new Set();
@@ -2606,8 +3067,8 @@ function build(options, binding) {
2606
3067
  * Whether `principal` may have a file: one it uploaded, or one a message it
2607
3068
  * can read carries. A deleted message drops its files, so it no longer counts.
2608
3069
  */
2609
- async function attachmentReadable(principal, id, uploadedBy) {
2610
- 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;
2611
3072
  await reads.ensure();
2612
3073
  return (await reads.tables.withAttachment(id)).some((message) => principal.access.readable(message.channel));
2613
3074
  }
@@ -2648,8 +3109,8 @@ function build(options, binding) {
2648
3109
  }
2649
3110
  /** Files a write carries, as the board stored them: each one the caller may have, or 404 as if it didn't exist. */
2650
3111
  const attachmentsFor = (principal, ids) => Promise.all([...new Set(ids ?? [])].map(async (id) => {
2651
- const { attachment, uploadedBy } = await readUpload(blob, id);
2652
- 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);
2653
3114
  return attachment;
2654
3115
  }));
2655
3116
  /**
@@ -2722,6 +3183,7 @@ function build(options, binding) {
2722
3183
  noSync: body.noSync,
2723
3184
  id: body.id,
2724
3185
  sync: body.sync,
3186
+ after: afterAt(body.after),
2725
3187
  ...schedule && !fromRequest(caller) ? {
2726
3188
  schedule,
2727
3189
  lenientMentions: true
@@ -2749,10 +3211,7 @@ function build(options, binding) {
2749
3211
  audience: url,
2750
3212
  team_id: teamId,
2751
3213
  project_id: id,
2752
- sync: (await linkable()).map(({ key, platform }) => ({
2753
- key,
2754
- platform
2755
- }))
3214
+ sync: await syncEntries()
2756
3215
  };
2757
3216
  },
2758
3217
  async me(caller) {
@@ -2778,6 +3237,12 @@ function build(options, binding) {
2778
3237
  cursor: ref(index)
2779
3238
  };
2780
3239
  },
3240
+ async createChannel(caller, { path }) {
3241
+ return writeChannel(writes, {
3242
+ identity: await identityOf(caller),
3243
+ path
3244
+ });
3245
+ },
2781
3246
  async actors(caller) {
2782
3247
  const { principal, actor } = await identityOf(caller);
2783
3248
  const { state, index } = await session.state(actorsReducer);
@@ -2788,31 +3253,7 @@ function build(options, binding) {
2788
3253
  seen = (id) => ids.has(id);
2789
3254
  }
2790
3255
  return {
2791
- actors: Object.values(state.byId).filter((a) => seen === null || seen(a.id)).sort((a, b) => a.handle.localeCompare(b.handle)).map(({ host: _host, ...a }) => {
2792
- const person = personView(state, a.id);
2793
- return person ? {
2794
- ...a,
2795
- person
2796
- } : a;
2797
- }),
2798
- cursor: ref(index)
2799
- };
2800
- },
2801
- async identity(caller) {
2802
- const { principal } = await identityOf(caller);
2803
- const { state, index } = await session.state(actorsReducer);
2804
- const links = linkedTo(state, principal.id).map((id) => ({
2805
- actor: id,
2806
- handle: state.byId[id]?.handle ?? null,
2807
- name: state.byId[id]?.name ?? null,
2808
- method: state.links[id].method,
2809
- at: state.links[id].at
2810
- }));
2811
- const unlinked = (await linkable()).filter((entry) => !links.some((link) => link.actor.startsWith(`${entry.platform}:user:`))).map((entry) => entry.key);
2812
- return {
2813
- principal: principal.id,
2814
- links,
2815
- 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),
2816
3257
  cursor: ref(index)
2817
3258
  };
2818
3259
  },
@@ -2880,25 +3321,27 @@ function build(options, binding) {
2880
3321
  async post(caller, parsed) {
2881
3322
  return postNow(caller, await identityOf(caller), parsed);
2882
3323
  },
2883
- async edit(caller, { id, text }) {
3324
+ async edit(caller, { id, text, revision }) {
2884
3325
  const identity = await identityOf(caller);
2885
- const authorized = await authorizeOwn(writes, identity.principal, messageAt(id), "edit");
3326
+ const authorized = await authorizeOwn(writes, identity, messageAt(id), "edit");
2886
3327
  const { unknownMentions } = await writeEdit(writes, {
2887
3328
  identity,
2888
3329
  authorized,
2889
- text
3330
+ text,
3331
+ revision
2890
3332
  });
2891
3333
  return {
2892
3334
  ok: true,
2893
3335
  ...unknownMentions ? { unknownMentions } : {}
2894
3336
  };
2895
3337
  },
2896
- async delete(caller, { id }) {
3338
+ async delete(caller, { id, revision }) {
2897
3339
  const identity = await identityOf(caller);
2898
- const authorized = await authorizeOwn(writes, identity.principal, messageAt(id), "delete");
3340
+ const authorized = await authorizeOwn(writes, identity, messageAt(id), "delete");
2899
3341
  await writeDelete(writes, {
2900
3342
  identity,
2901
- authorized
3343
+ authorized,
3344
+ revision
2902
3345
  });
2903
3346
  return { ok: true };
2904
3347
  },
@@ -2913,14 +3356,15 @@ function build(options, binding) {
2913
3356
  });
2914
3357
  return { ok: true };
2915
3358
  },
2916
- async label(caller, { id, set, unset }) {
3359
+ async label(caller, { id, set, unset, if: expected }) {
2917
3360
  const identity = await identityOf(caller);
2918
3361
  const authorized = await authorizeMessage(writes, identity.principal.access, "label", messageAt(id));
2919
3362
  await writeLabels(writes, {
2920
3363
  identity,
2921
3364
  authorized,
2922
3365
  set,
2923
- unset
3366
+ unset,
3367
+ if: expected
2924
3368
  });
2925
3369
  return { ok: true };
2926
3370
  },
@@ -2941,7 +3385,10 @@ function build(options, binding) {
2941
3385
  const at = messageAt(query.re);
2942
3386
  root = (await authorizeMessage(writes, principal.access, "read", at)).message.re ?? at;
2943
3387
  }
2944
- 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
+ });
2945
3392
  return { schedules: (await schedules.list({
2946
3393
  ...query,
2947
3394
  ...root !== void 0 ? { root } : {}
@@ -2955,6 +3402,12 @@ function build(options, binding) {
2955
3402
  schedule: blindSchedule(answer.schedule, identity.principal.access, identity.actor.id)
2956
3403
  };
2957
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),
2958
3411
  async upload(caller, { data, name, type }) {
2959
3412
  const { principal } = await identityOf(caller);
2960
3413
  if (!principal.access.anywhere("post") && !principal.access.anywhere("sync")) throw forbidden("this token can't post anywhere, so it can't upload");
@@ -2963,7 +3416,8 @@ function build(options, binding) {
2963
3416
  data,
2964
3417
  name,
2965
3418
  type,
2966
- uploadedBy: principal.id
3419
+ uploadedBy: principal.id,
3420
+ uploadedWith: await access.chain(principal)
2967
3421
  }, max) };
2968
3422
  },
2969
3423
  async uploadStart(caller, { name, type, size }) {
@@ -2973,17 +3427,18 @@ function build(options, binding) {
2973
3427
  name: name ?? null,
2974
3428
  type: type ?? null,
2975
3429
  size,
2976
- uploadedBy: principal.id
3430
+ uploadedBy: principal.id,
3431
+ uploadedWith: await access.chain(principal)
2977
3432
  });
2978
3433
  },
2979
3434
  async uploadFinish(caller, { id }) {
2980
3435
  const { principal } = await identityOf(caller);
2981
- return { attachment: await finishUpload(blob, id, principal.id) };
3436
+ return { attachment: await finishUpload(blob, id, principal) };
2982
3437
  },
2983
3438
  async attachment(caller, { id }) {
2984
3439
  const { principal } = await identityOf(caller);
2985
- const { attachment, uploadedBy } = await readUpload(blob, id);
2986
- 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);
2987
3442
  return {
2988
3443
  attachment,
2989
3444
  data: await attachmentBody(blob, attachment.id)
@@ -3194,6 +3649,7 @@ function build(options, binding) {
3194
3649
  origin: body.origin,
3195
3650
  from: body.from,
3196
3651
  ...body.origin && !ownSync(caller) ? { importedBy: principal.id } : {},
3652
+ after: afterAt(body.after),
3197
3653
  lenientMentions: true,
3198
3654
  attachments,
3199
3655
  links: await linksOf(principal, body.links)
@@ -3222,7 +3678,8 @@ function build(options, binding) {
3222
3678
  authorized: proof,
3223
3679
  text,
3224
3680
  from: body.from,
3225
- asAuthor
3681
+ asAuthor,
3682
+ revision: body.revision
3226
3683
  });
3227
3684
  return ref(found.id);
3228
3685
  };
@@ -3240,7 +3697,8 @@ function build(options, binding) {
3240
3697
  identity: author,
3241
3698
  authorized: await authorized("post"),
3242
3699
  from: body.from,
3243
- asAuthor
3700
+ asAuthor,
3701
+ revision: body.revision
3244
3702
  });
3245
3703
  else if (body.op === "react") await writeReaction(writes, {
3246
3704
  identity: author,
@@ -3253,7 +3711,8 @@ function build(options, binding) {
3253
3711
  identity: author,
3254
3712
  authorized: await authorized("label"),
3255
3713
  set: body.set,
3256
- unset: body.unset
3714
+ unset: body.unset,
3715
+ if: body.if
3257
3716
  });
3258
3717
  else await writeLinks(writes, {
3259
3718
  identity: author,
@@ -3337,12 +3796,6 @@ function build(options, binding) {
3337
3796
  const { tables } = store;
3338
3797
  let thread = null;
3339
3798
  let message = null;
3340
- let holder = null;
3341
- if (query.code !== void 0) {
3342
- const issued = (await codes.state(identityCodesReducer)).state[hashCode(query.code)];
3343
- const keys = new Set((await entriesOf(caller, principal.id)).map((entry) => entry.key));
3344
- if (issued && issued.usedBy === null && Date.parse(issued.expiresAt) >= Date.now() && keys.has(issued.sync)) holder = issued.principal;
3345
- }
3346
3799
  if (query.post !== void 0) {
3347
3800
  const landed = await reads.byEventId(importId(caller, principal.id, query.post), signal);
3348
3801
  if (landed && principal.access.can("sync", landed.channel)) message = ref(landed.id);
@@ -3361,22 +3814,9 @@ function build(options, binding) {
3361
3814
  if (found && principal.access.can("sync", found.channel)) message = ref(found.id);
3362
3815
  }
3363
3816
  }
3364
- const { state: actors } = query.account !== void 0 || query.person !== void 0 ? await session.state(actorsReducer) : { state: null };
3365
- const platforms = actors ? (await entriesOf(caller, principal.id)).map((entry) => `${entry.platform}:`) : [];
3366
- const ours = (account) => platforms.some((platform) => account.startsWith(platform));
3367
- const link = query.account !== void 0 && ours(query.account) ? actors?.links[query.account] : void 0;
3368
3817
  return {
3369
3818
  thread,
3370
- message,
3371
- person: link ? {
3372
- id: link.to,
3373
- method: link.method
3374
- } : null,
3375
- accounts: query.person === void 0 || !actors ? [] : linkedTo(actors, query.person).filter(ours).map((id) => ({
3376
- id,
3377
- method: actors.links[id].method
3378
- })),
3379
- holder
3819
+ message
3380
3820
  };
3381
3821
  },
3382
3822
  async syncCopies(caller, query) {
@@ -3394,14 +3834,6 @@ function build(options, binding) {
3394
3834
  }
3395
3835
  return { copies: out };
3396
3836
  },
3397
- async syncRoots(caller, { by, since }) {
3398
- const { principal } = await needSync(caller, "look up a sync");
3399
- await reads.ensure();
3400
- const roots = (await store.tables.recentRoots(by, since)).filter((root) => principal.access.can("sync", root.channel));
3401
- const { state: actors } = await session.state(actorsReducer);
3402
- const reader = await reads.reader(principal.access, roots);
3403
- return { roots: roots.map((root) => view(root, actors, reader)) };
3404
- },
3405
3837
  async inboxPush(caller, { key, events }) {
3406
3838
  const { principal } = await needSync(caller, "push to an inbox", true);
3407
3839
  for (const { event } of events) for (const location of [
@@ -3504,73 +3936,6 @@ function build(options, binding) {
3504
3936
  });
3505
3937
  }
3506
3938
  return { events: entries.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, limit ?? 50) };
3507
- },
3508
- async syncCode(caller, { sync }) {
3509
- const identity = await identityOf(caller);
3510
- if (identity.principal.kind !== "person") throw forbidden("only people link platform accounts");
3511
- const entry = (await linkable()).find((candidate) => candidate.key === sync);
3512
- if (!entry) throw notFound(`no sync entry named ${sync}`);
3513
- const principal = identity.principal.id;
3514
- const code = newCode();
3515
- const expiresAt = new Date(Date.now() + CODE_TTL_MS).toISOString();
3516
- await codes.append({
3517
- type: "identity.code_issued",
3518
- payload: {
3519
- hash: hashCode(code),
3520
- principal,
3521
- sync,
3522
- expiresAt
3523
- }
3524
- });
3525
- const signIn = entry.signIn && entry.url !== null ? `${entry.url}/identity/sign-in?${new URLSearchParams({
3526
- sync,
3527
- code
3528
- })}` : null;
3529
- return {
3530
- code,
3531
- expiresAt,
3532
- ...signIn ? { signIn } : {}
3533
- };
3534
- },
3535
- async syncAccount(caller, body) {
3536
- const { principal } = await needSync(caller, "link accounts", true);
3537
- const { account } = body;
3538
- await speaksFor(caller, principal.id, account, body.sync);
3539
- const hash = hashCode(body.code);
3540
- const issued = (await codes.state(identityCodesReducer)).state[hash];
3541
- const bad = () => notFound("that code isn't live: it expired, was used, or was issued for another sync entry", "bad_code");
3542
- if (!issued || issued.sync !== body.sync) throw bad();
3543
- const usedHere = (used) => used?.usedBy === account.id && used.usedIn === (body.message ?? null);
3544
- if (issued.usedBy === null ? Date.parse(issued.expiresAt) < Date.now() : !usedHere(issued)) throw bad();
3545
- await codes.append({
3546
- type: "identity.code_used",
3547
- id: `used:${hash}`,
3548
- payload: {
3549
- hash,
3550
- actor: account.id,
3551
- ...body.message !== void 0 ? { message: body.message } : {}
3552
- }
3553
- });
3554
- if (!usedHere((await codes.state(identityCodesReducer)).state[hash])) throw bad();
3555
- const linked = externalIdentity(account, recordsActors(caller, principal));
3556
- return {
3557
- person: issued.principal,
3558
- link: await link(linked, issued.principal, body.method ?? "code", `link:${hash}`)
3559
- };
3560
- },
3561
- async syncUnlink(caller, { account }) {
3562
- const { principal, author } = await identityOf(caller);
3563
- const { state } = await session.state(actorsReducer);
3564
- const found = state.links[account];
3565
- if (!found || found.to !== principal.id && !principal.access.everywhere("sync")) throw notFound(`${account} isn't linked`, "not_linked");
3566
- await session.append({
3567
- type: "actor.unlinked",
3568
- payload: {
3569
- actor: account,
3570
- by: author
3571
- }
3572
- });
3573
- return { ok: true };
3574
3939
  }
3575
3940
  };
3576
3941
  /**
@@ -3590,35 +3955,27 @@ function build(options, binding) {
3590
3955
  return ((await registry.state()).byOwner[principal]?.sync ?? []).filter((entry) => !binding.sync.some((own) => own.key === entry.key));
3591
3956
  }
3592
3957
  /**
3593
- * The sync entries this board links accounts on, by key, each with the URL where its sync runs,
3594
- * once that's known: its own sync's, then those of the syncs registered here that may still sync.
3595
- * A key its own sync has is its own. A key two registered syncs claim is neither's to sign people
3596
- * 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.
3597
3960
  */
3598
- async function linkable() {
3961
+ async function syncEntries() {
3599
3962
  const found = /* @__PURE__ */ new Map();
3600
- for (const entry of binding.sync) found.set(entry.key, {
3601
- ...entry,
3602
- url: binding.url()
3963
+ for (const { key, platform } of binding.sync) found.set(key, {
3964
+ key,
3965
+ platform
3603
3966
  });
3604
- const claimed = /* @__PURE__ */ new Map();
3605
3967
  for (const registration of Object.values((await registry.state()).byOwner)) {
3606
3968
  if (registration.sync.length === 0) continue;
3607
3969
  if (!(await binding.accessNow(registration.principal))?.anywhere("sync")) continue;
3608
- for (const entry of registration.sync) if (!found.has(entry.key)) (claimed.get(entry.key) ?? claimed.set(entry.key, []).get(entry.key)).push({
3609
- ...entry,
3610
- url: registration.url
3970
+ for (const { key, platform } of registration.sync) if (!found.has(key)) found.set(key, {
3971
+ key,
3972
+ platform
3611
3973
  });
3612
3974
  }
3613
- for (const [key, claims] of claimed) found.set(key, claims.length === 1 ? claims[0] : {
3614
- ...claims[0],
3615
- signIn: false,
3616
- url: null
3617
- });
3618
3975
  return [...found.values()];
3619
3976
  }
3620
3977
  /**
3621
- * 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
3622
3979
  * signs in, on a platform it syncs: the one of entry `from`, when it names one.
3623
3980
  */
3624
3981
  async function speaksFor(caller, principal, author, from) {
@@ -3787,43 +4144,6 @@ function build(options, binding) {
3787
4144
  });
3788
4145
  opened.add(channel);
3789
4146
  }
3790
- /** Records that platform user `identity` is `to`, unless it already is. `id` makes a retry a no-op. */
3791
- async function link(identity, to, method, id) {
3792
- const { actor: account, author: by } = identity;
3793
- const { state: actors } = await session.state(actorsReducer);
3794
- const seen = identity.records === false ? null : actorSeen(account, actors);
3795
- const existing = actors.links[account.id];
3796
- if (existing?.to === to && !id) {
3797
- if (seen) await session.append(seen);
3798
- return {
3799
- actor: account.id,
3800
- handle: account.handle,
3801
- name: account.name,
3802
- method: existing.method,
3803
- at: existing.at
3804
- };
3805
- }
3806
- await session.append(...seen ? [{
3807
- ...seen,
3808
- ...id ? { id: `${id}:seen` } : {}
3809
- }] : [], {
3810
- type: "actor.linked",
3811
- ...id ? { id } : {},
3812
- payload: {
3813
- actor: account.id,
3814
- to,
3815
- method,
3816
- by
3817
- }
3818
- });
3819
- return {
3820
- actor: account.id,
3821
- handle: account.handle,
3822
- name: account.name,
3823
- method,
3824
- at: (/* @__PURE__ */ new Date()).toISOString()
3825
- };
3826
- }
3827
4147
  const servers = [
3828
4148
  server,
3829
4149
  inboxServer,
@@ -3834,6 +4154,7 @@ function build(options, binding) {
3834
4154
  return {
3835
4155
  methods,
3836
4156
  schedules,
4157
+ trust: access.trust,
3837
4158
  /** The scheduler's callback, for every A2 server behind the board. */
3838
4159
  scheduler: options.scheduler ? options.scheduler.handler(...servers) : async (_request) => toErrorResponse(notFound("this board has no scheduler")),
3839
4160
  servers,
@@ -3879,6 +4200,11 @@ function a2(options = {}) {
3879
4200
  ...Object.fromEntries(METHODS.map((name) => [name, async (caller, input, signal) => (await ready()).methods[name](caller, input, signal)])),
3880
4201
  routes: [],
3881
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
+ },
3882
4208
  bind(given) {
3883
4209
  if (binding) throw new Error("a2: this backend already serves a board: create one a2() per board()");
3884
4210
  binding = given;