@vercel/board 0.0.0-stage → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +179 -2
  2. package/dist/a2-5EG8FkZ8.d.mts +3359 -0
  3. package/dist/a2-DYGYankn.mjs +3045 -0
  4. package/dist/access-CJU2-vaa.mjs +226 -0
  5. package/dist/access-CV96SYgr.d.mts +32 -0
  6. package/dist/access.d.mts +29 -0
  7. package/dist/access.mjs +4 -0
  8. package/dist/actors-rdN4dDTU.mjs +223 -0
  9. package/dist/attempts-BQUp9MKO.mjs +919 -0
  10. package/dist/auth-COzc09sn.mjs +276 -0
  11. package/dist/auth-wHFcAte3.d.mts +203 -0
  12. package/dist/backend-a2/blob-vercel.d.mts +8 -0
  13. package/dist/backend-a2/blob-vercel.mjs +81 -0
  14. package/dist/backend-a2/contract.d.mts +18 -0
  15. package/dist/backend-a2/contract.mjs +5 -0
  16. package/dist/backend-a2/scheduler-local.d.mts +11 -0
  17. package/dist/backend-a2/scheduler-local.mjs +2 -0
  18. package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
  19. package/dist/backend-a2/scheduler-qstash.mjs +2 -0
  20. package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
  21. package/dist/backend-a2/scheduler-vercel.mjs +2 -0
  22. package/dist/backend-a2/store-memory.d.mts +11 -0
  23. package/dist/backend-a2/store-memory.mjs +19 -0
  24. package/dist/backend-a2/store-postgres.d.mts +38 -0
  25. package/dist/backend-a2/store-postgres.mjs +110 -0
  26. package/dist/backend-a2/store-redis-http.d.mts +17 -0
  27. package/dist/backend-a2/store-redis-http.mjs +32 -0
  28. package/dist/backend-a2/store-redis.d.mts +23 -0
  29. package/dist/backend-a2/store-redis.mjs +39 -0
  30. package/dist/backend-a2/store-sqlite.d.mts +12 -0
  31. package/dist/backend-a2/store-sqlite.mjs +31 -0
  32. package/dist/backend-a2.d.mts +4 -0
  33. package/dist/backend-a2.mjs +2 -0
  34. package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
  35. package/dist/backend-http-BsDSgmeo.mjs +613 -0
  36. package/dist/backend-http-Cv6xbEy9.mjs +469 -0
  37. package/dist/backend-http.d.mts +10 -0
  38. package/dist/backend-http.mjs +2 -0
  39. package/dist/backend-http.node.d.mts +10 -0
  40. package/dist/backend-http.node.mjs +2 -0
  41. package/dist/blob-C0J3wbGv.d.mts +39 -0
  42. package/dist/bots-CGkO76qH.mjs +67 -0
  43. package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
  44. package/dist/chat-provider-jFSeyNmB.mjs +389 -0
  45. package/dist/cli.d.mts +1 -0
  46. package/dist/cli.mjs +2252 -0
  47. package/dist/client-DGDPaN7U.mjs +380 -0
  48. package/dist/conformance-DRR23YNy.mjs +512 -0
  49. package/dist/conformance.d.mts +30 -0
  50. package/dist/conformance.mjs +2 -0
  51. package/dist/contract-CuJmlrhw.mjs +566 -0
  52. package/dist/emoji-CTLpvH-A.mjs +445 -0
  53. package/dist/engine-3EvQ8idk.mjs +1449 -0
  54. package/dist/errors-DX3hpwWt.mjs +64 -0
  55. package/dist/exports-BpgyEdh6.d.mts +4 -0
  56. package/dist/exports-CyY3_FjZ.mjs +1285 -0
  57. package/dist/files-SniSmeEr.mjs +47 -0
  58. package/dist/index.d.mts +13 -0
  59. package/dist/index.default-BZ3lItjH.mjs +12 -0
  60. package/dist/index.mjs +19 -0
  61. package/dist/index.node.d.mts +14 -0
  62. package/dist/index.node.mjs +5 -0
  63. package/dist/inspect/app.js +416 -0
  64. package/dist/inspect/engine.js +5 -0
  65. package/dist/local-Cr_dSjvs.mjs +44 -0
  66. package/dist/own-OwgPWdmh.mjs +15 -0
  67. package/dist/project-CFiArraX.mjs +124 -0
  68. package/dist/provider-BGjz-mDa.d.mts +2473 -0
  69. package/dist/provider-CuewcDNZ.mjs +205 -0
  70. package/dist/redis-tables-B35vv4CZ.mjs +842 -0
  71. package/dist/reducers-B_vUGQRp.d.mts +1172 -0
  72. package/dist/reducers-mUPFuFJV.mjs +293 -0
  73. package/dist/schemas-Be08nyfW.mjs +74 -0
  74. package/dist/schemas-CWxs3ECJ.d.mts +50 -0
  75. package/dist/serve-Ccbb2LSb.mjs +170 -0
  76. package/dist/server-Cxey38B-.mjs +17 -0
  77. package/dist/serving-BNbzc96L.mjs +2 -0
  78. package/dist/sql-DbiEnqM0.mjs +571 -0
  79. package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
  80. package/dist/sync-github.d.mts +473 -0
  81. package/dist/sync-github.mjs +603 -0
  82. package/dist/sync-kapso.d.mts +175 -0
  83. package/dist/sync-kapso.mjs +86 -0
  84. package/dist/sync-slack.d.mts +364 -0
  85. package/dist/sync-slack.mjs +728 -0
  86. package/dist/sync.chat.d.mts +2 -0
  87. package/dist/sync.chat.mjs +2 -0
  88. package/dist/sync.d.mts +3 -0
  89. package/dist/sync.mjs +4 -0
  90. package/dist/targets-ojFEXEyN.mjs +94 -0
  91. package/dist/tokens-DKLn5P9B.mjs +256 -0
  92. package/dist/types-BHCjTZdB.d.mts +104 -0
  93. package/dist/users-Dls7_ANV.mjs +42 -0
  94. package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
  95. package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
  96. package/docs/01-board/01-mounting.md +506 -0
  97. package/docs/01-board/02-database.md +258 -0
  98. package/docs/01-board/03-identity.md +262 -0
  99. package/docs/01-board/04-messages.md +192 -0
  100. package/docs/01-board/05-reading.md +177 -0
  101. package/docs/01-board/06-watchers.md +249 -0
  102. package/docs/01-board/07-schedules.md +133 -0
  103. package/docs/01-board/08-middleware.md +139 -0
  104. package/docs/01-board/09-access.md +499 -0
  105. package/docs/01-quickstart.md +196 -0
  106. package/docs/02-sync/01-overview.md +290 -0
  107. package/docs/02-sync/02-callbacks.md +265 -0
  108. package/docs/02-sync/03-slack.md +248 -0
  109. package/docs/02-sync/04-github.md +201 -0
  110. package/docs/02-sync/05-whatsapp.md +148 -0
  111. package/docs/02-sync/06-providers.md +228 -0
  112. package/docs/03-guides/01-coding-agents.md +226 -0
  113. package/docs/03-guides/02-inspector.md +104 -0
  114. package/docs/03-guides/03-ci.md +251 -0
  115. package/docs/03-guides/04-triage.md +233 -0
  116. package/docs/03-guides/05-agent-factory.md +142 -0
  117. package/docs/04-reference/01-cli.md +606 -0
  118. package/docs/04-reference/02-board.md +579 -0
  119. package/docs/04-reference/03-http.md +489 -0
  120. package/docs/04-reference/04-log.md +250 -0
  121. package/docs/04-reference/05-conformance.md +97 -0
  122. package/docs/index.md +186 -0
  123. package/package.json +165 -3
  124. package/sql/postgres/board.sql +145 -0
  125. package/sql/postgres/notifications.sql +24 -0
  126. package/sql/postgres/schema.sql +451 -0
@@ -0,0 +1,3045 @@
1
+ import { n as DIRECT_UPLOAD_BYTES_MAX, r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
+ import { _ as RemoteWatcherId, b as actorKind, i as Attachment, x as platformAvatar } from "./schemas-Be08nyfW.mjs";
3
+ import { _ as schedulesContract, d as SCHEDULES_SESSION, f as boardContract, g as registryContract, h as inboxSession, m as inboxContract, o as IDENTITY_SESSION, p as identityContract, s as INBOXES_SESSION, t as BOARD_SESSION, u as REGISTRY_SESSION, v as watchContract, y as watchSession } from "./contract-CuJmlrhw.mjs";
4
+ import { s as isKeyIdOf, t as compileWatcher } from "./watch-spec-W0c7ypHJ.mjs";
5
+ import { r as duration, t as BoardError } from "./client-DGDPaN7U.mjs";
6
+ import { c as VERSION, l as SYNC, n as METHODS, t as canonicalWatchers } from "./watchers-hash-D9hbHLKB.mjs";
7
+ import { a as forbidden, c as toErrorResponse, r as badRequest, s as notFound, t as HttpError } from "./errors-DX3hpwWt.mjs";
8
+ import { a as resolveMentions, n as actorSeen, s as replaceMentions } from "./actors-rdN4dDTU.mjs";
9
+ import { n as authorize, t as FULL_ACCESS } from "./access-CJU2-vaa.mjs";
10
+ import { i as authenticates } from "./auth-COzc09sn.mjs";
11
+ import { a as MIDDLEWARE_ACTOR, i as DroppedError } from "./targets-ojFEXEyN.mjs";
12
+ import { n as channelsReducer, s as identityCodesReducer, t as actorsReducer, u as linkedTo } from "./reducers-mUPFuFJV.mjs";
13
+ import { A as view, C as linkedIds, D as readableFilter, E as pollFeed, M as viewedBy, O as ref, S as indexOf, T as personView, _ as waitUntil, a as blindText, b as eventView, c as landedFor, d as writeDelete, f as writeEdit, g as writeReaction, h as writePost, i as authorizePost, j as viewLinkedIds, k as toMessageFilter, l as trusted, m as writeLinks, n as authorizeMessage, p as writeLabels, r as authorizeOwn, s as deploymentIdentity, t as afterRetry, u as writeAttach, v as OWN_CODE, w as loadBefore, x as foldEvent, y as compileFilter } from "./attempts-BQUp9MKO.mjs";
14
+ import { t as TABLES_CONSUMER } from "./sql-DbiEnqM0.mjs";
15
+ import { A2Error, idempotentId, reducer } from "experimental-a2";
16
+ import { createHash, randomBytes } from "node:crypto";
17
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
18
+ import { dirname, join } from "node:path";
19
+ import { AsyncLocalStorage } from "node:async_hooks";
20
+ import { consumer, createServer } from "experimental-a2/server";
21
+ import { lookup } from "node:dns";
22
+ import { request } from "node:https";
23
+ import { BlockList, isIP } from "node:net";
24
+ //#region src/server/blob.ts
25
+ /** Files in memory, for tests: forgets everything when the process ends. */
26
+ function memoryBlob() {
27
+ const files = /* @__PURE__ */ new Map();
28
+ return {
29
+ put: async (key, data, contentType) => void files.set(key, {
30
+ data: data.slice(),
31
+ contentType
32
+ }),
33
+ get: async (key) => files.get(key) ?? null
34
+ };
35
+ }
36
+ /** Files on disk, for development and `vb serve`: under `dir`, `.a2/attachments` by default. */
37
+ function fsBlob(dir = ".a2/attachments") {
38
+ const path = (key) => join(dir, ...key.split("/"));
39
+ return {
40
+ async put(key, data, contentType) {
41
+ await mkdir(dirname(path(key)), { recursive: true });
42
+ await writeFile(path(key), data);
43
+ await writeFile(`${path(key)}.type`, contentType);
44
+ },
45
+ async get(key) {
46
+ try {
47
+ const [data, contentType] = await Promise.all([readFile(path(key)), readFile(`${path(key)}.type`, "utf8")]);
48
+ return {
49
+ data: new Uint8Array(data),
50
+ contentType
51
+ };
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+ };
57
+ }
58
+ const dataKey = (id) => `board/attachments/${id}`;
59
+ /** A direct upload the board signed, until it's finished: what it's called, and who may finish it. */
60
+ const ticketKey = (id) => `board/uploads/${id}.json`;
61
+ const metaKey = (id) => `board/attachments/${id}.json`;
62
+ /** `shot.png`, as the board stores a name: no paths, no control characters. */
63
+ function fileName(name) {
64
+ return (name ?? "").split(/[\\/]/).pop().replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 255) || "file";
65
+ }
66
+ /** `image/png`, or `application/octet-stream` when the type isn't one. */
67
+ function mediaType(type) {
68
+ const value = (type ?? "").split(";")[0].trim().toLowerCase();
69
+ return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(value) && value.length <= 255 ? value : "application/octet-stream";
70
+ }
71
+ /** A board without a blob store keeps no files: every file route says so. */
72
+ const noBlob = () => new HttpError(501, "no_files", "this board keeps no files: pass `blob` to `a2()`");
73
+ /** 128 random bits, base64url, drawn again when they'd start with `-`, which a command line reads as a flag. */
74
+ function attachmentId() {
75
+ for (;;) {
76
+ const id = randomBytes(16).toString("base64url");
77
+ if (!id.startsWith("-")) return id;
78
+ }
79
+ }
80
+ /**
81
+ * Stores an upload under a fresh ID, with its metadata beside it. `uploadedBy`
82
+ * is the principal that sent it, who can read it before any message carries it.
83
+ * `max` is the most it can be: `ATTACHMENT_BYTES_MAX` unless the board's own sync imports it.
84
+ */
85
+ async function writeUpload(blob, input, max = ATTACHMENT_BYTES_MAX) {
86
+ if (!blob) throw noBlob();
87
+ if (input.data.byteLength === 0) throw badRequest("the file is empty");
88
+ if (input.data.byteLength > max) throw new HttpError(413, "too_large", `attachments are at most ${max / 1024 / 1024} MB`);
89
+ const attachment = {
90
+ id: attachmentId(),
91
+ name: fileName(input.name),
92
+ type: mediaType(input.type),
93
+ size: input.data.byteLength
94
+ };
95
+ await blob.put(dataKey(attachment.id), input.data, attachment.type);
96
+ const meta = input.uploadedBy === void 0 ? attachment : {
97
+ ...attachment,
98
+ uploadedBy: input.uploadedBy
99
+ };
100
+ await blob.put(metaKey(attachment.id), new TextEncoder().encode(JSON.stringify(meta)), "application/json");
101
+ return attachment;
102
+ }
103
+ async function bytes(data) {
104
+ return data instanceof Uint8Array ? data : new Uint8Array(await new Response(data).arrayBuffer());
105
+ }
106
+ const attachmentNotFound = (id) => notFound(`no attachment: ${id}`, "attachment_not_found");
107
+ /**
108
+ * An upload's metadata, as the board recorded it, never what a request says,
109
+ * and who uploaded it: null for files the board imported itself, or stored
110
+ * before it recorded that.
111
+ */
112
+ async function readUpload(blob, id) {
113
+ if (!blob) throw noBlob();
114
+ const found = Attachment.shape.id.safeParse(id).success ? await blob.get(metaKey(id)) : null;
115
+ if (!found) throw attachmentNotFound(id);
116
+ const meta = JSON.parse(new TextDecoder().decode(await bytes(found.data)));
117
+ return {
118
+ attachment: Attachment.parse(meta),
119
+ uploadedBy: typeof meta.uploadedBy === "string" ? meta.uploadedBy : null
120
+ };
121
+ }
122
+ /** An attachment's bytes as the store keeps them, a stream or the bytes, for board() to serve. */
123
+ async function attachmentBody(blob, id) {
124
+ if (!blob) throw noBlob();
125
+ const found = await blob.get(dataKey(id));
126
+ if (!found) throw attachmentNotFound(id);
127
+ return found.data;
128
+ }
129
+ /** How long a direct upload's URL takes bytes: long enough for 100 MB on a slow link. */
130
+ const DIRECT_UPLOAD_TTL_MS = 18e5;
131
+ const noDirectUploads = () => new HttpError(501, "no_direct_uploads", `this board's blob store can't take direct uploads, so files are at most ${ATTACHMENT_BYTES_MAX / 1024 / 1024} MB`);
132
+ /**
133
+ * Starts a direct upload: a fresh ID, and a URL in the store to `PUT` its bytes to. The board
134
+ * keeps what the request said the file is until `finishUpload` checks the store has it.
135
+ */
136
+ async function startUpload(blob, input) {
137
+ if (!blob) throw noBlob();
138
+ if (!blob.uploadUrl || !blob.head) throw noDirectUploads();
139
+ if (input.size > 104857600) throw new HttpError(413, "too_large", `direct uploads are at most ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB`);
140
+ const expires = new Date(Date.now() + DIRECT_UPLOAD_TTL_MS);
141
+ const ticket = {
142
+ id: attachmentId(),
143
+ name: fileName(input.name),
144
+ type: mediaType(input.type),
145
+ size: input.size,
146
+ uploadedBy: input.uploadedBy,
147
+ expiresAt: expires.toISOString()
148
+ };
149
+ await blob.put(ticketKey(ticket.id), new TextEncoder().encode(JSON.stringify(ticket)), "application/json");
150
+ const { url, headers } = await blob.uploadUrl(dataKey(ticket.id), {
151
+ contentType: ticket.type,
152
+ maxBytes: ticket.size,
153
+ expiresAt: expires
154
+ });
155
+ return {
156
+ id: ticket.id,
157
+ url,
158
+ headers: {
159
+ ...headers,
160
+ "content-type": ticket.type
161
+ },
162
+ expiresAt: ticket.expiresAt
163
+ };
164
+ }
165
+ /**
166
+ * Finishes a direct upload: once the store has exactly the bytes its start said, the board records
167
+ * the attachment, as `writeUpload` does. Only its uploader may finish it; a second finish answers
168
+ * the same attachment. It never reads the record before writing it: a store with a cache (Vercel
169
+ * Blob's CDN) would keep that miss, and the post that names the file would find nothing.
170
+ */
171
+ async function finishUpload(blob, id, principal) {
172
+ if (!blob) throw noBlob();
173
+ const found = Attachment.shape.id.safeParse(id).success ? await blob.get(ticketKey(id)) : null;
174
+ const ticket = found ? JSON.parse(new TextDecoder().decode(await bytes(found.data))) : null;
175
+ if (!ticket || ticket.uploadedBy !== principal) throw notFound(`no upload: ${id}`, "upload_not_found");
176
+ if (!blob.head) throw noDirectUploads();
177
+ const stored = await blob.head(dataKey(id));
178
+ if (!stored) throw new HttpError(409, "not_uploaded", `nothing's been uploaded for ${id} yet: PUT the file to its URL first`);
179
+ if (stored.size !== ticket.size) throw badRequest(`the store has ${stored.size} bytes for ${id}, but its upload said ${ticket.size}`, "size_mismatch");
180
+ const attachment = {
181
+ id,
182
+ name: ticket.name,
183
+ type: ticket.type,
184
+ size: stored.size
185
+ };
186
+ try {
187
+ await blob.put(metaKey(id), new TextEncoder().encode(JSON.stringify({
188
+ ...attachment,
189
+ uploadedBy: principal
190
+ })), "application/json");
191
+ } catch (error) {
192
+ const done = await blob.get(metaKey(id)).catch(() => null);
193
+ if (!done) throw error;
194
+ return Attachment.parse(JSON.parse(new TextDecoder().decode(await bytes(done.data))));
195
+ }
196
+ return attachment;
197
+ }
198
+ //#endregion
199
+ //#region src/server/reads.ts
200
+ /** Keeps the tables from the board's log, one event at a time, from the first. */
201
+ function tablesConsumer(tables) {
202
+ return consumer({
203
+ contract: boardContract,
204
+ name: TABLES_CONSUMER,
205
+ lane: "tables",
206
+ backfill: true
207
+ }).run(async (ctx) => {
208
+ if (ctx.session.id !== "board") return;
209
+ await tables.apply(ctx.event);
210
+ });
211
+ }
212
+ /**
213
+ * The board's reads from its tables. A read that names a log index (a message
214
+ * ID is one) waits until the tables have applied it; list reads don't wait, and
215
+ * say which index they reflect.
216
+ */
217
+ function createReads(tables, session, projection) {
218
+ /**
219
+ * The first list read on an instance starts the tables catching up, and doesn't wait: after a deploy
220
+ * that adds them, or on a quiet board, nothing else may have yet.
221
+ */
222
+ let catching = null;
223
+ /**
224
+ * The reader of views whose links point to the board messages `linked`, looked up only for a
225
+ * reader that can't read every channel. A linked message is older than the one linking it, so
226
+ * the tables have it.
227
+ */
228
+ async function readerOf(access, linked) {
229
+ if (access.readsAll) return {
230
+ access,
231
+ channelOf: () => void 0
232
+ };
233
+ const ids = [...new Set(linked)].filter((id) => id > 0);
234
+ const found = ids.length === 0 ? /* @__PURE__ */ new Map() : await tables.messagesAt(ids, await tables.position());
235
+ return {
236
+ access,
237
+ channelOf: (id) => found.get(id)?.channel
238
+ };
239
+ }
240
+ function catchUp() {
241
+ if (catching) return;
242
+ catching = session.consume(projection).catch(() => {
243
+ catching = null;
244
+ });
245
+ waitUntil(catching);
246
+ }
247
+ /** Waits until the tables have applied `index`, or the whole log when it's omitted. */
248
+ async function ensure(index, signal) {
249
+ if (index !== void 0 && await tables.position() >= index) return;
250
+ await session.consume(projection, {
251
+ ...index !== void 0 ? { through: index } : {},
252
+ ...signal ? { signal } : {}
253
+ });
254
+ }
255
+ return {
256
+ tables,
257
+ ensure,
258
+ position: () => tables.position(),
259
+ async message(id, signal) {
260
+ await ensure(id, signal);
261
+ return tables.message(id);
262
+ },
263
+ /** A thread's root and replies; `cursor` is the index the tables had applied before reading it. */
264
+ async thread(root, signal) {
265
+ await ensure(root, signal);
266
+ const cursor = await tables.position();
267
+ return {
268
+ messages: await tables.thread(root),
269
+ cursor
270
+ };
271
+ },
272
+ async messagesAt(ids, index, signal) {
273
+ await ensure(index, signal);
274
+ return tables.messagesAt(ids, index);
275
+ },
276
+ /** By event ID, as of the whole log: an ID doesn't say how far to wait. */
277
+ async byEventId(eventId, signal) {
278
+ await ensure(void 0, signal);
279
+ return tables.byEventId(eventId);
280
+ },
281
+ /** The reader of views of `messages`: what a link shows depends on where the message it points to lives. */
282
+ reader: (access, messages) => readerOf(access, [...messages].flatMap((message) => linkedIds(message.links))),
283
+ readerOf,
284
+ async messages(filter, page) {
285
+ catchUp();
286
+ if (filter.channels?.length === 0) return {
287
+ messages: [],
288
+ position: await tables.position()
289
+ };
290
+ return tables.messages(filter, page);
291
+ },
292
+ async threads(filter, page) {
293
+ catchUp();
294
+ if (filter.channels?.length === 0) return {
295
+ threads: [],
296
+ position: await tables.position()
297
+ };
298
+ return tables.threads(filter, page);
299
+ }
300
+ };
301
+ }
302
+ //#endregion
303
+ //#region src/server/schedules.ts
304
+ /** Vercel Queues delays at most 6 days: a send further out than this goes in hops. */
305
+ const MAX_HOP_MS = 432e6;
306
+ /** A timer that fires this early just waits again; any closer counts as on time. */
307
+ const EARLY_MS = 1e3;
308
+ const identityOf = (as) => {
309
+ const { author, principal, actor } = as.payload.as;
310
+ return {
311
+ author,
312
+ principal: {
313
+ ...principal,
314
+ expiresAt: null
315
+ },
316
+ actor: {
317
+ ...actor,
318
+ kind: actorKind(actor.kind)
319
+ }
320
+ };
321
+ };
322
+ function foldSchedules(state, event) {
323
+ switch (event.type) {
324
+ case "schedule.created": {
325
+ const { payload } = event;
326
+ state.byId[event.index] = {
327
+ id: event.index,
328
+ channel: payload.channel,
329
+ re: payload.re,
330
+ text: payload.text,
331
+ labels: { ...payload.labels },
332
+ noSync: payload.noSync,
333
+ attachments: payload.attachments ?? [],
334
+ first: payload.first,
335
+ every: payload.every,
336
+ until: payload.until,
337
+ times: payload.times,
338
+ as: identityOf(event),
339
+ createdAt: new Date(event.createdAt).toISOString(),
340
+ blind: payload.blind ?? false,
341
+ sent: 0,
342
+ next: payload.first,
343
+ last: null,
344
+ canceled: null
345
+ };
346
+ return state;
347
+ }
348
+ case "schedule.canceled": {
349
+ const schedule = state.byId[event.payload.id];
350
+ if (!schedule || schedule.next === null) return state;
351
+ schedule.next = null;
352
+ schedule.canceled = {
353
+ by: event.payload.by,
354
+ reason: event.payload.reason
355
+ };
356
+ return state;
357
+ }
358
+ case "schedule.fired": {
359
+ const schedule = state.byId[event.payload.id];
360
+ if (!schedule || schedule.next === null || schedule.sent !== event.payload.n) return state;
361
+ schedule.sent++;
362
+ schedule.last = event.payload.message;
363
+ schedule.next = event.payload.next;
364
+ return state;
365
+ }
366
+ default: return state;
367
+ }
368
+ }
369
+ const schedulesReducer = reducer({
370
+ contract: schedulesContract,
371
+ name: "board.schedules.v1",
372
+ initialState: { byId: {} }
373
+ }).fold(foldSchedules);
374
+ /** The send after one due at `at`: the next slot after now, so downtime skips missed slots instead of replaying them. */
375
+ function nextSend(schedule, n, at, now) {
376
+ if (schedule.every === null) return null;
377
+ if (schedule.times !== null && n + 1 >= schedule.times) return null;
378
+ const next = at + schedule.every * Math.max(1, Math.floor((now - at) / schedule.every) + 1);
379
+ return schedule.until !== null && next > schedule.until ? null : next;
380
+ }
381
+ /**
382
+ * Whether `principal` is the schedule's creator, as far as seeing and canceling
383
+ * it goes. A principal ID is coarser than access (a repo's workflows share one,
384
+ * and so do the proxies narrowing one token with `x-board-scope`), so it also
385
+ * needs `post` there now, as it would to create it. Where it can't read, the
386
+ * schedule is its own only if it was created without reading too, so a
387
+ * principal ID shared with a caller that reads doesn't reach that caller's.
388
+ */
389
+ const createdBy = (schedule, principal) => schedule.as.principal.id === principal.id && principal.access.can("post", schedule.channel) && (schedule.blind || principal.access.readable(schedule.channel));
390
+ const scheduleView = (schedule, actors) => {
391
+ const author = actors.byId[schedule.as.actor.id] ?? schedule.as.actor;
392
+ return {
393
+ id: ref(schedule.id),
394
+ channel: schedule.channel,
395
+ re: schedule.re === null ? null : ref(schedule.re),
396
+ text: schedule.text,
397
+ labels: { ...schedule.labels },
398
+ noSync: schedule.noSync,
399
+ attachments: schedule.attachments,
400
+ first: new Date(schedule.first).toISOString(),
401
+ every: schedule.every,
402
+ until: schedule.until === null ? null : new Date(schedule.until).toISOString(),
403
+ times: schedule.times,
404
+ sent: schedule.sent,
405
+ next: schedule.next === null ? null : new Date(schedule.next).toISOString(),
406
+ last: schedule.last === null ? null : ref(schedule.last),
407
+ state: schedule.canceled ? "canceled" : schedule.next === null ? "done" : "pending",
408
+ canceled: schedule.canceled,
409
+ by: schedule.as.author,
410
+ author: {
411
+ kind: author.kind,
412
+ handle: author.handle,
413
+ name: author.name,
414
+ avatar: author.avatar
415
+ },
416
+ createdAt: schedule.createdAt
417
+ };
418
+ };
419
+ const SYSTEM = deploymentIdentity("board:schedule", "schedule");
420
+ /** A refusal (a 403, or a 404 for a thread the creator can no longer read) as null; anything else still throws. */
421
+ const refused = (error) => {
422
+ if (error instanceof HttpError && (error.status === 403 || error.status === 404)) return null;
423
+ throw error;
424
+ };
425
+ /**
426
+ * Scheduled messages: `schedule.created` arms the first send as an A2 timer, and
427
+ * each `schedule.due` posts as the schedule's creator, then arms the next.
428
+ */
429
+ function createSchedules(options) {
430
+ let writes = null;
431
+ let accessNow = null;
432
+ /** Posts as `identity`, through what every post does: the middleware too. */
433
+ let send = null;
434
+ /** Arms send `n`, due at `at`: the whole way, or one hop toward it. */
435
+ async function arm(ctx, name, id, n, at) {
436
+ const hop = Math.min(at, Date.now() + MAX_HOP_MS);
437
+ await ctx.session.schedule(name, { at: new Date(Math.max(hop, Date.now())) }, {
438
+ type: "schedule.due",
439
+ payload: {
440
+ id,
441
+ n,
442
+ at
443
+ }
444
+ });
445
+ }
446
+ async function due(ctx) {
447
+ if (!writes || !accessNow || !send) throw new Error("schedules used before the board bound them");
448
+ const { id, n, at } = ctx.event.payload;
449
+ const { state } = await ctx.session.state(schedulesReducer);
450
+ const schedule = state.byId[id];
451
+ if (!schedule || schedule.next !== at || schedule.sent !== n) return;
452
+ if (Date.now() < at - EARLY_MS) return arm(ctx, "hop", id, n, at);
453
+ if (schedule.re !== null) {
454
+ const root = await writes.reads.message(schedule.re, ctx.signal);
455
+ if (!root || root.deleted) {
456
+ await ctx.session.append("ended", {
457
+ type: "schedule.canceled",
458
+ payload: {
459
+ id,
460
+ by: SYSTEM.author,
461
+ reason: "thread_deleted"
462
+ }
463
+ });
464
+ return;
465
+ }
466
+ }
467
+ const access = await accessNow(schedule.as.principal);
468
+ const target = schedule.re === null ? { channel: schedule.channel } : { re: schedule.re };
469
+ const proofs = access && await authorizePost(writes, access, {
470
+ ...target,
471
+ labels: schedule.labels
472
+ }).catch(refused);
473
+ if (!access || !proofs) {
474
+ console.info(`[board] schedule s${id}'s creator can no longer post it in ${schedule.channel}`);
475
+ await ctx.session.append("ended", {
476
+ type: "schedule.canceled",
477
+ payload: {
478
+ id,
479
+ by: SYSTEM.author,
480
+ reason: "revoked"
481
+ }
482
+ });
483
+ return;
484
+ }
485
+ const input = {
486
+ ...schedule.re === null ? { channel: schedule.channel } : { re: ref(schedule.re) },
487
+ id: await idempotentId("board-schedule", String(id), String(n)),
488
+ text: schedule.text,
489
+ labels: schedule.labels,
490
+ noSync: schedule.noSync,
491
+ ...schedule.attachments.length ? { attachments: schedule.attachments.map((file) => file.id) } : {}
492
+ };
493
+ let posted;
494
+ try {
495
+ const sent = await send({
496
+ ...schedule.as,
497
+ principal: {
498
+ ...schedule.as.principal,
499
+ access
500
+ }
501
+ }, {
502
+ via: {
503
+ kind: "schedule",
504
+ id
505
+ },
506
+ schedule: {
507
+ id,
508
+ n
509
+ }
510
+ }, input);
511
+ posted = indexOf(sent.id);
512
+ } catch (error) {
513
+ if (!(error instanceof DroppedError)) throw error;
514
+ console.info(`[board] the middleware dropped schedule s${id}: ${error.message}`);
515
+ await ctx.session.append("ended", {
516
+ type: "schedule.canceled",
517
+ payload: {
518
+ id,
519
+ by: SYSTEM.author,
520
+ reason: "dropped"
521
+ }
522
+ });
523
+ return;
524
+ }
525
+ const next = nextSend(schedule, n, at, Date.now());
526
+ await ctx.session.append("fired", {
527
+ type: "schedule.fired",
528
+ payload: {
529
+ id,
530
+ n,
531
+ message: posted,
532
+ next
533
+ }
534
+ });
535
+ if (next !== null) await arm(ctx, "next", id, n + 1, next);
536
+ }
537
+ /**
538
+ * Arms each schedule's next send, as the schedule stands now, from the first schedule on: a queue
539
+ * delivers a timer to the deployment that armed it, so one an earlier deployment armed may never
540
+ * reach this one. The deployment that gets there first sends, and `due` makes the other's a no-op.
541
+ */
542
+ const arming = consumer({
543
+ contract: schedulesContract,
544
+ name: "board-schedules",
545
+ types: ["schedule.created"],
546
+ lane: "schedules",
547
+ backfill: true
548
+ }).run(async (ctx) => {
549
+ if (!options.scheduler) return;
550
+ const { state } = await ctx.session.state(schedulesReducer, { through: "latest" });
551
+ const schedule = state.byId[ctx.event.index];
552
+ if (!schedule || schedule.next === null) return;
553
+ await arm(ctx, "first", schedule.id, schedule.sent, schedule.next);
554
+ });
555
+ /** Makes each due send, one schedule at a time. */
556
+ const sending = consumer({
557
+ contract: schedulesContract,
558
+ name: "board-schedule-sends",
559
+ types: ["schedule.due"],
560
+ lane: ({ event }) => `schedule:${event.payload.id}`
561
+ }).run(due);
562
+ const server = createServer({
563
+ contract: schedulesContract,
564
+ ...options.store ? { store: options.store } : {},
565
+ ...options.scheduler ? { scheduler: options.scheduler } : {},
566
+ consumers: [arming, sending]
567
+ });
568
+ const session = server.session(SCHEDULES_SESSION);
569
+ /**
570
+ * The first request on an instance starts the arming catching up, and doesn't wait: on the
571
+ * first deploy with it, every schedule from before gets its next send armed here.
572
+ */
573
+ let catching = null;
574
+ function catchUp() {
575
+ if (catching || !options.scheduler) return;
576
+ catching = session.consume(arming).catch((error) => {
577
+ console.warn("[board] schedules are not caught up:", error.message);
578
+ catching = null;
579
+ });
580
+ waitUntil(catching);
581
+ }
582
+ /** `visible`: which schedules the caller may see. */
583
+ async function list(query, visible = () => true) {
584
+ if (!writes) throw new Error("schedules used before the board bound them");
585
+ const [{ state }, { state: actors }] = await Promise.all([session.state(schedulesReducer), writes.session.state(actorsReducer)]);
586
+ return Object.values(state.byId).filter(visible).filter((s) => query.all ? true : s.next !== null).filter((s) => query.root === void 0 ? true : s.re === query.root).filter((s) => query.channel === void 0 ? true : s.channel === query.channel).filter((s) => query.by === void 0 ? true : s.as.actor.id === query.by || s.as.actor.handle.toLowerCase() === query.by.toLowerCase()).sort((a, b) => a.id - b.id).map((s) => scheduleView(s, actors));
587
+ }
588
+ return {
589
+ server,
590
+ session,
591
+ catchUp,
592
+ bind: (binding) => {
593
+ writes = binding.writes;
594
+ accessNow = binding.accessNow;
595
+ send = binding.send;
596
+ },
597
+ list,
598
+ /** The pending schedules that reply in the thread rooted at `root`. */
599
+ forThread: (root) => list({ root }),
600
+ async create(identity, body, attachments) {
601
+ if (!writes) throw new Error("schedules used before the board bound them");
602
+ if (!options.scheduler) throw new HttpError(501, "no_scheduler", "this board has no scheduler: pass `scheduler` to `a2()` to send later");
603
+ const now = Date.now();
604
+ const every = body.every === void 0 ? null : duration(body.every);
605
+ if (every !== null && every < 6e4) throw badRequest("`every` is at least 1m");
606
+ if (every !== null && every > 316224e5) throw badRequest("`every` is at most a year");
607
+ const first = body.at !== void 0 ? Date.parse(body.at) : now + duration(body.in ?? body.every);
608
+ if (first < now - 6e4) throw badRequest("`at` is in the past");
609
+ const until = body.until !== void 0 ? Date.parse(body.until) : body.for !== void 0 ? first + duration(body.for) : null;
610
+ if (until !== null && until < first) throw badRequest("the schedule stops before its first send");
611
+ 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");
612
+ const { state: known } = await writes.session.state(actorsReducer);
613
+ const withoutEmails = replaceMentions(body.text, (t) => t.kind === "actor" && t.actor.includes("@") ? t.actor : null);
614
+ const { unknown } = resolveMentions(withoutEmails, known, identity.actor, false, identity.principal.access.readsAll);
615
+ let channel = body.channel;
616
+ let re = null;
617
+ if (body.re !== void 0) {
618
+ const target = await writes.reads.message(body.re);
619
+ if (!target || target.deleted) throw notFound(`message not found: #${body.re}`, "message_not_found");
620
+ re = target.re ?? target.id;
621
+ channel = target.channel;
622
+ }
623
+ const [row] = await session.append({
624
+ type: "schedule.created",
625
+ payload: {
626
+ channel,
627
+ re,
628
+ text: body.text,
629
+ labels: body.labels ?? {},
630
+ noSync: body.noSync ?? false,
631
+ ...attachments.length ? { attachments } : {},
632
+ first,
633
+ every,
634
+ until,
635
+ times: body.times ?? null,
636
+ as: {
637
+ author: identity.author,
638
+ principal: {
639
+ id: identity.principal.id,
640
+ kind: identity.principal.kind,
641
+ handle: identity.principal.handle,
642
+ ...identity.principal.facts ? { facts: identity.principal.facts } : {},
643
+ ...identity.principal.scope !== void 0 ? { scope: identity.principal.scope } : {}
644
+ },
645
+ actor: identity.actor
646
+ },
647
+ ...identity.principal.access.readable(channel) ? {} : { blind: true }
648
+ }
649
+ });
650
+ const { state } = await session.state(schedulesReducer);
651
+ const { state: actors } = await writes.session.state(actorsReducer);
652
+ const schedule = scheduleView(state.byId[row.index], actors);
653
+ return unknown.length && identity.principal.access.readable(channel) ? {
654
+ schedule,
655
+ unknownMentions: unknown
656
+ } : { schedule };
657
+ },
658
+ /**
659
+ * Its creator may cancel it while they can post there, and so may `manage`
660
+ * in its channel. Anyone else gets 403 where they can read it, and 404
661
+ * where they can't. Canceling twice is a no-op.
662
+ */
663
+ async cancel(identity, id) {
664
+ if (!writes) throw new Error("schedules used before the board bound them");
665
+ const { state } = await session.state(schedulesReducer);
666
+ const schedule = state.byId[id];
667
+ const missing = () => notFound(`schedule not found: s${id}`, "schedule_not_found");
668
+ if (!schedule) throw missing();
669
+ if (!createdBy(schedule, identity.principal)) authorize(identity.principal.access, "manage", schedule.channel, missing);
670
+ const canceled = schedule.next !== null;
671
+ if (canceled) await session.append({
672
+ type: "schedule.canceled",
673
+ payload: {
674
+ id,
675
+ by: identity.author,
676
+ reason: "canceled"
677
+ }
678
+ });
679
+ const { state: after } = await session.state(schedulesReducer);
680
+ const { state: actors } = await writes.session.state(actorsReducer);
681
+ return {
682
+ schedule: scheduleView(after.byId[id], actors),
683
+ canceled
684
+ };
685
+ }
686
+ };
687
+ }
688
+ /** How long a pulled match is its puller's before someone may pull it again. */
689
+ const LEASE_MS = 3e5;
690
+ /** After an error, a match waits 1 second, doubling to 30. */
691
+ const retryDelay = (attempts) => Math.min(3e4, 1e3 * 2 ** (attempts - 1));
692
+ /**
693
+ * Sweeps fall on the minute: a registrant has at most one timer a minute, however many matches it
694
+ * pulls. A sweep wakes it for leases that ran out, and for a wake-up that didn't arrive.
695
+ */
696
+ const SWEEP_MS = 6e4;
697
+ /** The sweep that covers `at`: the next minute at least a second after it, since a timer may land up to a second early. */
698
+ const sweepFor = (at) => Math.ceil((at + 1e3) / SWEEP_MS) * SWEEP_MS;
699
+ /** The longest a registrant whose wake-ups don't get there waits for the next one. */
700
+ const WAKE_BACKOFF_MAX_MS = 36e5;
701
+ /**
702
+ * After `failures` wake-ups in a row that didn't get there, how long until the next: a minute,
703
+ * doubling to an hour. Until then a match only queues.
704
+ */
705
+ const wakeBackoff = (failures) => Math.min(WAKE_BACKOFF_MAX_MS, SWEEP_MS * 2 ** (failures - 1));
706
+ /** The queue of platform events a registrant pushed for sync key `key`. */
707
+ const inboxQueue = (key) => `@inbox:${key}`;
708
+ const queueKey = (queue, thread) => `${queue}\u001f${thread}`;
709
+ function remove(state, id) {
710
+ const found = state.pending[id];
711
+ if (!found) return;
712
+ delete state.pending[id];
713
+ const key = queueKey(found.queue, found.thread);
714
+ const rest = (state.queues[key] ?? []).filter((queued) => queued !== id);
715
+ if (rest.length > 0) state.queues[key] = rest;
716
+ else delete state.queues[key];
717
+ }
718
+ function foldQueue(state, event) {
719
+ switch (event.type) {
720
+ case "watch.matched": {
721
+ const { watcher, sync, inbox, thread } = event.payload;
722
+ const queue = watcher ?? (sync ? "@sync" : inbox !== void 0 ? inboxQueue(inbox) : void 0);
723
+ if (queue === void 0 || state.pending[event.id]) return state;
724
+ state.pending[event.id] = {
725
+ id: event.id,
726
+ queue,
727
+ thread,
728
+ event: event.payload.event,
729
+ attempts: 0,
730
+ lease: null,
731
+ retryAt: null
732
+ };
733
+ (state.queues[queueKey(queue, thread)] ??= []).push(event.id);
734
+ return state;
735
+ }
736
+ case "watch.leased": {
737
+ const found = state.pending[event.payload.matched];
738
+ if (found && !found.lease) found.lease = {
739
+ id: event.payload.lease,
740
+ until: event.payload.until
741
+ };
742
+ return state;
743
+ }
744
+ case "watch.acked": {
745
+ const found = state.pending[event.payload.matched];
746
+ if (!found || found.lease?.id !== event.payload.lease) return state;
747
+ if (event.payload.error === void 0) {
748
+ remove(state, found.id);
749
+ return state;
750
+ }
751
+ found.attempts += 1;
752
+ found.lease = null;
753
+ found.retryAt = event.payload.retryAt ?? null;
754
+ return state;
755
+ }
756
+ case "watch.expired": {
757
+ const found = state.pending[event.payload.matched];
758
+ if (!found || found.lease?.id !== event.payload.lease) return state;
759
+ found.attempts += 1;
760
+ found.lease = null;
761
+ return state;
762
+ }
763
+ case "watch.retry": {
764
+ const found = state.pending[event.payload.matched];
765
+ if (found) found.retryAt = null;
766
+ return state;
767
+ }
768
+ case "watch.armed":
769
+ if (!state.sweeps.includes(event.payload.at)) state.sweeps.push(event.payload.at);
770
+ return state;
771
+ case "watch.due": {
772
+ const { sweep } = event.payload;
773
+ if (sweep !== void 0) state.sweeps = state.sweeps.filter((at) => at !== sweep);
774
+ return state;
775
+ }
776
+ case "watch.failed":
777
+ case "watch.skipped":
778
+ remove(state, event.payload.matched);
779
+ return state;
780
+ case "watch.unreachable":
781
+ state.unreachable = {
782
+ failures: event.payload.failures,
783
+ until: event.payload.until
784
+ };
785
+ return state;
786
+ case "watch.reachable":
787
+ state.unreachable = null;
788
+ return state;
789
+ default: return state;
790
+ }
791
+ }
792
+ const queueReducer = reducer({
793
+ contract: watchContract,
794
+ name: "board.watch-queue.v4",
795
+ initialState: {
796
+ queues: {},
797
+ pending: {},
798
+ sweeps: [],
799
+ unreachable: null
800
+ }
801
+ }).fold(foldQueue);
802
+ const describe$1 = (error) => ((error instanceof Error ? error.message : String(error)) || "unknown error").slice(0, 500);
803
+ /**
804
+ * Whether `access` may see `event` now, as it was matched: a message's channel, or every channel for
805
+ * none. Sync's queue gets what its registrant may sync, since it speaks for platform users there.
806
+ */
807
+ function readable(access, event, queue) {
808
+ const channel = event.message?.channel;
809
+ if (queue === "@sync") return channel === void 0 ? access.everywhere("sync") : access.can("sync", channel);
810
+ return channel === void 0 ? access.readsAll : access.readable(channel);
811
+ }
812
+ /** An append that holds an event ID already in the log: another pull's lease. */
813
+ const taken = (error) => error instanceof A2Error && error.code === "PARTIAL_DUPLICATE_BATCH";
814
+ /** Whether a match is free to pull now: no one holds it, and its wait after an error is over. A lease or wait whose time has passed counts as over. */
815
+ const free = (found, now) => !(found.lease && found.lease.until > now) && !(found.retryAt !== null && found.retryAt > now);
816
+ /** Whether the registrant has anything to pull now. */
817
+ const hasWork = (state, now) => Object.values(state.queues).some((ids) => free(state.pending[ids[0]], now));
818
+ /**
819
+ * Hands out what the registrant's watchers and sync may run now: each queue's oldest match, if it's
820
+ * free, leased to this pull. The inbox's queues are `pullInbox`'s.
821
+ */
822
+ async function pull(session, options) {
823
+ return (await lease(session, {
824
+ ...options,
825
+ queues: (queue) => !queue.startsWith("@inbox:"),
826
+ visible: (event, queue) => readable(options.access, event, queue)
827
+ })).map(({ id, queue, thread, lease, attempt, event }) => ({
828
+ id,
829
+ watcher: queue,
830
+ thread,
831
+ lease,
832
+ attempt,
833
+ event
834
+ }));
835
+ }
836
+ /**
837
+ * Hands out the platform events the registrant pushed for sync key `key`: each platform thread's
838
+ * oldest, if it's free, while the registrant may still sync somewhere.
839
+ */
840
+ function pullInbox(session, key, options) {
841
+ const queue = inboxQueue(key);
842
+ return lease(session, {
843
+ ...options,
844
+ queues: (name) => name === queue,
845
+ visible: () => options.access.anywhere("sync")
846
+ });
847
+ }
848
+ /**
849
+ * Leases what's free in the queues `queues` picks: each one's oldest item, which `visible` says
850
+ * its registrant may still have. What the pull changes goes in one append, and one read after
851
+ * tells which leases are this pull's: two pulls at once derive the same lease ID for a try, and
852
+ * only one lands.
853
+ */
854
+ async function lease(session, options) {
855
+ const { state } = await session.state(queueReducer);
856
+ const events = state.unreachable ? [{
857
+ type: "watch.reachable",
858
+ payload: { by: "pull" }
859
+ }] : [];
860
+ const offered = [];
861
+ for (const ids of Object.values(state.queues)) {
862
+ if (offered.length >= options.limit) break;
863
+ const head = state.pending[ids[0]];
864
+ if (!options.queues(head.queue) || !free(head, options.now)) continue;
865
+ let attempts = head.attempts;
866
+ if (head.lease) {
867
+ events.push({
868
+ type: "watch.expired",
869
+ payload: {
870
+ matched: head.id,
871
+ lease: head.lease.id
872
+ }
873
+ });
874
+ attempts += 1;
875
+ if (attempts >= 5) {
876
+ events.push(failure(head, `its lease ran out ${attempts} times`));
877
+ continue;
878
+ }
879
+ }
880
+ if (!options.visible(head.event, head.queue)) {
881
+ events.push({
882
+ type: "watch.skipped",
883
+ payload: {
884
+ matched: head.id,
885
+ reason: "its registrant can't have it anymore"
886
+ }
887
+ });
888
+ continue;
889
+ }
890
+ const lease = randomBytes(16).toString("hex");
891
+ events.push({
892
+ type: "watch.leased",
893
+ id: await idempotentId("board-lease", head.id, String(attempts)),
894
+ payload: {
895
+ matched: head.id,
896
+ lease,
897
+ until: options.now + LEASE_MS
898
+ }
899
+ });
900
+ offered.push({
901
+ head,
902
+ lease,
903
+ attempt: attempts
904
+ });
905
+ }
906
+ if (events.length === 0) return [];
907
+ await session.append(...events).catch(async (error) => {
908
+ if (!taken(error)) throw error;
909
+ for (const event of events) await session.append(event).catch((one) => {
910
+ if (!taken(one)) throw one;
911
+ });
912
+ });
913
+ if (offered.length === 0) return [];
914
+ const { state: after } = await session.state(queueReducer);
915
+ const handed = offered.filter(({ head, lease }) => after.pending[head.id]?.lease?.id === lease);
916
+ if (handed.length > 0 && options.timers) await arm(session, after, options.now + LEASE_MS);
917
+ return handed.map(({ head, lease, attempt }) => ({
918
+ id: head.id,
919
+ queue: head.queue,
920
+ thread: head.thread,
921
+ lease,
922
+ attempt,
923
+ event: head.event
924
+ }));
925
+ }
926
+ /** Records how a pulled match went: done, skipped, or an error that waits, then tries again. */
927
+ async function ack(session, id, body, options) {
928
+ const { state } = await session.state(queueReducer);
929
+ const found = state.pending[id];
930
+ if (!found) return "gone";
931
+ if (found.lease?.id !== body.lease) return "stale";
932
+ if (body.skip) {
933
+ await session.append({
934
+ type: "watch.skipped",
935
+ payload: {
936
+ matched: id,
937
+ reason: "its board skipped it"
938
+ }
939
+ });
940
+ return "acked";
941
+ }
942
+ if (body.error === void 0) {
943
+ await session.append({
944
+ type: "watch.acked",
945
+ payload: {
946
+ matched: id,
947
+ lease: body.lease
948
+ }
949
+ });
950
+ return "acked";
951
+ }
952
+ const attempts = found.attempts + 1;
953
+ if (attempts >= 5) {
954
+ await session.append({
955
+ type: "watch.acked",
956
+ payload: {
957
+ matched: id,
958
+ lease: body.lease,
959
+ error: body.error
960
+ }
961
+ }, failure(found, body.error));
962
+ return "acked";
963
+ }
964
+ const retryAt = options.now + retryDelay(attempts);
965
+ await session.append({
966
+ type: "watch.acked",
967
+ payload: {
968
+ matched: id,
969
+ lease: body.lease,
970
+ error: body.error,
971
+ retryAt
972
+ }
973
+ });
974
+ if (options.timers) await session.schedule(`retry:${id}:${attempts}`, { at: new Date(retryAt) }, {
975
+ type: "watch.due",
976
+ payload: {
977
+ matched: id,
978
+ retry: true
979
+ }
980
+ });
981
+ return "acked";
982
+ }
983
+ /**
984
+ * Sets a sweep for `at`, unless one is set already. The sweep runs on the minute after it, so the
985
+ * leases of a minute share one timer. Its timer goes first: a sweep recorded without one would
986
+ * never run, and a timer set twice under one name lands once.
987
+ */
988
+ async function arm(session, state, at) {
989
+ const sweep = sweepFor(at);
990
+ if (state.sweeps.includes(sweep)) return;
991
+ await session.schedule(`sweep:${sweep}`, { at: new Date(sweep) }, {
992
+ type: "watch.due",
993
+ payload: { sweep }
994
+ });
995
+ await session.append({
996
+ type: "watch.armed",
997
+ id: await idempotentId("board-sweep", String(sweep)),
998
+ payload: { at: sweep }
999
+ });
1000
+ }
1001
+ /**
1002
+ * A timer came due: an error's wait is over, or a sweep. Returns the queue after it, for its
1003
+ * consumer to tell whether the registrant has something to pull.
1004
+ */
1005
+ async function due(session, payload) {
1006
+ const { state } = await session.state(queueReducer);
1007
+ const found = payload.matched === void 0 ? void 0 : state.pending[payload.matched];
1008
+ if (!payload.retry || !found || found.retryAt === null) return state;
1009
+ await session.append({
1010
+ type: "watch.retry",
1011
+ payload: { matched: found.id }
1012
+ });
1013
+ return (await session.state(queueReducer)).state;
1014
+ }
1015
+ /** Gives up on a match: `watch.failed`, said in the logs too. */
1016
+ function failure(found, error) {
1017
+ console.error(`[board] a registrant's ${found.queue} gave up on an item after 5 tries:`, error);
1018
+ return {
1019
+ type: "watch.failed",
1020
+ payload: {
1021
+ matched: found.id,
1022
+ thread: found.thread,
1023
+ error: describe$1(error)
1024
+ }
1025
+ };
1026
+ }
1027
+ //#endregion
1028
+ //#region src/server/watch.ts
1029
+ const ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
1030
+ /** The events a tail shows, receipts included for `--all`: each concerns a message, and carries its thread for the lane. */
1031
+ const WATCHED = [
1032
+ "message.posted",
1033
+ "message.edited",
1034
+ "message.deleted",
1035
+ "reaction.added",
1036
+ "reaction.removed",
1037
+ "labels.changed",
1038
+ "message.sync_failed",
1039
+ "message.synced",
1040
+ "message.files_synced",
1041
+ "thread.synced"
1042
+ ];
1043
+ /** Receipts: only a `--all` tail shows them. */
1044
+ const RECEIPTS = /* @__PURE__ */ new Set(["message.synced", "message.files_synced"]);
1045
+ /**
1046
+ * What a registrant's sync gets, in every channel where it may sync: what it delivers to platforms,
1047
+ * and a thread's new targets. A tail never shows `thread.synced`, so no watcher gets it.
1048
+ */
1049
+ const SYNCED = /* @__PURE__ */ new Set([
1050
+ "message.posted",
1051
+ "message.edited",
1052
+ "message.deleted",
1053
+ "reaction.added",
1054
+ "reaction.removed",
1055
+ "labels.changed",
1056
+ "thread.synced"
1057
+ ]);
1058
+ /** Whether a match waits in a registrant's queue for it to pull, as opposed to a config watcher's, which runs here. */
1059
+ const queued = (payload) => payload.watcher !== void 0 || payload.sync === true || payload.inbox !== void 0;
1060
+ /**
1061
+ * A2 dead-letters an event after 10 failures, and a dead letter stops its
1062
+ * lane, which is the thread's: the watcher would never run on that thread
1063
+ * again. So `run` gets a few tries, then the failure is recorded and the
1064
+ * thread moves on.
1065
+ */
1066
+ const RUN_ATTEMPTS = 5;
1067
+ /**
1068
+ * A run that's killed (a timeout, out of memory) never throws. A2 counts it
1069
+ * toward the 10 failures when the next attempt takes over, and dead-letters
1070
+ * the event at that takeover instead of running it: the 10th attempt is the
1071
+ * last one a watcher sees. Throws give up at RUN_ATTEMPTS, so getting this far
1072
+ * means runs keep dying, and the 10th records the failure without running.
1073
+ */
1074
+ const KILLED_ATTEMPTS = 10;
1075
+ /** Tries at a wake-up before it's left to the next match or sweep, well short of A2's dead letter. */
1076
+ const WAKE_TRIES = 3;
1077
+ const describe = (error) => ((error instanceof Error ? error.message : String(error)) || "unknown error").slice(0, 500);
1078
+ /**
1079
+ * One match, on its thread's lane: `run`, retried by A2 while it fails, then
1080
+ * given up on (recorded as `watch.failed`) so the thread's next event runs.
1081
+ */
1082
+ async function runMatch(input) {
1083
+ const { watcher, match, attempt } = input;
1084
+ const event = match.payload.event;
1085
+ const giveUp = async (error) => {
1086
+ console.error(`[board] watcher ${watcher.id} gave up on event #${event.cursor} after ${attempt} tries:`, error);
1087
+ await input.record({
1088
+ matched: match.id,
1089
+ thread: match.payload.thread,
1090
+ error
1091
+ });
1092
+ };
1093
+ const early = !afterRetry(attempt);
1094
+ if (early && attempt >= KILLED_ATTEMPTS) return giveUp(`its run was cut short ${attempt - 1} times (a timeout, or out of memory)`);
1095
+ try {
1096
+ await watcher.run(event);
1097
+ } catch (error) {
1098
+ if (!early || attempt < RUN_ATTEMPTS) throw error;
1099
+ await giveUp(describe(error));
1100
+ }
1101
+ }
1102
+ /** A watcher writes as `board:watch:<id>`, and its own writes never wake it. */
1103
+ const actorOf = (id) => `board:watch:${id}`;
1104
+ /** `watch:@<principal>`: everything one registrant's watchers matched, queued for it to pull. */
1105
+ const registrantSession = (owner) => watchSession(`@${owner}`);
1106
+ /**
1107
+ * Watchers: a board-contract consumer matches each event against every
1108
+ * watcher's filters, the way a tail does, and appends what matched to that
1109
+ * watcher's session, where `run` happens on the thread's lane.
1110
+ */
1111
+ function createWatchers(options) {
1112
+ const watchers = Object.entries(options.watch).map(([id, watcher]) => {
1113
+ if (!ID.test(id)) throw new Error(`watch: ids use lowercase letters, numbers, - and _, got: ${id}`);
1114
+ return {
1115
+ id,
1116
+ ...compileWatcher(id, watcher)
1117
+ };
1118
+ });
1119
+ const byId = new Map(watchers.map((watcher) => [watcher.id, watcher]));
1120
+ let reads = null;
1121
+ /** Runs a watcher on a board event it matched, one thread at a time. */
1122
+ const runner = consumer({
1123
+ contract: watchContract,
1124
+ name: "board-watch",
1125
+ types: ["watch.matched"],
1126
+ lane: ({ event }) => `thread:${event.payload.thread}`
1127
+ }).run(async (ctx) => {
1128
+ const watcher = byId.get(ctx.session.id.slice(6));
1129
+ if (!watcher) return;
1130
+ await runMatch({
1131
+ watcher: {
1132
+ id: watcher.id,
1133
+ run: (event) => options.run(watcher.id, event, ctx.event.id, ctx.attempt)
1134
+ },
1135
+ match: ctx.event,
1136
+ attempt: ctx.attempt,
1137
+ record: (payload) => ctx.session.append(`failed:${ctx.event.id}`, {
1138
+ type: "watch.failed",
1139
+ payload
1140
+ })
1141
+ });
1142
+ });
1143
+ /**
1144
+ * Wakes a registrant's board when it has something to pull: a new match, a wait after an error
1145
+ * that's over, or a sweep that finds a lease ran out. A registrant's wake-ups go one at a time,
1146
+ * and only when there's something to pull: its board pulls until nothing's left, so a burst of
1147
+ * matches wakes it once. One that doesn't get there waits a minute for the next, doubling to an
1148
+ * hour while they keep failing, until its board answers one, pulls, or registers.
1149
+ */
1150
+ const waker = consumer({
1151
+ contract: watchContract,
1152
+ name: "board-wake",
1153
+ types: ["watch.matched", "watch.due"],
1154
+ lane: ({ event }) => "thread" in event.payload && (!queued(event.payload) || event.payload.inbox !== void 0) ? null : "wake"
1155
+ }).run(async (ctx) => {
1156
+ const key = ctx.session.id.slice(6);
1157
+ const registered = options.registered;
1158
+ if (!key.startsWith("@") || !registered) return;
1159
+ if (ctx.event.type === "watch.matched" && (!queued(ctx.event.payload) || ctx.event.payload.inbox !== void 0)) return;
1160
+ const queue = server.session(ctx.session.id);
1161
+ try {
1162
+ const state = ctx.event.type === "watch.due" ? await due(queue, ctx.event.payload) : (await queue.state(queueReducer)).state;
1163
+ if (!hasWork(state, Date.now())) return;
1164
+ const registration = (await registered.state()).byOwner[key.slice(1)];
1165
+ if (!registration || registration.url === null) return;
1166
+ const { url } = registration;
1167
+ const now = Date.now();
1168
+ if (state.unreachable && state.unreachable.until > now) return;
1169
+ const woken = await registered.wake({
1170
+ ...registration,
1171
+ url
1172
+ });
1173
+ if (woken.reached) {
1174
+ if (state.unreachable) await queue.append({
1175
+ type: "watch.reachable",
1176
+ payload: { by: "wake" }
1177
+ });
1178
+ return;
1179
+ }
1180
+ const failures = (state.unreachable?.failures ?? 0) + 1;
1181
+ const until = now + wakeBackoff(failures);
1182
+ if (failures === 1) console.error(`[board] couldn't wake ${url}: ${woken.why}`);
1183
+ if (failures === 2) console.error(`[board] couldn't wake ${url} again (${woken.why}): trying less often, up to once an hour, until it answers, pulls, or registers`);
1184
+ if (options.scheduler) await arm(queue, state, until);
1185
+ await queue.append({
1186
+ type: "watch.unreachable",
1187
+ payload: {
1188
+ failures,
1189
+ until,
1190
+ why: woken.why.slice(0, 200) || "unknown error"
1191
+ }
1192
+ });
1193
+ } catch (error) {
1194
+ if (ctx.attempt < WAKE_TRIES) throw error;
1195
+ console.error(`[board] couldn't wake the board of ${key.slice(1)}:`, error.message);
1196
+ }
1197
+ });
1198
+ const server = createServer({
1199
+ contract: watchContract,
1200
+ ...options.store ? { store: options.store } : {},
1201
+ ...options.scheduler ? { scheduler: options.scheduler } : {},
1202
+ consumers: [runner, waker]
1203
+ });
1204
+ const configured = watchers.map((watcher) => ({
1205
+ ...watcher,
1206
+ actor: actorOf(watcher.id)
1207
+ }));
1208
+ const compiled = /* @__PURE__ */ new Map();
1209
+ function compiledFor(registration) {
1210
+ const version = `${registration.hash}\u001f${JSON.stringify(registration.principal)}`;
1211
+ const hit = compiled.get(registration.owner);
1212
+ if (hit?.version === version) return hit.candidates;
1213
+ const candidates = [];
1214
+ for (const [id, spec] of Object.entries(registration.watch)) try {
1215
+ candidates.push({
1216
+ ...compileWatcher(id, spec),
1217
+ id,
1218
+ actor: `${registration.owner}+${id.toLowerCase()}`,
1219
+ registration
1220
+ });
1221
+ } catch (error) {
1222
+ console.error(`[board] skipping a watcher ${registration.owner} registered:`, error.message);
1223
+ }
1224
+ compiled.set(registration.owner, {
1225
+ version,
1226
+ candidates
1227
+ });
1228
+ return candidates;
1229
+ }
1230
+ async function registrations() {
1231
+ return options.registered ? Object.values((await options.registered.state()).byOwner) : [];
1232
+ }
1233
+ /** Matches one board event; the message before or after it must match, as in a tail. */
1234
+ async function dispatch(ctx) {
1235
+ const event = ctx.event;
1236
+ const through = event.index - 1;
1237
+ const [messages, { state: actors }] = await Promise.all([messagesBefore(event, ctx.signal), ctx.session.state(actorsReducer, { through })]);
1238
+ const { id, before, after } = foldEvent(messages, actors, event);
1239
+ const by = event.payload.by?.actor;
1240
+ const message = after ?? before;
1241
+ const thread = event.payload.thread;
1242
+ let view$1;
1243
+ /** Each session's matches, appended together: one append per watcher in config, one per registrant. */
1244
+ const appends = /* @__PURE__ */ new Map();
1245
+ const add = (session, matched) => (appends.get(session) ?? appends.set(session, []).get(session)).push(matched);
1246
+ const registered = await registrations();
1247
+ const targets = [...configured, ...registered.flatMap(compiledFor)];
1248
+ const registrants = /* @__PURE__ */ new Map();
1249
+ const accessOf = (registration) => {
1250
+ let found = registrants.get(registration.owner);
1251
+ if (!found) registrants.set(registration.owner, found = options.registered.accessNow(registration.principal));
1252
+ return found;
1253
+ };
1254
+ for (const watcher of targets) {
1255
+ if (event.type === "thread.synced") break;
1256
+ if (by === watcher.actor) continue;
1257
+ if (RECEIPTS.has(event.type) && !watcher.all) continue;
1258
+ const { registration } = watcher;
1259
+ let access = FULL_ACCESS;
1260
+ if (registration) {
1261
+ const now = await accessOf(registration);
1262
+ if (!now || (id === null ? !now.readsAll : !message || !now.readable(message.channel))) continue;
1263
+ access = now;
1264
+ }
1265
+ const match = compileFilter(watcher.filters, {
1266
+ me: null,
1267
+ actors,
1268
+ messages,
1269
+ access
1270
+ });
1271
+ if (watcher.enters ? !entered(match, before, after, messages) : !(watcher.criteria ? match(before) || match(after) : id !== null)) continue;
1272
+ const key = registration ? `@${registration.owner}/${watcher.id}` : watcher.id;
1273
+ const shown = registration ? eventView(event, after, actors, await reads.reader(access, after ? [after] : [])) : view$1 ??= eventView(event, after, actors, OWN_CODE);
1274
+ add(registration ? registrantSession(registration.owner) : watchSession(watcher.id), {
1275
+ type: "watch.matched",
1276
+ id: await idempotentId("board-watch", key, event.id),
1277
+ payload: {
1278
+ thread,
1279
+ event: shown,
1280
+ ...registration ? { watcher: watcher.id } : {}
1281
+ }
1282
+ });
1283
+ }
1284
+ const syncing = SYNCED.has(event.type) ? registered.filter((registration) => registration.sync.length > 0) : [];
1285
+ if (syncing.length > 0) {
1286
+ const about = event.type === "thread.synced" ? await rootOf(event.payload.thread, event.index, ctx.signal) : message;
1287
+ for (const registration of syncing) {
1288
+ const now = await accessOf(registration);
1289
+ if (!now || !about || !now.can("sync", about.channel)) continue;
1290
+ const reader = await reads.reader(now, [about, ...before ? [before] : []]);
1291
+ const shown = {
1292
+ ...eventView(event, about, actors, reader),
1293
+ ...event.type === "labels.changed" && before ? { before: view(before, actors, reader) } : {}
1294
+ };
1295
+ add(registrantSession(registration.owner), {
1296
+ type: "watch.matched",
1297
+ id: await idempotentId("board-watch", `@${registration.owner}/@sync`, event.id),
1298
+ payload: {
1299
+ thread,
1300
+ event: shown,
1301
+ sync: true
1302
+ }
1303
+ });
1304
+ }
1305
+ }
1306
+ await Promise.all([...appends].map(async ([session, events]) => {
1307
+ try {
1308
+ await server.session(session).append(...events);
1309
+ } catch (error) {
1310
+ if (!(error instanceof A2Error && error.code === "PARTIAL_DUPLICATE_BATCH")) throw error;
1311
+ for (const matched of events) await server.session(session).append(matched);
1312
+ }
1313
+ }));
1314
+ }
1315
+ /** A thread's root, as the board has it once the tables reach `index`: the event that synced it comes after it. */
1316
+ async function rootOf(thread, index, signal) {
1317
+ if (!reads) throw new Error("watchers used before the board bound them");
1318
+ await reads.ensure(index - 1, signal);
1319
+ return await reads.tables.byEventId(thread) ?? void 0;
1320
+ }
1321
+ /** The message `event` concerns, and its thread's root, as they were just before it. */
1322
+ async function messagesBefore(event, signal) {
1323
+ if (!reads) throw new Error("watchers used before the board bound them");
1324
+ return loadBefore(reads, event, signal);
1325
+ }
1326
+ /** Didn't match before and does now; a new reply only counts if its thread wasn't already in view. */
1327
+ function entered(match, before, after, messages) {
1328
+ if (match(before) || !match(after)) return false;
1329
+ return !match(before === void 0 && after.re !== null ? messages.byId[after.re] : void 0);
1330
+ }
1331
+ return {
1332
+ server,
1333
+ boardConsumer: consumer({
1334
+ contract: boardContract,
1335
+ name: "board-watchers",
1336
+ types: WATCHED,
1337
+ lane: ({ event }) => `thread:${event.payload.thread}`
1338
+ }).run(dispatch),
1339
+ bind: (binding) => {
1340
+ reads = binding.reads;
1341
+ }
1342
+ };
1343
+ }
1344
+ //#endregion
1345
+ //#region src/server/registry.ts
1346
+ /** A principal as a registration keeps it: enough to work out its access again at each match. */
1347
+ const storedPrincipal = (principal) => ({
1348
+ id: principal.id,
1349
+ kind: principal.kind,
1350
+ handle: principal.handle,
1351
+ ...principal.facts ? { facts: principal.facts } : {},
1352
+ ...principal.scope !== void 0 ? { scope: principal.scope } : {}
1353
+ });
1354
+ /** What a registration shows: the principal's token facts stay on the board. */
1355
+ const viewOf = ({ principal: _principal, ...view }) => view;
1356
+ /**
1357
+ * The same on both ends: a board on `http()` hashes its watchers, and whether its sync subscribes,
1358
+ * and the board it uses hashes what it holds.
1359
+ */
1360
+ function watchersHash(watch, sync = []) {
1361
+ return createHash("sha256").update(canonicalWatchers(watch, sync)).digest("hex").slice(0, 32);
1362
+ }
1363
+ function foldRegistry(state, event) {
1364
+ if (event.type === "watchers.registered") {
1365
+ const { owner, principal, url, watch, sync, hash } = event.payload;
1366
+ const copied = Object.fromEntries(Object.entries(watch).map(([id, spec]) => [id, {
1367
+ tail: spec.tail,
1368
+ when: spec.when
1369
+ }]));
1370
+ state.byOwner[owner] = {
1371
+ owner,
1372
+ principal: structuredClone(principal),
1373
+ url,
1374
+ watch: copied,
1375
+ sync: (sync ?? []).map((entry) => ({ ...entry })),
1376
+ hash
1377
+ };
1378
+ }
1379
+ return state;
1380
+ }
1381
+ const registryReducer = reducer({
1382
+ contract: registryContract,
1383
+ name: "board.registry.v1",
1384
+ initialState: { byOwner: {} }
1385
+ }).fold(foldRegistry);
1386
+ /** Watchers registered with this board by boards on `http()`. */
1387
+ function createRegistry(options) {
1388
+ const server = createServer({
1389
+ contract: registryContract,
1390
+ ...options.store ? { store: options.store } : {},
1391
+ ...options.scheduler ? { scheduler: options.scheduler } : {}
1392
+ });
1393
+ const session = server.session(REGISTRY_SESSION);
1394
+ const state = async () => (await session.state(registryReducer)).state;
1395
+ return {
1396
+ server,
1397
+ state,
1398
+ /**
1399
+ * Replaces `principal`'s registration. One that changes nothing writes nothing, so asking
1400
+ * again is free, unless the principal's token facts changed: those decide what it may read.
1401
+ */
1402
+ async register(principal, input) {
1403
+ const owner = principal.id;
1404
+ const hash = watchersHash(input.watch, input.sync);
1405
+ const current = (await state()).byOwner[owner];
1406
+ if (current && current.url === input.url && current.hash === hash && JSON.stringify(current.principal) === JSON.stringify(principal)) return viewOf(current);
1407
+ await session.append({
1408
+ type: "watchers.registered",
1409
+ payload: {
1410
+ owner,
1411
+ principal,
1412
+ url: input.url,
1413
+ watch: input.watch,
1414
+ ...input.sync.length > 0 ? { sync: input.sync } : {},
1415
+ hash
1416
+ }
1417
+ });
1418
+ return {
1419
+ owner,
1420
+ url: input.url,
1421
+ watch: input.watch,
1422
+ sync: input.sync,
1423
+ hash
1424
+ };
1425
+ },
1426
+ async list() {
1427
+ return Object.values((await state()).byOwner).sort((a, b) => a.owner.localeCompare(b.owner)).map(viewOf);
1428
+ }
1429
+ };
1430
+ }
1431
+ //#endregion
1432
+ //#region src/server/transport.ts
1433
+ /** Addresses a wake-up never reaches: this machine, private networks, link-local, and the rest that aren't the internet. */
1434
+ const NOT_PUBLIC = new BlockList();
1435
+ for (const [network, prefix] of [
1436
+ ["0.0.0.0", 8],
1437
+ ["10.0.0.0", 8],
1438
+ ["100.64.0.0", 10],
1439
+ ["127.0.0.0", 8],
1440
+ ["169.254.0.0", 16],
1441
+ ["172.16.0.0", 12],
1442
+ ["192.0.0.0", 24],
1443
+ ["192.168.0.0", 16],
1444
+ ["198.18.0.0", 15],
1445
+ ["224.0.0.0", 4],
1446
+ ["240.0.0.0", 4]
1447
+ ]) NOT_PUBLIC.addSubnet(network, prefix, "ipv4");
1448
+ for (const [network, prefix] of [
1449
+ ["::", 127],
1450
+ ["64:ff9b::", 96],
1451
+ ["64:ff9b:1::", 48],
1452
+ ["100::", 64],
1453
+ ["2001::", 32],
1454
+ ["2002::", 16],
1455
+ ["fc00::", 7],
1456
+ ["fe80::", 10],
1457
+ ["fec0::", 10],
1458
+ ["ff00::", 8]
1459
+ ]) NOT_PUBLIC.addSubnet(network, prefix, "ipv6");
1460
+ /** Whether `address` is one a wake-up may connect to. */
1461
+ const isPublicAddress = (address) => {
1462
+ const family = isIP(address);
1463
+ return family !== 0 && !NOT_PUBLIC.check(address, family === 4 ? "ipv4" : "ipv6");
1464
+ };
1465
+ /** Resolves as the system does, and refuses a name that resolves to any address that isn't public. */
1466
+ const publicLookup = (hostname, options, callback) => {
1467
+ lookup(hostname, {
1468
+ ...options,
1469
+ all: true
1470
+ }, (error, found) => {
1471
+ if (error) return callback(error, "", 0);
1472
+ const addresses = found;
1473
+ const bad = addresses.find((entry) => !isPublicAddress(entry.address));
1474
+ if (bad || addresses.length === 0) return callback(Object.assign(/* @__PURE__ */ new Error(`${hostname} resolves to ${bad?.address ?? "nothing"}, which isn't a public address`), { code: "ENOTPUBLIC" }), "", 0);
1475
+ if (options.all) return callback(null, addresses);
1476
+ callback(null, addresses[0].address, addresses[0].family);
1477
+ });
1478
+ };
1479
+ /** A response body is read only as far as an error message needs. */
1480
+ const ERROR_BYTES = 4096;
1481
+ /** Statuses a `Response` has no body with. */
1482
+ const NULL_BODY = /* @__PURE__ */ new Set([
1483
+ 204,
1484
+ 205,
1485
+ 304
1486
+ ]);
1487
+ /**
1488
+ * A wake-up's answer as a `Response`, whatever status the other end chose: one with no body has
1489
+ * none, and one a `Response` can't have (outside 200 to 599) is a 502.
1490
+ */
1491
+ function responseOf(status, body) {
1492
+ if (status === void 0 || !Number.isInteger(status) || status < 200 || status > 599) return new Response(null, { status: 502 });
1493
+ return new Response(NULL_BODY.has(status) ? null : body, { status });
1494
+ }
1495
+ /** POSTs to a public `https` URL: the default `Post`. */
1496
+ async function publicPost(url, init) {
1497
+ const target = new URL(url);
1498
+ if (target.protocol !== "https:") throw new Error(`wake-ups go over https, not to ${url}`);
1499
+ const host = target.hostname.replace(/^\[|\]$/g, "");
1500
+ if (isIP(host) && !isPublicAddress(host)) throw new Error(`${host} isn't a public address`);
1501
+ return new Promise((resolve, reject) => {
1502
+ const sent = request(target, {
1503
+ method: "POST",
1504
+ headers: {
1505
+ ...init.headers,
1506
+ "content-length": String(Buffer.byteLength(init.body))
1507
+ },
1508
+ lookup: publicLookup,
1509
+ signal: init.signal
1510
+ }, (response) => {
1511
+ const chunks = [];
1512
+ let size = 0;
1513
+ response.on("data", (chunk) => {
1514
+ if (size < ERROR_BYTES) chunks.push(chunk);
1515
+ size += chunk.length;
1516
+ });
1517
+ response.on("end", () => {
1518
+ try {
1519
+ resolve(responseOf(response.statusCode, Buffer.concat(chunks).subarray(0, ERROR_BYTES)));
1520
+ } catch (error) {
1521
+ reject(error);
1522
+ }
1523
+ });
1524
+ response.on("error", reject);
1525
+ });
1526
+ sent.on("error", reject);
1527
+ sent.end(init.body);
1528
+ });
1529
+ }
1530
+ //#endregion
1531
+ //#region src/server/a2.ts
1532
+ /** Sync's own lookups and receipts, from board() in this deployment. */
1533
+ const SYNC_ACTOR = "board:sync";
1534
+ /** The board's own code, through its handle: `team.post(…)`. With `as(profile)`, `board:client:<profile>`. */
1535
+ const CLIENT_ACTOR = "board:client";
1536
+ /** A cursor or message ref in a request, as this board's log index: decimals, from 0. */
1537
+ function cursorAt(value) {
1538
+ const index = indexOf(value);
1539
+ if (index === null) throw badRequest(`${value} isn't a cursor or a message ID on this board`, "invalid_cursor");
1540
+ return index;
1541
+ }
1542
+ /** The message a ref in a request names, as its log index, or a 404 for one that names nothing here. */
1543
+ function messageAt(value) {
1544
+ const index = indexOf(value);
1545
+ if (index === null || index === 0) throw notFound(`message not found: #${value}`, "message_not_found");
1546
+ return index;
1547
+ }
1548
+ /** A schedule's ID, `12` or as the CLI shows it, `s12`, as its index in session `schedules`. */
1549
+ function scheduleAt(value) {
1550
+ const bare = value.replace(/^s/, "");
1551
+ const index = indexOf(bare);
1552
+ if (index === null || index === 0) throw notFound(`schedule not found: s${bare}`, "schedule_not_found");
1553
+ return index;
1554
+ }
1555
+ new AbortController().signal;
1556
+ /** A wake-up only has to arrive: the board it wakes runs what it pulls before it answers. */
1557
+ const WAKE_TIMEOUT_MS = 5e3;
1558
+ /**
1559
+ * `idempotentId`'s shape, a UUIDv8: the IDs the board derives for its own writes (schedule
1560
+ * sends, watchers, sync). They're guessable, so an API caller can't post with one and take it first.
1561
+ */
1562
+ const DERIVED_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
1563
+ /** A request to the board, as board() verified it, as opposed to a write the board's own code makes in-process. */
1564
+ const fromRequest = (caller) => "identity" in caller && (caller.origin.via?.kind ?? "api") === "api";
1565
+ /** Sync in board() in this deployment, as opposed to a sync elsewhere that uses this board. */
1566
+ const ownSync = (caller) => "self" in caller && caller.origin.via?.kind === "engine";
1567
+ /** board()'s own code, as the backend's discovery is asked for it. */
1568
+ const OWN = {
1569
+ self: null,
1570
+ origin: { via: {
1571
+ kind: "client",
1572
+ name: null
1573
+ } }
1574
+ };
1575
+ /**
1576
+ * Who a caller writes as. This deployment's own code writes as `board:client` from a handle,
1577
+ * `board:client:<profile>` from `as(profile)`, as `board:watch:<id>` from a watcher, which its
1578
+ * own writes never wake, and as `board:middleware` and `board:sync` from board()'s middleware
1579
+ * and sync.
1580
+ */
1581
+ function selfIdentity(caller) {
1582
+ const { self, origin } = caller;
1583
+ if (origin.via?.kind === "watcher") return deploymentIdentity(`board:watch:${self}`, self ?? "watch");
1584
+ if (origin.via?.kind === "middleware") return deploymentIdentity(MIDDLEWARE_ACTOR, "middleware");
1585
+ if (origin.via?.kind === "engine") return deploymentIdentity(SYNC_ACTOR, "sync");
1586
+ if (self === null) return deploymentIdentity(CLIENT_ACTOR, "client");
1587
+ if (!ID.test(self)) throw badRequest(`as: profiles use lowercase letters, numbers, - and _, got: ${self}`);
1588
+ return deploymentIdentity(`${CLIENT_ACTOR}:${self}`, self);
1589
+ }
1590
+ /** A schedule as its creator sees it where it can't read: its text as a blind writer's message's is. */
1591
+ const blindSchedule = (schedule, access, own) => access.readable(schedule.channel) ? schedule : {
1592
+ ...schedule,
1593
+ text: blindText(schedule.text, own)
1594
+ };
1595
+ /**
1596
+ * A platform user, writing through sync: full access, and each write still proved against the
1597
+ * channel it lands in. Their avatar is an https URL or none. `records`: the write keeps the
1598
+ * board's record of who they are, which only a sync that may sync everywhere does.
1599
+ */
1600
+ function externalIdentity(author, records) {
1601
+ return {
1602
+ principal: {
1603
+ id: author.id,
1604
+ kind: "external",
1605
+ handle: author.handle,
1606
+ access: FULL_ACCESS,
1607
+ expiresAt: null
1608
+ },
1609
+ author: {
1610
+ actor: author.id,
1611
+ principal: author.id,
1612
+ profile: null,
1613
+ session: null
1614
+ },
1615
+ actor: {
1616
+ id: author.id,
1617
+ kind: "external",
1618
+ handle: author.handle,
1619
+ name: author.name,
1620
+ avatar: platformAvatar(author.avatar)
1621
+ },
1622
+ ...records ? {} : { records: false }
1623
+ };
1624
+ }
1625
+ /**
1626
+ * A one-time code for linking an account: `vb-` and eight letters and digits that
1627
+ * can't be misread. Only its hash is kept.
1628
+ */
1629
+ const CODE_LETTERS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
1630
+ const CODE_TTL_MS = 6e5;
1631
+ const newCode = () => {
1632
+ const letters = [...randomBytes(8)].map((byte) => CODE_LETTERS[byte % 31]).join("");
1633
+ return `vb-${letters.slice(0, 4)}-${letters.slice(4)}`;
1634
+ };
1635
+ const hashCode = (code) => createHash("sha256").update(code.toUpperCase()).digest("base64url");
1636
+ /** The board events sync delivers: what a platform shows, and a thread's new targets. */
1637
+ const DELIVERED = [
1638
+ "thread.synced",
1639
+ "message.posted",
1640
+ "message.edited",
1641
+ "message.deleted",
1642
+ "reaction.added",
1643
+ "reaction.removed",
1644
+ "labels.changed"
1645
+ ];
1646
+ /** The inboxes that have had an event, so a sync key's inbox lists every channel's. */
1647
+ const inboxesReducer = reducer({
1648
+ contract: inboxContract,
1649
+ name: "board.inboxes.v1",
1650
+ initialState: {}
1651
+ }).fold((state, event) => {
1652
+ if (event.type === "inbox.opened") state[event.payload.channel] = true;
1653
+ return state;
1654
+ });
1655
+ /** An append that holds an event ID already in the log. */
1656
+ const duplicate = (error) => error instanceof A2Error && error.code === "PARTIAL_DUPLICATE_BATCH";
1657
+ /** Where a board given no store keeps it, in the working directory, in every environment. */
1658
+ const DEFAULT_STORE_PATH = ".a2/dev.db";
1659
+ /** Why a file couldn't be opened, in a few words: a read-only filesystem, a serverless function's say. */
1660
+ function unwritable(error) {
1661
+ const { code, message } = error;
1662
+ if (code === "EROFS" || code === "EACCES" || code === "EPERM" || /unable to open database|readonly database/i.test(String(message))) return "this filesystem isn't writable here";
1663
+ return String(message ?? error).split("\n")[0];
1664
+ }
1665
+ /**
1666
+ * The store of a board given none: SQLite at `.a2/dev.db`, opened on its first use (so nothing loads
1667
+ * SQLite for a board that's given a store). Where it can't open, each request says why and what to
1668
+ * pass: never a store in memory, which would lose the board without a word.
1669
+ */
1670
+ async function defaultStore() {
1671
+ const { sqlite } = await import("./backend-a2/store-sqlite.mjs");
1672
+ try {
1673
+ return sqlite({ path: DEFAULT_STORE_PATH });
1674
+ } catch (error) {
1675
+ throw new HttpError(500, "no_store", `can't open SQLite at ${DEFAULT_STORE_PATH}: ${unwritable(error)}, so pass a store to a2()`);
1676
+ }
1677
+ }
1678
+ /** Everything behind a board on `a2()`, built once board() binds it. Nothing in it connects until it's used. */
1679
+ function build(options, binding) {
1680
+ const blob = options.blob ?? null;
1681
+ const { store } = options;
1682
+ const log = store.log;
1683
+ const registry = createRegistry({
1684
+ store: log,
1685
+ scheduler: options.scheduler
1686
+ });
1687
+ /**
1688
+ * Who a caller is, and what it may do now: one board() verified, as it said, its access
1689
+ * included; this deployment's own code everything.
1690
+ */
1691
+ const identityOf = async (caller) => "identity" in caller ? caller.identity : selfIdentity(caller);
1692
+ /** Its own code's handle, which reaches it in process. */
1693
+ let ownHandle = null;
1694
+ const post = options.transport?.post ?? publicPost;
1695
+ const watchers = createWatchers({
1696
+ watch: Object.fromEntries(Object.entries(binding.subscriptions).flatMap(([id, subscription]) => subscription.kind === "tail" ? [[id, {
1697
+ tail: subscription.tail,
1698
+ when: subscription.when
1699
+ }]] : [])),
1700
+ run: (id, event, trigger, attempt) => binding.run(id, event, {
1701
+ trigger,
1702
+ attempt
1703
+ }),
1704
+ store: log,
1705
+ scheduler: options.scheduler,
1706
+ registered: {
1707
+ state: registry.state,
1708
+ accessNow: (principal) => binding.accessNow(principal),
1709
+ wake
1710
+ }
1711
+ });
1712
+ const schedules = createSchedules({
1713
+ store: log,
1714
+ scheduler: options.scheduler
1715
+ });
1716
+ const projection = tablesConsumer(store.tables);
1717
+ /** Sync's delivery in progress here: its receipts are its consumer's appends, fenced by its attempt. */
1718
+ const delivering = new AsyncLocalStorage();
1719
+ /**
1720
+ * Sync's outbound, when board() syncs: each event it delivers, on its thread's lane, with its own
1721
+ * place in the log, so no watcher holds it up. A2 retries what throws, and dead-letters it after 10.
1722
+ */
1723
+ const outbound = binding.subscriptions["@sync"] ? consumer({
1724
+ contract: boardContract,
1725
+ name: "board-sync",
1726
+ types: DELIVERED,
1727
+ lane: ({ event }) => `thread:${event.payload.thread}`
1728
+ }).run(async (ctx) => {
1729
+ const event = ctx.event;
1730
+ const [messages, { state: actors }] = await Promise.all([loadBefore(reads, event, ctx.signal), ctx.session.state(actorsReducer, { through: event.index - 1 })]);
1731
+ const { before, after } = foldEvent(messages, actors, event);
1732
+ const about = event.type === "thread.synced" ? (await reads.ensure(event.index - 1, ctx.signal), await store.tables.byEventId(event.payload.thread)) ?? void 0 : after;
1733
+ const shown = {
1734
+ ...eventView(event, about, actors, OWN_CODE),
1735
+ ...event.type === "labels.changed" && before ? { before: view(before, actors, OWN_CODE) } : {}
1736
+ };
1737
+ await delivering.run(ctx, () => binding.run(SYNC, shown, {
1738
+ trigger: ctx.event.id,
1739
+ attempt: ctx.attempt
1740
+ }));
1741
+ }) : void 0;
1742
+ const consumers = [
1743
+ projection,
1744
+ watchers.boardConsumer,
1745
+ outbound
1746
+ ].filter((consumer) => consumer !== void 0);
1747
+ const server = createServer({
1748
+ contract: boardContract,
1749
+ store: log,
1750
+ ...options.scheduler ? { scheduler: options.scheduler } : {},
1751
+ consumers
1752
+ });
1753
+ const session = server.session(BOARD_SESSION);
1754
+ const reads = createReads(store.tables, session, projection);
1755
+ const writes = {
1756
+ session,
1757
+ reads
1758
+ };
1759
+ /** Sync's inbound: each platform event board()'s sync pushed here, one platform thread at a time. */
1760
+ const inbound = consumer({
1761
+ contract: inboxContract,
1762
+ name: "board-inbox",
1763
+ types: ["external.received"],
1764
+ lane: ({ event }) => `thread:${event.payload.event.thread}`
1765
+ }).run(async (ctx) => {
1766
+ const { payload } = ctx.event;
1767
+ await binding.receive(payload.sync, {
1768
+ event: payload.event,
1769
+ raw: payload.raw
1770
+ }, {
1771
+ id: ctx.event.id,
1772
+ attempt: ctx.attempt,
1773
+ signal: ctx.signal
1774
+ });
1775
+ });
1776
+ const inboxServer = createServer({
1777
+ contract: inboxContract,
1778
+ store: log,
1779
+ ...options.scheduler ? { scheduler: options.scheduler } : {},
1780
+ consumers: [inbound]
1781
+ });
1782
+ const codes = createServer({
1783
+ contract: identityContract,
1784
+ store: log
1785
+ }).session(IDENTITY_SESSION);
1786
+ const inboxes = inboxServer.session(INBOXES_SESSION);
1787
+ /** The inboxes this instance knows have an `inbox.opened`, so most pushes write nothing more. */
1788
+ const opened = /* @__PURE__ */ new Set();
1789
+ schedules.bind({
1790
+ writes,
1791
+ accessNow: binding.accessNow,
1792
+ send: async (identity, origin, input) => sendAs(identity, origin, input)
1793
+ });
1794
+ watchers.bind({ reads });
1795
+ /**
1796
+ * Whether `principal` may have a file: one it uploaded, or one a message it
1797
+ * can read carries. A deleted message drops its files, so it no longer counts.
1798
+ */
1799
+ async function attachmentReadable(principal, id, uploadedBy) {
1800
+ if (principal.access.readsAll || uploadedBy === principal.id) return true;
1801
+ await reads.ensure();
1802
+ return (await reads.tables.withAttachment(id)).some((message) => principal.access.readable(message.channel));
1803
+ }
1804
+ /**
1805
+ * `filter`, narrowed to the channels `access` reads among those the log has,
1806
+ * and the index that list reflects: a read's cursor goes no further, or the
1807
+ * first post in a channel newer than the list would be in neither the page
1808
+ * nor the feed after it.
1809
+ */
1810
+ async function readable(filter, access) {
1811
+ if (access.readsAll) return {
1812
+ filter,
1813
+ through: Number.POSITIVE_INFINITY
1814
+ };
1815
+ const { state, index } = await session.state(channelsReducer);
1816
+ return {
1817
+ filter: readableFilter(filter, Object.keys(state), access),
1818
+ through: index
1819
+ };
1820
+ }
1821
+ /** Who wrote, was mentioned, or reacted in the channels `access` reads, a page at a time. */
1822
+ async function actorsIn(access) {
1823
+ const ids = /* @__PURE__ */ new Set();
1824
+ const { filter } = await readable({}, access);
1825
+ for (let before;;) {
1826
+ const { messages } = await reads.messages(filter, {
1827
+ limit: 500,
1828
+ ...before !== void 0 ? { before } : {}
1829
+ });
1830
+ for (const message of messages) {
1831
+ ids.add(message.by.actor);
1832
+ for (const id of message.mentions) ids.add(id);
1833
+ for (const reactors of Object.values(message.reactions)) for (const id of reactors) ids.add(id);
1834
+ }
1835
+ if (messages.length < 500) return ids;
1836
+ before = messages[0].id;
1837
+ }
1838
+ }
1839
+ /** Files a write carries, as the board stored them: each one the caller may have, or 404 as if it didn't exist. */
1840
+ const attachmentsFor = (principal, ids) => Promise.all([...new Set(ids ?? [])].map(async (id) => {
1841
+ const { attachment, uploadedBy } = await readUpload(blob, id);
1842
+ if (!await attachmentReadable(principal, id, uploadedBy)) throw attachmentNotFound(id);
1843
+ return attachment;
1844
+ }));
1845
+ /**
1846
+ * Tells a registrant's board it has matches to pull, with no event and no credential in it: its
1847
+ * board pulls them with its own credentials, and lets this POST through its own protection. The hash says whether its registration is stale. Says whether the
1848
+ * wake-up got there: an answer, or still pulling when the wait ran out.
1849
+ */
1850
+ async function wake(registration) {
1851
+ try {
1852
+ const response = await post(`${registration.url}/wake`, {
1853
+ headers: {
1854
+ "content-type": "application/json",
1855
+ "x-board-watchers": registration.hash
1856
+ },
1857
+ body: "{}",
1858
+ signal: AbortSignal.timeout(WAKE_TIMEOUT_MS)
1859
+ });
1860
+ return response.ok ? { reached: true } : {
1861
+ reached: false,
1862
+ why: String(response.status)
1863
+ };
1864
+ } catch (error) {
1865
+ if (error.name === "TimeoutError") return { reached: true };
1866
+ return {
1867
+ reached: false,
1868
+ why: error.message
1869
+ };
1870
+ }
1871
+ }
1872
+ /** A registrant's queue, in the watch contract: its watchers' and sync's matches, and its inbox. */
1873
+ const queueOf = (owner) => watchers.server.session(registrantSession(owner));
1874
+ /**
1875
+ * A schedule's send, as its creator, through board(): its middleware sees it as any post. One the
1876
+ * middleware drops is a `DroppedError`, as the schedule takes it.
1877
+ */
1878
+ async function sendAs(identity, origin, input) {
1879
+ const { channel, re, ...rest } = input;
1880
+ const handle = binding.handle({
1881
+ identity,
1882
+ origin
1883
+ });
1884
+ try {
1885
+ return channel !== void 0 ? await handle.post(channel, rest) : await handle.reply(re, rest);
1886
+ } catch (error) {
1887
+ if (error instanceof BoardError && error.code === "dropped") throw new DroppedError(error.message === "the board dropped this message" ? [] : error.message.split("\n"));
1888
+ throw error;
1889
+ }
1890
+ }
1891
+ /** A post as it lands: what the caller may post, with the files it may have. */
1892
+ async function postNow(caller, identity, parsed) {
1893
+ const { principal } = identity;
1894
+ const body = {
1895
+ ...parsed,
1896
+ re: parsed.re === void 0 ? void 0 : messageAt(parsed.re)
1897
+ };
1898
+ const proofs = await authorizePost(writes, principal.access, body);
1899
+ if (fromRequest(caller) && body.id !== void 0 && DERIVED_ID.test(body.id)) throw badRequest("id: UUIDv8s are the board's own, for the writes it makes itself");
1900
+ if (fromRequest(caller) && body.id?.startsWith("key:") && !isKeyIdOf(body.id, principal.id)) throw badRequest(`id: key:… IDs derive from a client's key, and this token's are key:${principal.id}:…`);
1901
+ if (fromRequest(caller) && body.id?.startsWith("sync:")) throw badRequest("id: sync:… IDs are what sync imports, under the principal that imports them");
1902
+ const attachments = await attachmentsFor(principal, body.attachments);
1903
+ const { schedule, config } = caller.origin;
1904
+ const { unknownMentions, ...message } = await writePost(writes, {
1905
+ attachments,
1906
+ identity,
1907
+ proofs,
1908
+ channel: body.channel,
1909
+ re: body.re,
1910
+ text: body.text,
1911
+ labels: body.labels,
1912
+ noSync: body.noSync,
1913
+ id: body.id,
1914
+ sync: body.sync,
1915
+ config,
1916
+ ...schedule && !fromRequest(caller) ? {
1917
+ schedule,
1918
+ lenientMentions: true
1919
+ } : {}
1920
+ });
1921
+ return {
1922
+ message,
1923
+ ...unknownMentions ? { unknownMentions } : {}
1924
+ };
1925
+ }
1926
+ /**
1927
+ * The sync entry an import says it came through, when it names one: one its caller syncs, or a
1928
+ * 403. This board's own sync's, or what a sync elsewhere registered.
1929
+ */
1930
+ async function checkSource(caller, principal, source) {
1931
+ if (source && !(await entriesOf(caller, principal)).some((entry) => entry.key === source.key)) throw forbidden(`this token doesn't sync ${source.key} here: its board registers its sync entries`);
1932
+ }
1933
+ const methods = {
1934
+ async discovery() {
1935
+ const url = binding.url() ?? (ownHandle ??= binding.handle(OWN)).url;
1936
+ const { id, teamId } = await binding.project();
1937
+ return {
1938
+ version: VERSION,
1939
+ url,
1940
+ audience: url,
1941
+ team_id: teamId,
1942
+ project_id: id,
1943
+ sync: (await linkable()).map(({ key, platform }) => ({
1944
+ key,
1945
+ platform
1946
+ }))
1947
+ };
1948
+ },
1949
+ async me(caller) {
1950
+ const { principal, author, actor } = await identityOf(caller);
1951
+ return {
1952
+ actor: actor.id,
1953
+ principal: principal.id,
1954
+ kind: actor.kind,
1955
+ handle: actor.handle,
1956
+ name: actor.name,
1957
+ avatar: actor.avatar,
1958
+ profile: author.profile,
1959
+ session: author.session,
1960
+ expiresAt: principal.expiresAt?.toISOString() ?? null,
1961
+ can: principal.access.summary()
1962
+ };
1963
+ },
1964
+ async channels(caller) {
1965
+ const { principal } = await identityOf(caller);
1966
+ const { state, index } = await session.state(channelsReducer);
1967
+ return {
1968
+ channels: Object.values(state).filter((channel) => principal.access.readable(channel.path)).sort((a, b) => a.path.localeCompare(b.path)),
1969
+ cursor: ref(index)
1970
+ };
1971
+ },
1972
+ async actors(caller) {
1973
+ const { principal, actor } = await identityOf(caller);
1974
+ const { state, index } = await session.state(actorsReducer);
1975
+ let seen = null;
1976
+ if (!principal.access.readsAll) {
1977
+ const ids = await actorsIn(principal.access);
1978
+ ids.add(actor.id);
1979
+ seen = (id) => ids.has(id);
1980
+ }
1981
+ return {
1982
+ actors: Object.values(state.byId).filter((a) => seen === null || seen(a.id)).sort((a, b) => a.handle.localeCompare(b.handle)).map(({ host: _host, ...a }) => {
1983
+ const person = personView(state, a.id);
1984
+ return person ? {
1985
+ ...a,
1986
+ person
1987
+ } : a;
1988
+ }),
1989
+ cursor: ref(index)
1990
+ };
1991
+ },
1992
+ async identity(caller) {
1993
+ const { principal } = await identityOf(caller);
1994
+ const { state, index } = await session.state(actorsReducer);
1995
+ const links = linkedTo(state, principal.id).map((id) => ({
1996
+ actor: id,
1997
+ handle: state.byId[id]?.handle ?? null,
1998
+ name: state.byId[id]?.name ?? null,
1999
+ method: state.links[id].method,
2000
+ at: state.links[id].at
2001
+ }));
2002
+ const unlinked = (await linkable()).filter((entry) => !links.some((link) => link.actor.startsWith(`${entry.platform}:user:`))).map((entry) => entry.key);
2003
+ return {
2004
+ principal: principal.id,
2005
+ links,
2006
+ unlinked,
2007
+ cursor: ref(index)
2008
+ };
2009
+ },
2010
+ async messages(caller, query) {
2011
+ const { principal, actor } = await identityOf(caller);
2012
+ const { state: actors } = await session.state(actorsReducer);
2013
+ const { filter, through } = await readable(toMessageFilter(query, {
2014
+ me: actor.id,
2015
+ actors
2016
+ }), principal.access);
2017
+ const { messages, position } = await reads.messages(filter, {
2018
+ ...query.before !== void 0 ? { before: cursorAt(query.before) } : {},
2019
+ ...query.after !== void 0 ? { after: cursorAt(query.after) } : {},
2020
+ limit: query.limit ?? 50
2021
+ });
2022
+ const reader = await reads.reader(principal.access, messages);
2023
+ return {
2024
+ messages: messages.map((m) => view(m, actors, reader)),
2025
+ cursor: ref(Math.min(position, through))
2026
+ };
2027
+ },
2028
+ async threads(caller, query) {
2029
+ const { principal, actor } = await identityOf(caller);
2030
+ const { state: actors } = await session.state(actorsReducer);
2031
+ const { filter, through } = await readable(toMessageFilter(query, {
2032
+ me: actor.id,
2033
+ actors
2034
+ }), principal.access);
2035
+ const { threads, position } = await reads.threads(filter, {
2036
+ ...query.after !== void 0 ? { after: cursorAt(query.after) } : {},
2037
+ limit: query.limit ?? 50
2038
+ });
2039
+ const reader = await reads.reader(principal.access, threads.flatMap((t) => t.last ? [t.root, t.last] : [t.root]));
2040
+ return {
2041
+ threads: threads.map((t) => ({
2042
+ message: view(t.root, actors, reader),
2043
+ replies: t.replies,
2044
+ last: t.last && view(t.last, actors, reader)
2045
+ })),
2046
+ cursor: ref(Math.min(position, through))
2047
+ };
2048
+ },
2049
+ async message(caller, { id }, signal) {
2050
+ const { principal } = await identityOf(caller);
2051
+ const at = messageAt(id);
2052
+ const message = await reads.message(at, signal);
2053
+ if (!message || !principal.access.readable(message.channel)) throw notFound(`message not found: #${at}`, "message_not_found");
2054
+ const [{ messages, cursor }, { state: actors }] = await Promise.all([reads.thread(message.re ?? message.id, signal), session.state(actorsReducer)]);
2055
+ const [root, ...replies] = messages;
2056
+ const reader = await reads.reader(principal.access, messages);
2057
+ return {
2058
+ message: view(root, actors, reader),
2059
+ replies: replies.map((reply) => view(reply, actors, reader)),
2060
+ cursor: ref(cursor),
2061
+ schedules: await schedules.forThread(root.id)
2062
+ };
2063
+ },
2064
+ async posted(caller, { id }, signal) {
2065
+ const identity = await identityOf(caller);
2066
+ const landed = await reads.byEventId(id, signal);
2067
+ const shown = landed ? await landedFor(writes, identity, landed) : null;
2068
+ if (!shown) throw notFound(`no post of yours has id ${id}`, "post_not_found");
2069
+ return { message: shown };
2070
+ },
2071
+ async post(caller, parsed) {
2072
+ return postNow(caller, await identityOf(caller), parsed);
2073
+ },
2074
+ async edit(caller, { id, text }) {
2075
+ const identity = await identityOf(caller);
2076
+ const authorized = await authorizeOwn(writes, identity.principal, messageAt(id), "edit");
2077
+ const { unknownMentions } = await writeEdit(writes, {
2078
+ identity,
2079
+ authorized,
2080
+ text
2081
+ });
2082
+ return {
2083
+ ok: true,
2084
+ ...unknownMentions ? { unknownMentions } : {}
2085
+ };
2086
+ },
2087
+ async delete(caller, { id }) {
2088
+ const identity = await identityOf(caller);
2089
+ const authorized = await authorizeOwn(writes, identity.principal, messageAt(id), "delete");
2090
+ await writeDelete(writes, {
2091
+ identity,
2092
+ authorized
2093
+ });
2094
+ return { ok: true };
2095
+ },
2096
+ async react(caller, { id, emoji, remove }) {
2097
+ const identity = await identityOf(caller);
2098
+ const authorized = await authorizeMessage(writes, identity.principal.access, "react", messageAt(id));
2099
+ await writeReaction(writes, {
2100
+ identity,
2101
+ authorized,
2102
+ emoji,
2103
+ remove
2104
+ });
2105
+ return { ok: true };
2106
+ },
2107
+ async label(caller, { id, set, unset }) {
2108
+ const identity = await identityOf(caller);
2109
+ const authorized = await authorizeMessage(writes, identity.principal.access, "label", messageAt(id));
2110
+ await writeLabels(writes, {
2111
+ identity,
2112
+ authorized,
2113
+ set,
2114
+ unset
2115
+ });
2116
+ return { ok: true };
2117
+ },
2118
+ async schedule(caller, parsed) {
2119
+ const identity = await identityOf(caller);
2120
+ const body = {
2121
+ ...parsed,
2122
+ re: parsed.re === void 0 ? void 0 : messageAt(parsed.re)
2123
+ };
2124
+ await authorizePost(writes, identity.principal.access, body);
2125
+ const attachments = await attachmentsFor(identity.principal, body.attachments);
2126
+ return schedules.create(identity, body, attachments);
2127
+ },
2128
+ async schedules(caller, query) {
2129
+ const { principal, actor } = await identityOf(caller);
2130
+ let root;
2131
+ if (query.re !== void 0) {
2132
+ const at = messageAt(query.re);
2133
+ root = (await authorizeMessage(writes, principal.access, "read", at)).message.re ?? at;
2134
+ }
2135
+ const visible = (schedule) => principal.access.readable(schedule.channel) || createdBy(schedule, principal);
2136
+ return { schedules: (await schedules.list({
2137
+ ...query,
2138
+ ...root !== void 0 ? { root } : {}
2139
+ }, visible)).map((schedule) => blindSchedule(schedule, principal.access, actor.id)) };
2140
+ },
2141
+ async cancelSchedule(caller, { id }) {
2142
+ const identity = await identityOf(caller);
2143
+ const answer = await schedules.cancel(identity, scheduleAt(id));
2144
+ return {
2145
+ ...answer,
2146
+ schedule: blindSchedule(answer.schedule, identity.principal.access, identity.actor.id)
2147
+ };
2148
+ },
2149
+ async upload(caller, { data, name, type }) {
2150
+ const { principal } = await identityOf(caller);
2151
+ if (!principal.access.anywhere("post") && !principal.access.anywhere("sync")) throw forbidden("this token can't post anywhere, so it can't upload");
2152
+ const max = ownSync(caller) ? IMPORT_BYTES_MAX : ATTACHMENT_BYTES_MAX;
2153
+ return { attachment: await writeUpload(blob, {
2154
+ data,
2155
+ name,
2156
+ type,
2157
+ uploadedBy: principal.id
2158
+ }, max) };
2159
+ },
2160
+ async uploadStart(caller, { name, type, size }) {
2161
+ const { principal } = await identityOf(caller);
2162
+ if (!principal.access.anywhere("post") && !principal.access.anywhere("sync")) throw forbidden("this token can't post anywhere, so it can't upload");
2163
+ return startUpload(blob, {
2164
+ name: name ?? null,
2165
+ type: type ?? null,
2166
+ size,
2167
+ uploadedBy: principal.id
2168
+ });
2169
+ },
2170
+ async uploadFinish(caller, { id }) {
2171
+ const { principal } = await identityOf(caller);
2172
+ return { attachment: await finishUpload(blob, id, principal.id) };
2173
+ },
2174
+ async attachment(caller, { id }) {
2175
+ const { principal } = await identityOf(caller);
2176
+ const { attachment, uploadedBy } = await readUpload(blob, id);
2177
+ if (!await attachmentReadable(principal, attachment.id, uploadedBy)) throw attachmentNotFound(attachment.id);
2178
+ return {
2179
+ attachment,
2180
+ data: await attachmentBody(blob, attachment.id)
2181
+ };
2182
+ },
2183
+ async watchers(caller) {
2184
+ const { principal } = await identityOf(caller);
2185
+ const all = await registry.list();
2186
+ return { watchers: principal.access.everywhere("manage") ? all : all.filter((found) => found.owner === principal.id) };
2187
+ },
2188
+ async register(caller, body) {
2189
+ const { principal } = await identityOf(caller);
2190
+ ownWatchers(principal);
2191
+ if (!principal.access.anywhere("read")) throw forbidden("this token can't read anywhere, so a watcher it registers would see nothing");
2192
+ if ((body.sync?.length ?? 0) > 0 && !principal.access.anywhere("sync")) throw forbidden("this token can't register a sync: it needs sync somewhere");
2193
+ const taken = body.sync?.find((entry) => binding.sync.some((own) => own.key === entry.key));
2194
+ if (taken) throw new HttpError(409, "sync_key_taken", `this board's own sync has sync entry ${taken.key}: give yours another key`);
2195
+ for (const [id, spec] of Object.entries(body.watch)) {
2196
+ const name = RemoteWatcherId.safeParse(id);
2197
+ if (!name.success) throw badRequest(`watch ${id}: ${name.error.issues[0].message}`, "bad_watcher");
2198
+ try {
2199
+ compileWatcher(id, spec);
2200
+ } catch (error) {
2201
+ throw badRequest(error.message, "bad_watcher");
2202
+ }
2203
+ }
2204
+ const registered = await registry.register(storedPrincipal(principal), {
2205
+ url: body.url ?? null,
2206
+ watch: body.watch,
2207
+ sync: body.sync ?? []
2208
+ });
2209
+ const queue = queueOf(principal.id);
2210
+ if ((await queue.state(queueReducer)).state.unreachable) await queue.append({
2211
+ type: "watch.reachable",
2212
+ payload: { by: "registration" }
2213
+ });
2214
+ return registered;
2215
+ },
2216
+ async matches(caller, query, signal) {
2217
+ const { principal } = await identityOf(caller);
2218
+ ownWatchers(principal);
2219
+ if (!(await registry.state()).byOwner[principal.id]) throw notFound("this token has no watchers registered here", "no_registration");
2220
+ const queue = queueOf(principal.id);
2221
+ const pulling = {
2222
+ access: principal.access,
2223
+ limit: query.limit ?? 10,
2224
+ timers: options.scheduler !== void 0
2225
+ };
2226
+ const deadline = Date.now() + (query.wait ?? 0) * 1e3;
2227
+ for (let pause = 100;; pause = Math.min(pause * 2, 1e3)) {
2228
+ const matches = await pull(queue, {
2229
+ ...pulling,
2230
+ now: Date.now()
2231
+ });
2232
+ if (matches.length > 0) {
2233
+ const reader = await reads.readerOf(principal.access, matches.flatMap((match) => viewLinkedIds(match.event)));
2234
+ return { matches: matches.map((match) => ({
2235
+ ...match,
2236
+ event: viewedBy(match.event, reader)
2237
+ })) };
2238
+ }
2239
+ if (Date.now() >= deadline || signal.aborted) return { matches };
2240
+ await new Promise((resolve) => setTimeout(resolve, Math.min(pause, Math.max(0, deadline - Date.now()))));
2241
+ }
2242
+ },
2243
+ async ack(caller, { id, ...body }) {
2244
+ const { principal } = await identityOf(caller);
2245
+ ownWatchers(principal);
2246
+ const result = await ack(queueOf(principal.id), id, body, {
2247
+ now: Date.now(),
2248
+ timers: options.scheduler !== void 0
2249
+ });
2250
+ if (result === "gone") throw notFound(`no match ${id} is waiting for this token`, "no_match");
2251
+ if (result === "stale") throw new HttpError(409, "stale_lease", "that lease ran out, and the match was pulled again");
2252
+ return { ok: true };
2253
+ },
2254
+ async events(caller, query, signal) {
2255
+ const { principal, actor } = await identityOf(caller);
2256
+ const after = query.after !== void 0 ? cursorAt(query.after) : void 0;
2257
+ const { state: actors } = await session.state(actorsReducer);
2258
+ const feed = {
2259
+ session,
2260
+ reads,
2261
+ after,
2262
+ filters: query,
2263
+ all: query.all ?? false,
2264
+ me: actor.id,
2265
+ actors,
2266
+ access: principal.access
2267
+ };
2268
+ const { events, cursor } = await pollFeed(feed, (query.wait ?? 0) * 1e3, signal);
2269
+ return {
2270
+ events,
2271
+ cursor: ref(cursor)
2272
+ };
2273
+ },
2274
+ async typing(caller, { id, status }, signal) {
2275
+ const { principal } = await identityOf(caller);
2276
+ const found = await reads.message(messageAt(id), signal);
2277
+ if (!found || found.deleted || !principal.access.readable(found.channel)) throw notFound(`message not found: #${id}`, "message_not_found");
2278
+ if (!principal.access.can("post", found.channel)) throw forbidden(`this token can't post in ${found.channel}`);
2279
+ const { message, replies } = await methods.message(caller, { id }, signal);
2280
+ return { targets: await binding.typing([message, ...replies], status) };
2281
+ },
2282
+ async readSource(caller, { key, target, limit }) {
2283
+ if (!binding.sync.some((entry) => entry.key === key)) throw notFound(`this board doesn't run sync entry ${key}: read it where its sync runs`, "not_synced_here");
2284
+ const { principal } = await identityOf(caller);
2285
+ if (!principal.access.everywhere("manage")) throw forbidden("this token can't read a source: it needs manage in every channel");
2286
+ return binding.readSource(key, target, limit ?? 50);
2287
+ },
2288
+ async syncImport(caller, body, signal) {
2289
+ const { principal } = await needSync(caller, "import", true);
2290
+ await speaksFor(caller, principal.id, body.author, body.op === "seen" ? null : body.from);
2291
+ const author = externalIdentity(body.author, recordsActors(caller, principal));
2292
+ if (body.op === "seen") {
2293
+ const { state: actors } = await session.state(actorsReducer);
2294
+ const seen = author.records === false ? null : actorSeen(author.actor, actors);
2295
+ if (seen) await session.append(seen);
2296
+ return { message: null };
2297
+ }
2298
+ const may = (channel) => principal.access.can("sync", channel);
2299
+ if (body.op === "post") {
2300
+ if (body.channel === void 0 === (body.re === void 0)) throw badRequest("an import needs `channel`; a reply needs `re`");
2301
+ let re;
2302
+ let root = null;
2303
+ let channel = body.channel;
2304
+ if (body.re !== void 0) {
2305
+ re = messageAt(body.re);
2306
+ root = await reads.message(re);
2307
+ if (!root || !principal.access.readable(root.channel)) throw notFound(`message not found: #${body.re}`, "message_not_found");
2308
+ channel = root.channel;
2309
+ }
2310
+ if (!may(channel)) throw forbidden(`this token can't sync in ${channel}, so it can't import there`);
2311
+ const id = body.id === void 0 ? void 0 : importId(caller, principal.id, body.id);
2312
+ const attachments = await attachmentsFor(principal, body.attachments);
2313
+ const commit = async ({ text, labels }) => {
2314
+ const proofs = await trusted.post(writes, {
2315
+ channel: body.channel,
2316
+ re,
2317
+ labels
2318
+ });
2319
+ return (await writePost(writes, {
2320
+ identity: author,
2321
+ proofs,
2322
+ channel: body.channel,
2323
+ re,
2324
+ text,
2325
+ labels,
2326
+ noSync: body.noSync,
2327
+ id,
2328
+ origin: body.origin,
2329
+ from: body.from,
2330
+ ...body.origin && !ownSync(caller) ? { importedBy: principal.id } : {},
2331
+ lenientMentions: true,
2332
+ attachments,
2333
+ links: await linksOf(principal, body.links),
2334
+ config: caller.origin.config
2335
+ })).id;
2336
+ };
2337
+ const write = {
2338
+ text: body.text,
2339
+ labels: body.labels ?? {}
2340
+ };
2341
+ if (id !== void 0 && await reads.byEventId(id, signal)) return { message: await commit(write) };
2342
+ if (body.origin) await claimable(body.origin, body.from, root?.thread ?? null);
2343
+ await checkSource(caller, principal.id, body.source);
2344
+ return { message: await commit(write) };
2345
+ }
2346
+ if (body.origin === void 0 === (body.message === void 0)) throw badRequest("a change names its message by `origin` or by `message`, one or the other");
2347
+ const id = body.origin !== void 0 ? await locate(body.origin) : indexOf(body.message);
2348
+ const found = id === null || id === 0 ? null : await reads.message(id);
2349
+ if (!found || !may(found.channel)) return { message: null };
2350
+ const authorized = (permission) => trusted.message(writes, permission, found.id);
2351
+ const asAuthor = body.origin === void 0;
2352
+ if (body.op === "edit") {
2353
+ const proof = await authorized("post");
2354
+ const commit = async ({ text }) => {
2355
+ await writeEdit(writes, {
2356
+ identity: author,
2357
+ authorized: proof,
2358
+ text,
2359
+ from: body.from,
2360
+ asAuthor
2361
+ });
2362
+ return ref(found.id);
2363
+ };
2364
+ const write = {
2365
+ kind: "edit",
2366
+ id: ref(found.id),
2367
+ channel: found.channel,
2368
+ re: found.re === null ? null : ref(found.re),
2369
+ text: body.text
2370
+ };
2371
+ if (!found.deleted && !(asAuthor && found.by.principal !== body.author.id)) await checkSource(caller, principal.id, body.source);
2372
+ return { message: await commit(write) };
2373
+ }
2374
+ if (body.op === "delete") await writeDelete(writes, {
2375
+ identity: author,
2376
+ authorized: await authorized("post"),
2377
+ from: body.from,
2378
+ asAuthor
2379
+ });
2380
+ else if (body.op === "react") await writeReaction(writes, {
2381
+ identity: author,
2382
+ authorized: await authorized("react"),
2383
+ emoji: body.emoji,
2384
+ remove: body.remove ?? false,
2385
+ from: body.from
2386
+ });
2387
+ else if (body.op === "labels") await writeLabels(writes, {
2388
+ identity: author,
2389
+ authorized: await authorized("label"),
2390
+ set: body.set,
2391
+ unset: body.unset
2392
+ });
2393
+ else await writeLinks(writes, {
2394
+ identity: author,
2395
+ authorized: await authorized("post"),
2396
+ links: await linksOf(principal, body.links) ?? [],
2397
+ from: body.from
2398
+ });
2399
+ return { message: ref(found.id) };
2400
+ },
2401
+ async syncReceipt(caller, body) {
2402
+ const identity = await identityOf(caller);
2403
+ unnarrowed(identity.principal, "record what it synced");
2404
+ const authorized = await authorizeMessage(writes, identity.principal.access, "sync", messageAt(body.message));
2405
+ const { message } = authorized;
2406
+ if (body.kind === "thread") return {
2407
+ ok: true,
2408
+ ...await writeAttach(writes, {
2409
+ identity,
2410
+ authorized,
2411
+ target: {
2412
+ target: body.target,
2413
+ kind: body.targetKind
2414
+ },
2415
+ cause: body.cause
2416
+ })
2417
+ };
2418
+ await deliverable(caller, identity.principal.id, message, body.target, body.kind === "message" ? body.external : void 0);
2419
+ const about = {
2420
+ id: message.id,
2421
+ thread: message.thread,
2422
+ target: body.target
2423
+ };
2424
+ const by = ownSync(caller) ? {} : { syncedBy: identity.principal.id };
2425
+ if (body.kind === "message") await receipt(caller, `receipt:${message.id}:${body.target}`, [{
2426
+ type: "message.synced",
2427
+ payload: {
2428
+ ...about,
2429
+ external: body.external,
2430
+ ...by
2431
+ }
2432
+ }, ...body.files !== void 0 ? [{
2433
+ type: "message.files_synced",
2434
+ payload: {
2435
+ ...about,
2436
+ files: body.files,
2437
+ ...by
2438
+ }
2439
+ }] : []]);
2440
+ else if (body.kind === "files") await receipt(caller, `files:${message.id}:${body.target}`, [{
2441
+ type: "message.files_synced",
2442
+ payload: {
2443
+ ...about,
2444
+ files: body.files,
2445
+ ...by
2446
+ }
2447
+ }]);
2448
+ else await receipt(caller, `failed:${message.id}:${body.target}`, [{
2449
+ type: "message.sync_failed",
2450
+ payload: {
2451
+ ...about,
2452
+ error: body.error
2453
+ }
2454
+ }]);
2455
+ return { ok: true };
2456
+ },
2457
+ async syncThread(caller, { thread }) {
2458
+ const { principal } = await needSync(caller, "look up a sync");
2459
+ await reads.ensure();
2460
+ const synced = await store.tables.syncOf(thread);
2461
+ if (!synced || !principal.access.can("sync", synced.channel)) throw notFound(`no thread ${thread} on this board`, "thread_not_found");
2462
+ return {
2463
+ thread,
2464
+ root: ref(synced.root),
2465
+ channel: synced.channel,
2466
+ targets: [...synced.targets],
2467
+ external: { ...synced.external }
2468
+ };
2469
+ },
2470
+ async syncFind(caller, query, signal) {
2471
+ const { principal } = await needSync(caller, "look up a sync");
2472
+ const { tables } = store;
2473
+ let thread = null;
2474
+ let message = null;
2475
+ let holder = null;
2476
+ if (query.code !== void 0) {
2477
+ const issued = (await codes.state(identityCodesReducer)).state[hashCode(query.code)];
2478
+ const keys = new Set((await entriesOf(caller, principal.id)).map((entry) => entry.key));
2479
+ if (issued && issued.usedBy === null && Date.parse(issued.expiresAt) >= Date.now() && keys.has(issued.sync)) holder = issued.principal;
2480
+ }
2481
+ if (query.post !== void 0) {
2482
+ const landed = await reads.byEventId(importId(caller, principal.id, query.post), signal);
2483
+ if (landed && principal.access.can("sync", landed.channel)) message = ref(landed.id);
2484
+ }
2485
+ if (query.thread !== void 0) {
2486
+ let key = query.message === void 0 ? await tables.threadOf(query.thread) : null;
2487
+ if (key === null) {
2488
+ await reads.ensure(void 0, signal);
2489
+ key = await tables.threadOf(query.thread);
2490
+ }
2491
+ const synced = key === null ? null : await tables.syncOf(key);
2492
+ if (synced && principal.access.can("sync", synced.channel)) thread = key;
2493
+ if (query.message !== void 0) {
2494
+ const id = await tables.messageOf(query.thread, query.message);
2495
+ const found = id === null ? null : await tables.message(id);
2496
+ if (found && principal.access.can("sync", found.channel)) message = ref(found.id);
2497
+ }
2498
+ }
2499
+ const { state: actors } = query.account !== void 0 || query.person !== void 0 ? await session.state(actorsReducer) : { state: null };
2500
+ const platforms = actors ? (await entriesOf(caller, principal.id)).map((entry) => `${entry.platform}:`) : [];
2501
+ const ours = (account) => platforms.some((platform) => account.startsWith(platform));
2502
+ const link = query.account !== void 0 && ours(query.account) ? actors?.links[query.account] : void 0;
2503
+ return {
2504
+ thread,
2505
+ message,
2506
+ person: link ? {
2507
+ id: link.to,
2508
+ method: link.method
2509
+ } : null,
2510
+ accounts: query.person === void 0 || !actors ? [] : linkedTo(actors, query.person).filter(ours).map((id) => ({
2511
+ id,
2512
+ method: actors.links[id].method
2513
+ })),
2514
+ holder
2515
+ };
2516
+ },
2517
+ async syncCopies(caller, query) {
2518
+ const { principal } = await needSync(caller, "look up a sync");
2519
+ await reads.ensure();
2520
+ const ids = [...new Set(query.message.map(indexOf).filter((id) => id !== null && id > 0))];
2521
+ const everywhere = principal.access.everywhere("sync");
2522
+ const [copies, messages] = await Promise.all([store.tables.copiesOf(ids), everywhere ? null : store.tables.messagesAt(ids, await store.tables.position())]);
2523
+ const own = ownSync(caller);
2524
+ const mine = (copy) => own ? copy.by === void 0 : copy.by === principal.id;
2525
+ const out = {};
2526
+ for (const id of ids) {
2527
+ const found = messages?.get(id);
2528
+ if (everywhere || found && principal.access.can("sync", found.channel)) out[ref(id)] = Object.fromEntries(Object.entries(copies.get(id) ?? {}).filter(([, copy]) => mine(copy)).map(([target, { by: _by, ...copy }]) => [target, copy]));
2529
+ }
2530
+ return { copies: out };
2531
+ },
2532
+ async syncRoots(caller, { by, since }) {
2533
+ const { principal } = await needSync(caller, "look up a sync");
2534
+ await reads.ensure();
2535
+ const roots = (await store.tables.recentRoots(by, since)).filter((root) => principal.access.can("sync", root.channel));
2536
+ const { state: actors } = await session.state(actorsReducer);
2537
+ const reader = await reads.reader(principal.access, roots);
2538
+ return { roots: roots.map((root) => view(root, actors, reader)) };
2539
+ },
2540
+ async inboxPush(caller, { key, events }) {
2541
+ const { principal } = await needSync(caller, "push to an inbox", true);
2542
+ for (const { event } of events) for (const location of [
2543
+ event.channel,
2544
+ event.thread,
2545
+ "subthread" in event ? event.subthread : null
2546
+ ]) if (location !== null && !location.startsWith(`${key}:`)) throw badRequest(`an inbox's platform locations belong to its sync key: ${location} isn't ${key}'s`);
2547
+ if (ownSync(caller)) {
2548
+ for (const { id, event, raw } of events) {
2549
+ await openInbox(event.channel);
2550
+ await inboxServer.session(inboxSession(event.channel)).append({
2551
+ type: "external.received",
2552
+ id: await idempotentId("board-inbox", key, id),
2553
+ payload: {
2554
+ sync: key,
2555
+ event,
2556
+ raw
2557
+ }
2558
+ });
2559
+ }
2560
+ return { ok: true };
2561
+ }
2562
+ const queue = queueOf(principal.id);
2563
+ const matched = await Promise.all(events.map(async ({ id, event, raw }) => ({
2564
+ type: "watch.matched",
2565
+ id: await idempotentId("board-inbox", key, id),
2566
+ payload: {
2567
+ thread: event.thread,
2568
+ inbox: key,
2569
+ event: {
2570
+ sync: key,
2571
+ event,
2572
+ raw
2573
+ }
2574
+ }
2575
+ })));
2576
+ await queue.append(...matched).catch(async (error) => {
2577
+ if (!duplicate(error)) throw error;
2578
+ for (const one of matched) await queue.append(one);
2579
+ });
2580
+ return { ok: true };
2581
+ },
2582
+ async inboxNext(caller, { key, limit }) {
2583
+ if (ownSync(caller)) return { items: [] };
2584
+ const { principal } = await needSync(caller, "pull from an inbox", true);
2585
+ return { items: (await pullInbox(queueOf(principal.id), key, {
2586
+ access: principal.access,
2587
+ limit: limit ?? 10,
2588
+ now: Date.now(),
2589
+ timers: options.scheduler !== void 0
2590
+ })).map(({ id, thread, lease, attempt, event }) => {
2591
+ const item = event;
2592
+ return {
2593
+ id,
2594
+ key,
2595
+ thread,
2596
+ lease,
2597
+ attempt,
2598
+ event: item.event,
2599
+ raw: item.raw
2600
+ };
2601
+ }) };
2602
+ },
2603
+ async inboxAck(caller, { id, lease, error }) {
2604
+ const { principal } = await needSync(caller, "ack an inbox event", true);
2605
+ const result = await ack(queueOf(principal.id), id, {
2606
+ lease,
2607
+ error
2608
+ }, {
2609
+ now: Date.now(),
2610
+ timers: options.scheduler !== void 0
2611
+ });
2612
+ if (result === "gone") throw notFound(`no inbox event ${id} is waiting for this token`, "no_item");
2613
+ if (result === "stale") throw new HttpError(409, "stale_lease", "that lease ran out, and the event was pulled again");
2614
+ return { ok: true };
2615
+ },
2616
+ async inbox(caller, { key, channel, limit }) {
2617
+ const { principal } = await needSync(caller, "read an inbox");
2618
+ const everywhere = principal.access.everywhere("sync");
2619
+ const entries = [];
2620
+ if (everywhere) {
2621
+ const channels = channel !== void 0 ? [channel] : Object.keys((await inboxes.state(inboxesReducer)).state).filter((found) => found.startsWith(`${key}:`));
2622
+ for (const found of channels) for (const event of await inboxServer.session(inboxSession(found)).history()) if (event.type === "external.received" && event.payload.sync === key) entries.push({
2623
+ id: event.id,
2624
+ channel: found,
2625
+ createdAt: new Date(event.createdAt).toISOString(),
2626
+ ...event.payload
2627
+ });
2628
+ }
2629
+ const pushers = /* @__PURE__ */ new Set([principal.id, ...everywhere ? Object.keys((await registry.state()).byOwner) : []]);
2630
+ for (const pusher of pushers) for (const event of await queueOf(pusher).history()) {
2631
+ const pushed = event.payload;
2632
+ if (event.type !== "watch.matched" || pushed.inbox !== key || !pushed.event) continue;
2633
+ if (channel !== void 0 && pushed.event.event.channel !== channel) continue;
2634
+ entries.push({
2635
+ id: event.id,
2636
+ channel: pushed.event.event.channel,
2637
+ createdAt: new Date(event.createdAt).toISOString(),
2638
+ ...pushed.event
2639
+ });
2640
+ }
2641
+ return { events: entries.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, limit ?? 50) };
2642
+ },
2643
+ async syncCode(caller, { sync }) {
2644
+ const identity = await identityOf(caller);
2645
+ if (identity.principal.kind !== "person") throw forbidden("only people link platform accounts");
2646
+ const entry = (await linkable()).find((candidate) => candidate.key === sync);
2647
+ if (!entry) throw notFound(`no sync entry named ${sync}`);
2648
+ const principal = identity.principal.id;
2649
+ const code = newCode();
2650
+ const expiresAt = new Date(Date.now() + CODE_TTL_MS).toISOString();
2651
+ await codes.append({
2652
+ type: "identity.code_issued",
2653
+ payload: {
2654
+ hash: hashCode(code),
2655
+ principal,
2656
+ sync,
2657
+ expiresAt
2658
+ }
2659
+ });
2660
+ const signIn = entry.signIn && entry.url !== null ? `${entry.url}/identity/sign-in?${new URLSearchParams({
2661
+ sync,
2662
+ code
2663
+ })}` : null;
2664
+ return {
2665
+ code,
2666
+ expiresAt,
2667
+ ...signIn ? { signIn } : {}
2668
+ };
2669
+ },
2670
+ async syncAccount(caller, body) {
2671
+ const { principal } = await needSync(caller, "link accounts", true);
2672
+ const { account } = body;
2673
+ await speaksFor(caller, principal.id, account, body.sync);
2674
+ const hash = hashCode(body.code);
2675
+ const issued = (await codes.state(identityCodesReducer)).state[hash];
2676
+ const bad = () => notFound("that code isn't live: it expired, was used, or was issued for another sync entry", "bad_code");
2677
+ if (!issued || issued.sync !== body.sync) throw bad();
2678
+ const usedHere = (used) => used?.usedBy === account.id && used.usedIn === (body.message ?? null);
2679
+ if (issued.usedBy === null ? Date.parse(issued.expiresAt) < Date.now() : !usedHere(issued)) throw bad();
2680
+ await codes.append({
2681
+ type: "identity.code_used",
2682
+ id: `used:${hash}`,
2683
+ payload: {
2684
+ hash,
2685
+ actor: account.id,
2686
+ ...body.message !== void 0 ? { message: body.message } : {}
2687
+ }
2688
+ });
2689
+ if (!usedHere((await codes.state(identityCodesReducer)).state[hash])) throw bad();
2690
+ const linked = externalIdentity(account, recordsActors(caller, principal));
2691
+ return {
2692
+ person: issued.principal,
2693
+ link: await link(linked, issued.principal, body.method ?? "code", `link:${hash}`)
2694
+ };
2695
+ },
2696
+ async syncUnlink(caller, { account }) {
2697
+ const { principal, author } = await identityOf(caller);
2698
+ const { state } = await session.state(actorsReducer);
2699
+ const found = state.links[account];
2700
+ if (!found || found.to !== principal.id && !principal.access.everywhere("sync")) throw notFound(`${account} isn't linked`, "not_linked");
2701
+ await session.append({
2702
+ type: "actor.unlinked",
2703
+ payload: {
2704
+ actor: account,
2705
+ by: author
2706
+ }
2707
+ });
2708
+ return { ok: true };
2709
+ }
2710
+ };
2711
+ /**
2712
+ * An import's client ID as the board keeps it. A sync elsewhere names its posts under its own
2713
+ * principal, so it can't take an ID anyone else would use.
2714
+ */
2715
+ function importId(caller, principal, id) {
2716
+ return ownSync(caller) ? id : `sync:${principal}:${createHash("sha256").update(`board-sync\u001f${id}`).digest("hex").slice(0, 32)}`;
2717
+ }
2718
+ /**
2719
+ * The sync entries a caller imports through: this board's own sync's, or what a sync elsewhere
2720
+ * registered, but for a key this board's own sync has since taken: its platform locations are
2721
+ * that sync's alone.
2722
+ */
2723
+ async function entriesOf(caller, principal) {
2724
+ if (ownSync(caller)) return binding.sync;
2725
+ return ((await registry.state()).byOwner[principal]?.sync ?? []).filter((entry) => !binding.sync.some((own) => own.key === entry.key));
2726
+ }
2727
+ /**
2728
+ * The sync entries this board links accounts on, by key, each with the URL where its sync runs,
2729
+ * once that's known: its own sync's, then those of the syncs registered here that may still sync.
2730
+ * A key its own sync has is its own. A key two registered syncs claim is neither's to sign people
2731
+ * in for: a person links it by posting the code where either syncs.
2732
+ */
2733
+ async function linkable() {
2734
+ const found = /* @__PURE__ */ new Map();
2735
+ for (const entry of binding.sync) found.set(entry.key, {
2736
+ ...entry,
2737
+ url: binding.url()
2738
+ });
2739
+ const claimed = /* @__PURE__ */ new Map();
2740
+ for (const registration of Object.values((await registry.state()).byOwner)) {
2741
+ if (registration.sync.length === 0) continue;
2742
+ if (!(await binding.accessNow(registration.principal))?.anywhere("sync")) continue;
2743
+ for (const entry of registration.sync) if (!found.has(entry.key)) (claimed.get(entry.key) ?? claimed.set(entry.key, []).get(entry.key)).push({
2744
+ ...entry,
2745
+ url: registration.url
2746
+ });
2747
+ }
2748
+ for (const [key, claims] of claimed) found.set(key, claims.length === 1 ? claims[0] : {
2749
+ ...claims[0],
2750
+ signIn: false,
2751
+ url: null
2752
+ });
2753
+ return [...found.values()];
2754
+ }
2755
+ /**
2756
+ * Whether a caller may write as, or link, `author`: a platform user, never anyone this board
2757
+ * signs in, on a platform it syncs: the one of entry `from`, when it names one.
2758
+ */
2759
+ async function speaksFor(caller, principal, author, from) {
2760
+ if (authenticates(author.id) || await signedIn(author.id)) throw forbidden(`${author.id} is someone this board signs in, not a platform user: sync can't write as them`);
2761
+ const entries = await entriesOf(caller, principal);
2762
+ const platforms = (from === null ? entries : entries.filter((entry) => entry.key === from)).map((entry) => entry.platform);
2763
+ if (from !== null && platforms.length === 0) throw forbidden(`this token doesn't sync ${from} here: its board registers its sync entries`);
2764
+ if (!platforms.some((platform) => author.id.startsWith(`${platform}:`))) throw forbidden(`${author.id} isn't a user of ${platforms.length ? platforms.join(" or ") : "a platform this token syncs"}`);
2765
+ }
2766
+ /**
2767
+ * Whether `id` is someone this board signs in that `authenticates` can't tell by its shape: a
2768
+ * host's principal, which may take any ID and kind, and their agents (`<principal>+<profile>`).
2769
+ * It is once it has registered (its watchers write as `<principal>+<id>`, which never wakes
2770
+ * them), or written: the board records a host's principals and their profiles as the host's,
2771
+ * whatever their kind, and anything else that writes as other than a platform user.
2772
+ */
2773
+ async function signedIn(id) {
2774
+ const plus = id.indexOf("+");
2775
+ const owners = (await registry.state()).byOwner;
2776
+ if (owners[id] || plus > 0 && owners[id.slice(0, plus)]) return true;
2777
+ const { state: actors } = await session.state(actorsReducer);
2778
+ return [id, plus > 0 ? id.slice(0, plus) : null].some((known) => {
2779
+ const actor = known === null ? void 0 : actors.byId[known];
2780
+ return actor !== void 0 && (actor.kind !== "external" || actor.host === true);
2781
+ });
2782
+ }
2783
+ /**
2784
+ * Refuses a caller narrowed with `x-board-scope` (a sandboxed agent, say): its principal's
2785
+ * watchers are one registration, which its project registers and runs with the token it
2786
+ * narrowed. A narrowed pull would take matches from it, and a narrowed register replace it.
2787
+ */
2788
+ function ownWatchers(principal) {
2789
+ if (principal.scope !== void 0) throw forbidden("this token is narrowed with x-board-scope: registering and running watchers is its project's job");
2790
+ }
2791
+ /**
2792
+ * Whether a sync's writes keep the board's record of the platform users they're by (name and
2793
+ * avatar), which every channel shows: this board's own sync's, and those of one that may sync in every
2794
+ * channel. Anyone else's still carry their author, and leave the record as it is.
2795
+ */
2796
+ function recordsActors(caller, principal) {
2797
+ return ownSync(caller) || principal.access.everywhere("sync");
2798
+ }
2799
+ /**
2800
+ * The caller, when it may sync somewhere: what every sync route needs. `records`: what it does
2801
+ * keeps sync's records (imports, receipts, its inbox, links), which only an integration does,
2802
+ * never a caller narrowed with `x-board-scope`.
2803
+ */
2804
+ async function needSync(caller, what, records = false) {
2805
+ const identity = await identityOf(caller);
2806
+ if (!identity.principal.access.anywhere("sync")) throw forbidden(`this token can't ${what}: it needs sync`);
2807
+ if (records) unnarrowed(identity.principal, what);
2808
+ return identity;
2809
+ }
2810
+ /**
2811
+ * Refuses a caller narrowed with `x-board-scope` what its principal's sync does: a platform
2812
+ * thread or message it claimed for a channel in its scope would bring that sync's later events
2813
+ * there, from wherever on the platform they are.
2814
+ */
2815
+ function unnarrowed(principal, what) {
2816
+ if (principal.scope !== void 0) throw forbidden(`this token is narrowed with x-board-scope, so it can't ${what}: that's its project's sync's job`);
2817
+ }
2818
+ /**
2819
+ * Refuses a platform origin an import can't have. Sync's records map each platform thread and
2820
+ * message to one board thread and message, board-wide, so taking one over would send what the
2821
+ * platform says there to another channel: a new root's platform thread must be no board
2822
+ * thread's yet, a reply's must be its own thread's, and its platform message no board message's.
2823
+ * Its platform locations are under the sync key it came through.
2824
+ */
2825
+ async function claimable(origin, from, thread) {
2826
+ if (from === null) throw badRequest("an import with an origin names the sync key it came through: pass `from`");
2827
+ for (const location of [
2828
+ origin.channel,
2829
+ origin.thread,
2830
+ origin.subthread
2831
+ ]) if (location !== void 0 && location !== null && !location.startsWith(`${from}:`)) throw badRequest(`an import's platform locations belong to its sync key: ${location} isn't ${from}'s`);
2832
+ if (origin.subthread != null && !origin.subthread.startsWith(`${origin.thread}:`)) throw badRequest(`an import's subthread is in its thread: ${origin.subthread} isn't in ${origin.thread}`);
2833
+ await reads.ensure();
2834
+ const owner = await store.tables.threadOf(origin.thread);
2835
+ if (owner !== null && owner !== thread) throw new HttpError(409, "already_synced", `${origin.thread} is already synced with another thread`);
2836
+ if (owner === null && thread !== null) throw new HttpError(409, "not_synced", `${origin.thread} isn't synced with this thread`);
2837
+ const exact = origin.subthread ?? origin.thread;
2838
+ if (await store.tables.messageOf(exact, origin.message) !== null) throw new HttpError(409, "already_synced", `${origin.message} in ${exact} is already on the board`);
2839
+ }
2840
+ /**
2841
+ * Refuses a delivery receipt a caller's sync can't have made: one for a target of another sync's,
2842
+ * or of none of its message's thread's, or one that says its message landed as a platform thread
2843
+ * or message another board thread or message syncs with. Receipts map those board-wide.
2844
+ */
2845
+ async function deliverable(caller, principal, message, target, external) {
2846
+ const key = target.slice(0, target.indexOf(":"));
2847
+ if (!(await entriesOf(caller, principal)).some((entry) => entry.key === key)) throw forbidden(`this token doesn't sync ${key} here: its board registers its sync entries`);
2848
+ await reads.ensure();
2849
+ if (!(await store.tables.syncOf(message.thread))?.targets.includes(target)) throw badRequest(`#${message.id}'s thread doesn't sync with ${target}`, "not_a_target");
2850
+ if (!external) return;
2851
+ if (!external.thread.startsWith(`${key}:`)) throw badRequest(`a delivery to ${target} lands under ${key}, not in ${external.thread}`);
2852
+ const owner = await store.tables.threadOf(external.thread);
2853
+ if (owner !== null && owner !== message.thread) throw new HttpError(409, "already_synced", `${external.thread} is already synced with another thread`);
2854
+ const copy = await store.tables.messageOf(external.thread, external.message);
2855
+ if (copy !== null && copy !== message.id) throw new HttpError(409, "already_synced", `${external.message} in ${external.thread} is another board message's`);
2856
+ }
2857
+ /** The board message an import changes: a platform message, or the root of the thread a platform thread belongs to. */
2858
+ async function locate(origin) {
2859
+ await reads.ensure();
2860
+ if (origin.message !== void 0) return store.tables.messageOf(origin.thread, origin.message);
2861
+ const thread = await store.tables.threadOf(origin.thread);
2862
+ return thread === null ? null : (await store.tables.syncOf(thread))?.root ?? null;
2863
+ }
2864
+ /**
2865
+ * An import's links, as the board keeps them. One names a board message only if the importer
2866
+ * may read it, and then says what the board's own copy says, never what the import does: its
2867
+ * readers see that snapshot as the message's. Any other keeps its URL, and the platform's
2868
+ * snapshot if it came with one.
2869
+ */
2870
+ async function linksOf(principal, links) {
2871
+ if (!links) return void 0;
2872
+ const { state: actors } = await session.state(actorsReducer);
2873
+ return Promise.all(links.map(async ({ message: ref, ...link }) => {
2874
+ const id = ref === null ? null : indexOf(ref);
2875
+ const found = id === null || id === 0 ? null : await reads.message(id);
2876
+ if (!found || found.deleted || !principal.access.readable(found.channel)) return {
2877
+ ...link,
2878
+ message: null
2879
+ };
2880
+ const snapshot = {
2881
+ author: actors.byId[found.by.actor]?.name ?? found.by.actor,
2882
+ text: found.text,
2883
+ createdAt: found.createdAt,
2884
+ files: found.attachments.map((file) => ({
2885
+ name: file.name,
2886
+ url: null
2887
+ }))
2888
+ };
2889
+ return {
2890
+ url: link.url,
2891
+ message: found.id,
2892
+ snapshot,
2893
+ unavailable: null
2894
+ };
2895
+ }));
2896
+ }
2897
+ /**
2898
+ * A receipt. This board's own sync, while it delivers here, appends it as its consumer, keyed by
2899
+ * `key`: a retry after a crash lands it once, and an attempt that was taken over can't. Anyone
2900
+ * else's lands once by `key`.
2901
+ */
2902
+ async function receipt(caller, key, events) {
2903
+ const ctx = delivering.getStore();
2904
+ if (ctx && ownSync(caller)) {
2905
+ await ctx.session.append(key, ...events);
2906
+ return;
2907
+ }
2908
+ for (const [i, event] of events.entries()) await session.append({
2909
+ ...event,
2910
+ id: await idempotentId("board-receipt", key, String(i))
2911
+ }).catch((error) => {
2912
+ if (!duplicate(error)) throw error;
2913
+ });
2914
+ }
2915
+ /** Records that the inbox for `channel` exists, the first time anything lands in it. */
2916
+ async function openInbox(channel) {
2917
+ if (opened.has(channel)) return;
2918
+ if (!(await inboxes.state(inboxesReducer)).state[channel]) await inboxes.append({
2919
+ type: "inbox.opened",
2920
+ id: await idempotentId("board-inbox", "opened", channel),
2921
+ payload: { channel }
2922
+ });
2923
+ opened.add(channel);
2924
+ }
2925
+ /** Records that platform user `identity` is `to`, unless it already is. `id` makes a retry a no-op. */
2926
+ async function link(identity, to, method, id) {
2927
+ const { actor: account, author: by } = identity;
2928
+ const { state: actors } = await session.state(actorsReducer);
2929
+ const seen = identity.records === false ? null : actorSeen(account, actors);
2930
+ const existing = actors.links[account.id];
2931
+ if (existing?.to === to && !id) {
2932
+ if (seen) await session.append(seen);
2933
+ return {
2934
+ actor: account.id,
2935
+ handle: account.handle,
2936
+ name: account.name,
2937
+ method: existing.method,
2938
+ at: existing.at
2939
+ };
2940
+ }
2941
+ await session.append(...seen ? [{
2942
+ ...seen,
2943
+ ...id ? { id: `${id}:seen` } : {}
2944
+ }] : [], {
2945
+ type: "actor.linked",
2946
+ ...id ? { id } : {},
2947
+ payload: {
2948
+ actor: account.id,
2949
+ to,
2950
+ method,
2951
+ by
2952
+ }
2953
+ });
2954
+ return {
2955
+ actor: account.id,
2956
+ handle: account.handle,
2957
+ name: account.name,
2958
+ method,
2959
+ at: (/* @__PURE__ */ new Date()).toISOString()
2960
+ };
2961
+ }
2962
+ const servers = [
2963
+ server,
2964
+ inboxServer,
2965
+ schedules.server,
2966
+ registry.server,
2967
+ watchers.server
2968
+ ];
2969
+ return {
2970
+ methods,
2971
+ schedules,
2972
+ /** The scheduler's callback, for every A2 server behind the board. */
2973
+ scheduler: options.scheduler ? options.scheduler.handler(...servers) : async (_request) => toErrorResponse(notFound("this board has no scheduler")),
2974
+ servers,
2975
+ session,
2976
+ store
2977
+ };
2978
+ }
2979
+ /**
2980
+ * The board in this deployment, on A2's log: its store (SQLite at `.a2/dev.db` by default), and
2981
+ * optionally a scheduler and somewhere for files. It's the backend itself: pass it to
2982
+ * `board({ backend })`, and mount its `scheduler` in a route of its own. It trusts the callers
2983
+ * board() verified; what it does outside requests, it asks board()'s policy about and writes
2984
+ * through board(). Nothing is built or connected until board() binds it, and the default store
2985
+ * opens on the first call.
2986
+ */
2987
+ function a2(options = {}) {
2988
+ let binding = null;
2989
+ let built = null;
2990
+ let opening = null;
2991
+ /** What's built, for what can't wait for the default store to open. */
2992
+ const now = () => {
2993
+ if (built) return built;
2994
+ if (!binding) throw new Error("a2: this backend isn't serving a board yet: pass it to board({ backend })");
2995
+ throw new Error("a2: its default store opens on the board's first request: pass a store to reach it before that");
2996
+ };
2997
+ const ready = async () => {
2998
+ if (!built) {
2999
+ if (!binding) throw new Error("a2: this backend isn't serving a board yet: pass it to board({ backend })");
3000
+ const bound = binding;
3001
+ opening ??= defaultStore().then((store) => built ??= build({
3002
+ ...options,
3003
+ store
3004
+ }, bound));
3005
+ opening.catch(() => opening = null);
3006
+ await opening;
3007
+ }
3008
+ built.schedules.catchUp();
3009
+ return built;
3010
+ };
3011
+ const backend = {
3012
+ ...Object.fromEntries(METHODS.map((name) => [name, async (caller, input, signal) => (await ready()).methods[name](caller, input, signal)])),
3013
+ routes: [],
3014
+ keeps: true,
3015
+ bind(given) {
3016
+ if (binding) throw new Error("a2: this backend already serves a board: create one a2() per board()");
3017
+ binding = given;
3018
+ if (options.store) built = build({
3019
+ ...options,
3020
+ store: options.store
3021
+ }, given);
3022
+ },
3023
+ /** The scheduler's callback: `export { scheduler as POST }` from a route of its own. A board without a scheduler answers 404. */
3024
+ scheduler: async (request) => (await ready()).scheduler(request),
3025
+ /** The A2 servers behind this board, to share one scheduler with other A2 servers. */
3026
+ get servers() {
3027
+ return now().servers;
3028
+ },
3029
+ /** For tests and tools. */
3030
+ get session() {
3031
+ return now().session;
3032
+ },
3033
+ get store() {
3034
+ return now().store;
3035
+ }
3036
+ };
3037
+ for (const key of [
3038
+ "servers",
3039
+ "session",
3040
+ "store"
3041
+ ]) Object.defineProperty(backend, key, { enumerable: false });
3042
+ return backend;
3043
+ }
3044
+ //#endregion
3045
+ export { fsBlob as n, memoryBlob as r, a2 as t };