@vercel/board 0.0.0 → 0.16.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.
- package/README.md +180 -0
- package/dist/a2-C41hD8SF.mjs +3045 -0
- package/dist/a2-D24cvwpN.d.mts +3359 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +30 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-DjMeZpnU.mjs +224 -0
- package/dist/attempts-Bxp9KgPf.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-CU56Q2Xh.d.mts +204 -0
- package/dist/backend-a2/blob-vercel.d.mts +8 -0
- package/dist/backend-a2/blob-vercel.mjs +81 -0
- package/dist/backend-a2/contract.d.mts +18 -0
- package/dist/backend-a2/contract.mjs +5 -0
- package/dist/backend-a2/scheduler-local.d.mts +11 -0
- package/dist/backend-a2/scheduler-local.mjs +2 -0
- package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
- package/dist/backend-a2/scheduler-qstash.mjs +2 -0
- package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
- package/dist/backend-a2/scheduler-vercel.mjs +2 -0
- package/dist/backend-a2/store-memory.d.mts +11 -0
- package/dist/backend-a2/store-memory.mjs +19 -0
- package/dist/backend-a2/store-postgres.d.mts +38 -0
- package/dist/backend-a2/store-postgres.mjs +110 -0
- package/dist/backend-a2/store-redis-http.d.mts +17 -0
- package/dist/backend-a2/store-redis-http.mjs +32 -0
- package/dist/backend-a2/store-redis.d.mts +23 -0
- package/dist/backend-a2/store-redis.mjs +39 -0
- package/dist/backend-a2/store-sqlite.d.mts +12 -0
- package/dist/backend-a2/store-sqlite.mjs +31 -0
- package/dist/backend-a2.d.mts +4 -0
- package/dist/backend-a2.mjs +2 -0
- package/dist/backend-http-0roEmjl0.mjs +613 -0
- package/dist/backend-http-BVRno-ca.mjs +469 -0
- package/dist/backend-http-Bid7RIpC.d.mts +630 -0
- package/dist/backend-http.d.mts +10 -0
- package/dist/backend-http.mjs +2 -0
- package/dist/backend-http.node.d.mts +10 -0
- package/dist/backend-http.node.mjs +2 -0
- package/dist/blob-C0J3wbGv.d.mts +39 -0
- package/dist/bots-CGkO76qH.mjs +67 -0
- package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
- package/dist/chat-provider-jFSeyNmB.mjs +389 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2252 -0
- package/dist/client-DGDPaN7U.mjs +380 -0
- package/dist/conformance-DRR23YNy.mjs +512 -0
- package/dist/conformance.d.mts +30 -0
- package/dist/conformance.mjs +2 -0
- package/dist/contract-CuJmlrhw.mjs +566 -0
- package/dist/emoji-CTLpvH-A.mjs +445 -0
- package/dist/engine-Bir5s8ts.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-DLiE7fLY.d.mts +4 -0
- package/dist/exports-Dvc1o52I.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-Bkg77Pxn.mjs +12 -0
- package/dist/index.mjs +19 -0
- package/dist/index.node.d.mts +14 -0
- package/dist/index.node.mjs +5 -0
- package/dist/inspect/app.js +416 -0
- package/dist/inspect/engine.js +5 -0
- package/dist/local-Cr_dSjvs.mjs +44 -0
- package/dist/own-OwgPWdmh.mjs +15 -0
- package/dist/project-CFiArraX.mjs +124 -0
- package/dist/provider-BGjz-mDa.d.mts +2473 -0
- package/dist/provider-CuewcDNZ.mjs +205 -0
- package/dist/redis-tables-B35vv4CZ.mjs +842 -0
- package/dist/reducers-B_vUGQRp.d.mts +1172 -0
- package/dist/reducers-mUPFuFJV.mjs +293 -0
- package/dist/schemas-Be08nyfW.mjs +74 -0
- package/dist/schemas-CWxs3ECJ.d.mts +50 -0
- package/dist/serve-BsOOc-6_.mjs +170 -0
- package/dist/server-jwPY7_wi.mjs +17 -0
- package/dist/serving-BNbzc96L.mjs +2 -0
- package/dist/sql-DbiEnqM0.mjs +571 -0
- package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
- package/dist/sync-github.d.mts +473 -0
- package/dist/sync-github.mjs +603 -0
- package/dist/sync-kapso.d.mts +175 -0
- package/dist/sync-kapso.mjs +86 -0
- package/dist/sync-slack.d.mts +364 -0
- package/dist/sync-slack.mjs +728 -0
- package/dist/sync.chat.d.mts +2 -0
- package/dist/sync.chat.mjs +2 -0
- package/dist/sync.d.mts +3 -0
- package/dist/sync.mjs +4 -0
- package/dist/targets-yM5JBUgr.mjs +94 -0
- package/dist/tokens-DKLn5P9B.mjs +256 -0
- package/dist/types-BHCjTZdB.d.mts +104 -0
- package/dist/users-Dls7_ANV.mjs +42 -0
- package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
- package/dist/watchers-hash-CLjsa6od.mjs +225 -0
- package/docs/01-board/01-mounting.md +506 -0
- package/docs/01-board/02-database.md +258 -0
- package/docs/01-board/03-identity.md +264 -0
- package/docs/01-board/04-messages.md +192 -0
- package/docs/01-board/05-reading.md +177 -0
- package/docs/01-board/06-watchers.md +249 -0
- package/docs/01-board/07-schedules.md +133 -0
- package/docs/01-board/08-middleware.md +139 -0
- package/docs/01-board/09-access.md +501 -0
- package/docs/01-quickstart.md +196 -0
- package/docs/02-sync/01-overview.md +290 -0
- package/docs/02-sync/02-callbacks.md +265 -0
- package/docs/02-sync/03-slack.md +248 -0
- package/docs/02-sync/04-github.md +201 -0
- package/docs/02-sync/05-whatsapp.md +148 -0
- package/docs/02-sync/06-providers.md +228 -0
- package/docs/03-guides/01-coding-agents.md +226 -0
- package/docs/03-guides/02-inspector.md +104 -0
- package/docs/03-guides/03-ci.md +251 -0
- package/docs/03-guides/04-triage.md +233 -0
- package/docs/03-guides/05-agent-factory.md +142 -0
- package/docs/04-reference/01-cli.md +606 -0
- package/docs/04-reference/02-board.md +579 -0
- package/docs/04-reference/03-http.md +489 -0
- package/docs/04-reference/04-log.md +250 -0
- package/docs/04-reference/05-conformance.md +97 -0
- package/docs/index.md +186 -0
- package/package.json +168 -1
- package/sql/postgres/board.sql +145 -0
- package/sql/postgres/notifications.sql +24 -0
- 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-CLjsa6od.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-DjMeZpnU.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-yM5JBUgr.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-Bxp9KgPf.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 };
|