@vercel/board 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -17
- package/dist/{a2-HUbQJ0RD.mjs → a2-DngWY2Eb.mjs} +741 -415
- package/dist/access-CY5xVMIK.mjs +242 -0
- package/dist/{access-CV96SYgr.d.mts → access-DPcrKIH_.d.mts} +6 -1
- package/dist/access.d.mts +4 -4
- package/dist/access.mjs +5 -4
- package/dist/{actors-DcJhFbwJ.mjs → actors-Bm-ojWWT.mjs} +2 -2
- package/dist/{auth-JO22iIwr.mjs → auth-CyO2aw5n.mjs} +126 -236
- package/dist/{auth-CU56Q2Xh.d.mts → auth-DLYSt_-A.d.mts} +32 -4
- package/dist/{backend-CPC_vyOo.d.mts → backend-Bob-5CM7.d.mts} +93 -42
- package/dist/backend-a2/blob-vercel.d.mts +1 -1
- package/dist/backend-a2/contract.d.mts +38 -98
- package/dist/backend-a2/contract.mjs +1 -1
- package/dist/backend-a2/scheduler-local.d.mts +1 -1
- package/dist/backend-a2/scheduler-local.mjs +1 -1
- package/dist/backend-a2/scheduler-qstash.d.mts +3 -3
- package/dist/backend-a2/scheduler-qstash.mjs +8 -8
- package/dist/backend-a2/scheduler-vercel.d.mts +3 -3
- package/dist/backend-a2/scheduler-vercel.mjs +6 -6
- package/dist/backend-a2/store-memory.d.mts +2 -2
- package/dist/backend-a2/store-memory.mjs +3 -3
- package/dist/backend-a2/store-postgres.d.mts +1 -1
- package/dist/backend-a2/store-postgres.mjs +34 -15
- package/dist/backend-a2/store-redis-http.d.mts +1 -1
- package/dist/backend-a2/store-redis-http.mjs +6 -6
- package/dist/backend-a2/store-redis.d.mts +2 -2
- package/dist/backend-a2/store-redis.mjs +5 -5
- package/dist/backend-a2/store-sqlite.d.mts +1 -1
- package/dist/backend-a2/store-sqlite.mjs +3 -3
- package/dist/backend-a2.d.mts +143 -242
- package/dist/backend-a2.mjs +2 -2
- package/dist/{backend-http-CqfKhrnn.d.mts → backend-http-BRrRbjRx.d.mts} +59 -45
- package/dist/{backend-http-CzIJMm58.mjs → backend-http-CvUKOn-m.mjs} +11 -10
- package/dist/{backend-http-Bmp0IgD3.mjs → backend-http-D8mUzuKQ.mjs} +2 -2
- package/dist/backend-http.d.mts +1 -1
- package/dist/backend-http.mjs +1 -1
- package/dist/backend-http.node.d.mts +1 -1
- package/dist/backend-http.node.mjs +1 -1
- package/dist/{backend-vercel-DQc_TaHp.mjs → backend-vercel-Ba8JDtqW.mjs} +3 -3
- package/dist/{backend-vercel-4tvcPKqX.d.mts → backend-vercel-BaSEUdwg.d.mts} +62 -47
- package/dist/{backend-vercel-BNauAs7w.mjs → backend-vercel-CmY9yZ9m.mjs} +279 -42
- package/dist/backend-vercel.d.mts +1 -1
- package/dist/backend-vercel.mjs +1 -1
- package/dist/backend-vercel.node.d.mts +1 -1
- package/dist/backend-vercel.node.mjs +1 -1
- package/dist/{blob-DmiQjrYy.d.mts → blob-Bx8QPWEd.d.mts} +2 -2
- package/dist/{blob-DdbpJqN1.mjs → blob-WbB2DJKn.mjs} +30 -11
- package/dist/{chat-provider-BWHbChcQ.d.mts → chat-provider-BChYrrA7.d.mts} +32 -28
- package/dist/{chat-provider-COtsU5jB.mjs → chat-provider-DkFp2hrh.mjs} +105 -60
- package/dist/cli-commands.d.mts +1 -1
- package/dist/cli-commands.mjs +1 -1
- package/dist/cli-embedded.d.mts +2 -2
- package/dist/cli-embedded.mjs +1 -1
- package/dist/cli.mjs +4 -4
- package/dist/{client-BqAjk0Q4.d.mts → client-COfYrgfV.d.mts} +240 -227
- package/dist/{commands-D6gvrOh3.d.mts → commands-DK4W3HCJ.d.mts} +47 -10
- package/dist/{commands-C3R1AWgm.mjs → commands-L1JSGz2Z.mjs} +212 -54
- package/dist/conformance-DK43HiHf.mjs +1000 -0
- package/dist/conformance.d.mts +8 -2
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-DQxXk7dX.mjs → engine-DurYIRt8.mjs} +29 -210
- package/dist/{errors-DDktVeUa.d.mts → errors-2vGs49Db.d.mts} +1 -1
- package/dist/{client-D0Px7KzF.mjs → errors-CJXhhzvT.mjs} +132 -27
- package/dist/{provider-BaVmi4Hi.mjs → events-lujPA3gQ.mjs} +80 -17
- package/dist/{exports-BG-0Wyn-.mjs → exports-CWIdN0Ub.mjs} +195 -103
- package/dist/exports-FBOQWT7O.d.mts +5 -0
- package/dist/{folds-BDMSxBUf.mjs → folds-Cu9Q9zyQ.mjs} +3 -22
- package/dist/{folds-ZkHM4M-v.d.mts → folds-DLUzfibJ.d.mts} +15 -40
- package/dist/{index-D3VvNfh_.d.mts → index-Ho-ayyQL.d.mts} +2 -2
- package/dist/index.d.mts +7 -7
- package/dist/{index.default-rB8sJ9RX.mjs → index.default-BUcwDaAe.mjs} +1 -1
- package/dist/index.mjs +5 -6
- package/dist/index.node.d.mts +7 -7
- package/dist/index.node.mjs +3 -4
- package/dist/inspect/app.js +62 -61
- package/dist/inspect/engine.js +3 -3
- package/dist/{internal-CvsrprnU-CRY89I-b.mjs → internal-CvsrprnU-zZMpDiRY.mjs} +1 -1
- package/dist/{local-DhkrAqPP.mjs → local-C76Fy5ad.mjs} +1 -1
- package/dist/{main-CVBqe3VO.mjs → main-Dz-8komG.mjs} +251 -127
- package/dist/{middleware-CXjEeef4.mjs → middleware-BavYSDUa.mjs} +1 -1
- package/dist/{node-Zd_C-gxP.mjs → node-BgeGcwX0.mjs} +7 -3
- package/dist/{platform-B4TnJtWu-CwcTyKg2.mjs → platform-B4TnJtWu-Do3agKr5.mjs} +1 -1
- package/dist/{project-CBXlLftU.mjs → project-CQ4VUibA.mjs} +1 -1
- package/dist/{provider-DmSTp9lI.d.mts → provider-gynDUoyW.d.mts} +13 -43
- package/dist/{redis-tables-BJQVUspu.mjs → redis-tables-DHf85ixI.mjs} +40 -42
- package/dist/{reducer-CjmvZSAb-D_OSISmj.mjs → reducer-Bswr11ar-P324o9vn.mjs} +2 -2
- package/dist/{reducer-CYPHsSmd-BQIfozGi.d.mts → reducer-CYPHsSmd-BXjGiptm.d.mts} +1 -1
- package/dist/{reducers-DJzVpXwM.mjs → reducers-DcENwtwZ.mjs} +74 -77
- package/dist/{retryable-lazy-DZWmHpii-Bye-W8q7.mjs → retryable-lazy-DZWmHpii-DpTc5PIP.mjs} +1 -1
- package/dist/{scheduler-task-X1SToDjP-BVoWuInI.mjs → scheduler-task-X1SToDjP-D2Il2rln.mjs} +1 -1
- package/dist/{serve-G9RuaDSr.mjs → serve-CTmow-YH.mjs} +7 -7
- package/dist/{server-qgez0Xny-D5mLfqFQ.d.mts → server-B9gu0qi1-DI1WvcVK.d.mts} +10 -4
- package/dist/{server-BixV_byP.mjs → server-DG8D6VV0.mjs} +4 -4
- package/dist/{server-DtFrWxi3-Boa6gdWO.mjs → server-DX7mM6nm-D7N_7bug.mjs} +32 -23
- package/dist/{serving-B75Y4BF-.mjs → serving-DBFRo6U_.mjs} +1 -1
- package/dist/{session-clear-wHd5GCpZ-BnKSxReK.mjs → session-clear-XJYpmUpg-2q8l_FJR.mjs} +14 -2
- package/dist/{sql-D4Z_efJX.mjs → sql-CT5xVuGr.mjs} +2 -8
- package/dist/{sqlite-db-Bz2JkfCU.mjs → sqlite-db-0WeSOEZN.mjs} +1 -1
- package/dist/{store-D0MhBvg6-4Z2L0Kqd.d.mts → store-DEzcHZOA-B-5vhxef.d.mts} +12 -2
- package/dist/store-N8PXxDAS-DWWgv-Vz.mjs +5 -0
- package/dist/{store-codec-DwrWlgQx-CYxGW0aD.mjs → store-codec-pAG2oPgQ-SqDXlPBR.mjs} +4 -4
- package/dist/store-memory-4IBl0Ztu.mjs +2 -0
- package/dist/{store-memory-Bas9Uq5C.mjs → store-memory-B0PkVSuQ.mjs} +14 -7
- package/dist/{store-polling-BZt2Rf3D-BiEM6_HB.mjs → store-polling-BZt2Rf3D-Div-bJ_D.mjs} +2 -2
- package/dist/{store-presence-polling-JuAy2Ck2-CYpuK75X.mjs → store-presence-polling-JuAy2Ck2-Dh-g_KYM.mjs} +3 -3
- package/dist/{store-sqlite-CxxPCcCT.mjs → store-sqlite-D85t3tE7.mjs} +14 -7
- package/dist/store-sqlite-iBTYYKBj.mjs +2 -0
- package/dist/{stream-buffer-DrTyiIuo-BKy-vI14.mjs → stream-buffer-DrTyiIuo-Be10yOBR.mjs} +2 -2
- package/dist/{sync-kapso.d.mts → sync-chat-sdk.d.mts} +33 -37
- package/dist/sync-chat-sdk.mjs +44 -0
- package/dist/sync-github.d.mts +5 -18
- package/dist/sync-github.mjs +4 -70
- package/dist/sync-linear.d.mts +3 -3
- package/dist/sync-linear.mjs +6 -5
- package/dist/sync-slack.d.mts +7 -7
- package/dist/sync-slack.mjs +33 -54
- package/dist/sync.d.mts +3 -3
- package/dist/sync.mjs +2 -4
- package/dist/{tokens-CC3izSNr.mjs → tokens-Dnyd_y1n.mjs} +15 -6
- package/dist/{types-No0WdhE5.d.mts → types-CmrFjpml.d.mts} +2 -4
- package/dist/{watchers-hash-BDhLVkV5.mjs → watchers-hash-BjwlnGD9.mjs} +124 -115
- package/docs/01-board/01-mounting.md +2 -2
- package/docs/01-board/03-identity.md +5 -6
- package/docs/01-board/04-messages.md +96 -2
- package/docs/01-board/05-reading.md +2 -1
- package/docs/01-board/06-watchers.md +8 -6
- package/docs/01-board/08-middleware.md +6 -3
- package/docs/01-board/09-access.md +145 -14
- package/docs/02-sync/01-overview.md +19 -26
- package/docs/02-sync/02-functions.md +8 -9
- package/docs/02-sync/03-slack.md +5 -5
- package/docs/02-sync/04-github.md +2 -2
- package/docs/02-sync/06-providers.md +91 -62
- package/docs/03-guides/01-coding-agents.md +1 -1
- package/docs/03-guides/03-ci.md +7 -4
- package/docs/04-reference/01-cli.md +109 -51
- package/docs/04-reference/02-board.md +76 -28
- package/docs/04-reference/03-http.md +76 -38
- package/docs/04-reference/04-log.md +8 -11
- package/docs/04-reference/05-conformance.md +47 -7
- package/docs/index.md +2 -2
- package/package.json +7 -10
- package/sql/postgres/board.sql +1 -1
- package/sql/postgres/schema.sql +1 -1
- package/dist/conformance-DlnfuzEB.mjs +0 -556
- package/dist/errors-CusN4x2l.mjs +0 -88
- package/dist/events-qf5XG5VQ.mjs +0 -75
- package/dist/exports-mhAYiU4i.d.mts +0 -5
- package/dist/store-N8PXxDAS-BWBgzBHp.mjs +0 -5
- package/dist/store-memory-ZLiZbH3R.mjs +0 -2
- package/dist/store-sqlite-BmwL3pUR.mjs +0 -2
- package/dist/sync-kapso.mjs +0 -89
- package/dist/sync.chat.d.mts +0 -2
- package/dist/sync.chat.mjs +0 -2
- package/dist/users-yryHKFMo.mjs +0 -42
- package/docs/02-sync/05-whatsapp.md +0 -152
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
import { r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
|
|
2
|
-
import { _ as RemoteWatcherId, b as actorKind, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
|
|
2
|
+
import { _ as RemoteWatcherId, b as actorKind, v as SCOPE_MAX, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
|
|
3
3
|
import "./external-D3p5T57R.mjs";
|
|
4
|
-
import { a as METHODS, d as VERSION, f as SYNC, n as compileWatcher, r as hasCriteria, t as canonicalWatchers } from "./watchers-hash-
|
|
5
|
-
import {
|
|
4
|
+
import { a as METHODS, d as VERSION, f as SYNC, n as compileWatcher, r as hasCriteria, t as canonicalWatchers } from "./watchers-hash-BjwlnGD9.mjs";
|
|
5
|
+
import { a as forbidden, c as toErrorResponse, f as duration, i as duplicateId, l as unauthorized, r as badRequest, s as notFound, t as HttpError, u as BoardError } from "./errors-CJXhhzvT.mjs";
|
|
6
6
|
import { i as isKeyIdOf, r as derivedId, t as waitUntil } from "./wait-until-Dw4XrIDQ.mjs";
|
|
7
|
-
import { a as
|
|
7
|
+
import { a as scopeOf, i as parseScope, n as authorize, r as compileAccess, t as FULL_ACCESS } from "./access-CY5xVMIK.mjs";
|
|
8
8
|
import { t as own } from "./own-OwgPWdmh.mjs";
|
|
9
9
|
import { n as replaceMentions } from "./mentions-CB6ShY4R.mjs";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { n as MIDDLEWARE_ACTOR, t as DroppedError } from "./middleware-
|
|
10
|
+
import { a as resolveMentions, n as actorSeen } from "./actors-Bm-ojWWT.mjs";
|
|
11
|
+
import { C as wroteAs, f as grantIssuers, n as DEFAULT_CAN, o as authenticates, s as checkGrant, t as BOARD_TOKEN_PREFIX, y as tokenHash } from "./auth-CyO2aw5n.mjs";
|
|
12
|
+
import { n as MIDDLEWARE_ACTOR, t as DroppedError } from "./middleware-BavYSDUa.mjs";
|
|
13
13
|
import { n as syncTarget, r as withEmailMentions } from "./targets-Cgjs29T0.mjs";
|
|
14
|
-
import { t as A2Error } from "./session-clear-
|
|
15
|
-
import { a as reducer } from "./reducer-
|
|
16
|
-
import {
|
|
17
|
-
import { n as createServer, t as consumer } from "./server-
|
|
18
|
-
import {
|
|
19
|
-
import { c as
|
|
20
|
-
import { t as concernedMessageId } from "./project-
|
|
21
|
-
import { t as TABLES_CONSUMER } from "./sql-
|
|
14
|
+
import { t as A2Error } from "./session-clear-XJYpmUpg-2q8l_FJR.mjs";
|
|
15
|
+
import { a as reducer } from "./reducer-Bswr11ar-P324o9vn.mjs";
|
|
16
|
+
import { a as INBOXES_SESSION, c as accessContract, d as inboxSession, f as registryContract, g as BOARD_SESSION, h as watchSession, i as ACCESS_SESSION, l as boardContract, m as watchContract, n as channelsReducer, o as REGISTRY_SESSION, p as schedulesContract, s as SCHEDULES_SESSION, t as actorsReducer, u as inboxContract } from "./reducers-DcENwtwZ.mjs";
|
|
17
|
+
import { n as createServer, t as consumer } from "./server-DX7mM6nm-D7N_7bug.mjs";
|
|
18
|
+
import { i as iso, n as foldMessages, t as foldActors } from "./folds-Cu9Q9zyQ.mjs";
|
|
19
|
+
import { c as ownUpload, d as writeUpload, i as finishUpload, l as readUpload, n as attachmentNotFound, t as attachmentBody, u as startUpload } from "./blob-WbB2DJKn.mjs";
|
|
20
|
+
import { t as concernedMessageId } from "./project-CQ4VUibA.mjs";
|
|
21
|
+
import { t as TABLES_CONSUMER } from "./sql-CT5xVuGr.mjs";
|
|
22
22
|
import { t as afterRetry } from "./attempts-HXcskN1w.mjs";
|
|
23
23
|
import { createHash, randomBytes } from "node:crypto";
|
|
24
24
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
25
25
|
import { lookup } from "node:dns";
|
|
26
26
|
import { request } from "node:https";
|
|
27
27
|
import { BlockList, isIP } from "node:net";
|
|
28
|
-
//#region ../../node_modules/.pnpm/experimental-a2@0.
|
|
28
|
+
//#region ../../node_modules/.pnpm/experimental-a2@0.24.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__b1e2c243a963f2620a5d202817417c26/node_modules/experimental-a2/dist/idempotent-id-BRJVeylj.js
|
|
29
29
|
async function idempotentId(...parts) {
|
|
30
30
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(parts)));
|
|
31
31
|
const bytes = new Uint8Array(digest).slice(0, 16);
|
|
@@ -35,6 +35,350 @@ async function idempotentId(...parts) {
|
|
|
35
35
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
36
36
|
}
|
|
37
37
|
//#endregion
|
|
38
|
+
//#region src/server/trust.ts
|
|
39
|
+
/** How long a token lives when its minter doesn't say, and at most: npm caps its write tokens at 90 days. */
|
|
40
|
+
const TOKEN_DEFAULT_MS = 2592e6;
|
|
41
|
+
/** How deep tokens minted with tokens go: each request walks the chain to its root. */
|
|
42
|
+
const TOKEN_DEPTH_MAX = 8;
|
|
43
|
+
const DAY_MS = 864e5;
|
|
44
|
+
/** What makes two grants one: the issuer, and the claims with their names sorted. */
|
|
45
|
+
const grantKey = (issuer, claims) => JSON.stringify([issuer, Object.keys(claims).sort().map((name) => [name, claims[name]])]);
|
|
46
|
+
function dropToken(state, id) {
|
|
47
|
+
const row = state.tokens[id];
|
|
48
|
+
if (!row) return;
|
|
49
|
+
delete state.hashes[row.hash];
|
|
50
|
+
delete state.tokens[id];
|
|
51
|
+
}
|
|
52
|
+
/** Whether token `id` is token `ancestor`, or was minted with it at any depth. */
|
|
53
|
+
function mintedWith(state, id, ancestor) {
|
|
54
|
+
let at = id;
|
|
55
|
+
for (let depth = 0; at !== null && depth <= TOKEN_DEPTH_MAX; depth++) {
|
|
56
|
+
if (at === ancestor) return true;
|
|
57
|
+
at = state.tokens[at]?.parent ?? null;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether `identity` may see and revoke token `row`: anyone's with `manage` in every channel. A
|
|
63
|
+
* token acts as its minter, so its principal alone would hand it every token its minter has: with
|
|
64
|
+
* a token, it's that token and those minted with it; without one, those its principal minted.
|
|
65
|
+
*/
|
|
66
|
+
function manages(state, { principal }, row) {
|
|
67
|
+
if (principal.access.everywhere("manage")) return true;
|
|
68
|
+
if (row.minter.id !== principal.id) return false;
|
|
69
|
+
if (principal.token === void 0) return true;
|
|
70
|
+
const held = indexOf$1(principal.token, "t");
|
|
71
|
+
return held !== null && mintedWith(state, row.id, held);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The access session's fold. It prunes by the events' own times, so every instance folds the same
|
|
75
|
+
* state, and each event costs a day's expired tokens at most. A child whose parent is gone counts
|
|
76
|
+
* as dead (`liveIn`), and lives no longer than its parent, so pruning never brings one back.
|
|
77
|
+
*/
|
|
78
|
+
function foldAccess(state, event) {
|
|
79
|
+
const at = new Date(event.createdAt).getTime();
|
|
80
|
+
const day = Math.floor(at / DAY_MS);
|
|
81
|
+
if (state.pruned === null) state.pruned = day;
|
|
82
|
+
for (; state.pruned < day; state.pruned++) {
|
|
83
|
+
for (const id of state.expiring[state.pruned] ?? []) dropToken(state, id);
|
|
84
|
+
delete state.expiring[state.pruned];
|
|
85
|
+
}
|
|
86
|
+
switch (event.type) {
|
|
87
|
+
case "grant.set": {
|
|
88
|
+
const { issuer, claims, can, by } = event.payload;
|
|
89
|
+
const key = grantKey(issuer, claims);
|
|
90
|
+
const existing = state.grants[key];
|
|
91
|
+
if (existing) Object.assign(existing, {
|
|
92
|
+
can,
|
|
93
|
+
by,
|
|
94
|
+
at
|
|
95
|
+
});
|
|
96
|
+
else {
|
|
97
|
+
state.grants[key] = {
|
|
98
|
+
id: event.index,
|
|
99
|
+
issuer,
|
|
100
|
+
claims,
|
|
101
|
+
can,
|
|
102
|
+
by,
|
|
103
|
+
at
|
|
104
|
+
};
|
|
105
|
+
state.grantKeys[event.index] = key;
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case "grant.revoked": {
|
|
110
|
+
const key = state.grantKeys[event.payload.id];
|
|
111
|
+
if (key !== void 0) {
|
|
112
|
+
delete state.grants[key];
|
|
113
|
+
delete state.grantKeys[event.payload.id];
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "token.minted": {
|
|
118
|
+
const { payload } = event;
|
|
119
|
+
if (state.hashes[payload.hash] !== void 0) break;
|
|
120
|
+
state.tokens[event.index] = {
|
|
121
|
+
id: event.index,
|
|
122
|
+
...payload,
|
|
123
|
+
minter: payload.minter,
|
|
124
|
+
at
|
|
125
|
+
};
|
|
126
|
+
state.hashes[payload.hash] = event.index;
|
|
127
|
+
(state.expiring[Math.floor(payload.expiresAt / DAY_MS)] ??= []).push(event.index);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case "token.revoked": dropToken(state, event.payload.id);
|
|
131
|
+
}
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
const accessReducer = reducer({
|
|
135
|
+
contract: accessContract,
|
|
136
|
+
name: "board.access.v1",
|
|
137
|
+
initialState: {
|
|
138
|
+
grants: {},
|
|
139
|
+
grantKeys: {},
|
|
140
|
+
tokens: {},
|
|
141
|
+
hashes: {},
|
|
142
|
+
expiring: {},
|
|
143
|
+
pruned: null
|
|
144
|
+
}
|
|
145
|
+
}).fold(foldAccess);
|
|
146
|
+
const grantId = (id) => `g${id}`;
|
|
147
|
+
const tokenId = (id) => `t${id}`;
|
|
148
|
+
/** The index an ID names: `g12` or `t12` for 12. Null for one that names none. */
|
|
149
|
+
function indexOf$1(id, prefix) {
|
|
150
|
+
const found = new RegExp(`^${prefix}([1-9]\\d{0,15})$`).exec(id);
|
|
151
|
+
return found ? Number(found[1]) : null;
|
|
152
|
+
}
|
|
153
|
+
const grantView = (row) => ({
|
|
154
|
+
id: grantId(row.id),
|
|
155
|
+
issuer: row.issuer,
|
|
156
|
+
claims: row.claims,
|
|
157
|
+
can: compileAccess(row.can).summary(),
|
|
158
|
+
by: row.by,
|
|
159
|
+
at: new Date(row.at).toISOString()
|
|
160
|
+
});
|
|
161
|
+
/** A token as the protocol shows it, with `can` what it may do now (`accessNow`'s, for its minter). */
|
|
162
|
+
const tokenView = (row, can) => ({
|
|
163
|
+
id: tokenId(row.id),
|
|
164
|
+
name: row.name,
|
|
165
|
+
can: can === null ? {} : can.summary(),
|
|
166
|
+
profile: row.profile,
|
|
167
|
+
session: row.session,
|
|
168
|
+
by: row.by,
|
|
169
|
+
at: new Date(row.at).toISOString(),
|
|
170
|
+
expiresAt: new Date(row.expiresAt).toISOString(),
|
|
171
|
+
parent: row.parent === null ? null : tokenId(row.parent)
|
|
172
|
+
});
|
|
173
|
+
/** A new token's secret: the prefix and 32 random bytes, base64url. */
|
|
174
|
+
function newSecret() {
|
|
175
|
+
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
|
|
176
|
+
return `${BOARD_TOKEN_PREFIX}${btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
|
|
177
|
+
}
|
|
178
|
+
const needsManage = (identity, what) => {
|
|
179
|
+
if (!identity.principal.access.everywhere("manage")) throw forbidden(`this token can't ${what}: it needs manage in every channel`);
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* The board's grants and tokens, in session `access` of its log: what `a2()` answers for them, and
|
|
183
|
+
* what board() checks requests against. `issuers`: the OIDC issuers the board's rules name, which no
|
|
184
|
+
* grant's may share principals with. `accessNow`: what a principal may do now, under board()'s
|
|
185
|
+
* rules and grants, which a token's listing shows.
|
|
186
|
+
*/
|
|
187
|
+
function createTrust(options) {
|
|
188
|
+
const session = createServer({
|
|
189
|
+
contract: accessContract,
|
|
190
|
+
store: options.store
|
|
191
|
+
}).session(ACCESS_SESSION);
|
|
192
|
+
const read = async () => (await session.state(accessReducer)).state;
|
|
193
|
+
/**
|
|
194
|
+
* What each of `rows` may do now: its minter's access, narrowed by its scope. A minter's is worked
|
|
195
|
+
* out once, however many tokens it has.
|
|
196
|
+
*/
|
|
197
|
+
async function viewsOf(rows) {
|
|
198
|
+
const minters = /* @__PURE__ */ new Map();
|
|
199
|
+
return Promise.all(rows.map(async (row) => {
|
|
200
|
+
const { scope, ...minter } = row.minter;
|
|
201
|
+
const key = JSON.stringify(minter);
|
|
202
|
+
let access = minters.get(key);
|
|
203
|
+
if (!access) minters.set(key, access = options.accessNow(minter));
|
|
204
|
+
const now = await access;
|
|
205
|
+
return tokenView(row, now === null || scope === void 0 ? now : now.narrow(parseScope(scope)));
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
/** Whether token `id` and every token it was minted with are live now. */
|
|
209
|
+
function liveIn(state, id) {
|
|
210
|
+
let at = id;
|
|
211
|
+
for (let depth = 0; at !== null; depth++) {
|
|
212
|
+
const row = state.tokens[at];
|
|
213
|
+
if (!row || row.expiresAt <= Date.now() || depth > TOKEN_DEPTH_MAX) return false;
|
|
214
|
+
at = row.parent;
|
|
215
|
+
}
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
trust: {
|
|
220
|
+
async grants() {
|
|
221
|
+
return Object.values((await read()).grants).map((row) => ({
|
|
222
|
+
...grantView(row),
|
|
223
|
+
can: row.can
|
|
224
|
+
}));
|
|
225
|
+
},
|
|
226
|
+
async token(hash) {
|
|
227
|
+
const state = await read();
|
|
228
|
+
const id = state.hashes[hash];
|
|
229
|
+
if (id === void 0 || !liveIn(state, id)) return null;
|
|
230
|
+
const row = state.tokens[id];
|
|
231
|
+
return {
|
|
232
|
+
id: tokenId(row.id),
|
|
233
|
+
name: row.name,
|
|
234
|
+
minter: row.minter,
|
|
235
|
+
pinned: {
|
|
236
|
+
profile: row.profile,
|
|
237
|
+
avatar: row.avatar,
|
|
238
|
+
session: row.session,
|
|
239
|
+
handle: row.handle,
|
|
240
|
+
name: row.personName
|
|
241
|
+
},
|
|
242
|
+
expiresAt: new Date(row.expiresAt)
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
async live(id) {
|
|
246
|
+
const index = indexOf$1(id, "t");
|
|
247
|
+
return index !== null && liveIn(await read(), index);
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
async grants(identity) {
|
|
251
|
+
needsManage(identity, "list grants");
|
|
252
|
+
return { grants: Object.values((await read()).grants).sort((a, b) => a.id - b.id).map(grantView) };
|
|
253
|
+
},
|
|
254
|
+
async createGrant(identity, body) {
|
|
255
|
+
needsManage(identity, "set grants");
|
|
256
|
+
const before = await read();
|
|
257
|
+
const checked = checkGrant(body, [...options.issuers(), ...grantIssuers(Object.values(before.grants))]);
|
|
258
|
+
const key = grantKey(checked.issuer, checked.claims);
|
|
259
|
+
const created = before.grants[key] === void 0;
|
|
260
|
+
await session.append({
|
|
261
|
+
type: "grant.set",
|
|
262
|
+
payload: {
|
|
263
|
+
issuer: checked.issuer,
|
|
264
|
+
claims: checked.claims,
|
|
265
|
+
can: checked.can,
|
|
266
|
+
by: identity.actor.id
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
const row = (await read()).grants[key];
|
|
270
|
+
return {
|
|
271
|
+
grant: grantView(row),
|
|
272
|
+
created
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
async revokeGrant(identity, { id }) {
|
|
276
|
+
needsManage(identity, "revoke grants");
|
|
277
|
+
const index = indexOf$1(id, "g");
|
|
278
|
+
if (index === null || (await read()).grantKeys[index] === void 0) return { revoked: false };
|
|
279
|
+
await session.append({
|
|
280
|
+
type: "grant.revoked",
|
|
281
|
+
payload: {
|
|
282
|
+
id: index,
|
|
283
|
+
by: identity.actor.id
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
return { revoked: true };
|
|
287
|
+
},
|
|
288
|
+
async tokens(identity) {
|
|
289
|
+
const state = await read();
|
|
290
|
+
return { tokens: await viewsOf(Object.values(state.tokens).filter((row) => liveIn(state, row.id) && manages(state, identity, row)).sort((a, b) => a.id - b.id)) };
|
|
291
|
+
},
|
|
292
|
+
/** The board tokens `principal` came with: its own, then each it was minted with. Undefined with none. */
|
|
293
|
+
async chain(principal) {
|
|
294
|
+
if (principal.token === void 0) return void 0;
|
|
295
|
+
const state = await read();
|
|
296
|
+
const chain = [];
|
|
297
|
+
for (let at = indexOf$1(principal.token, "t"); at !== null && chain.length <= TOKEN_DEPTH_MAX; at = state.tokens[at]?.parent ?? null) chain.push(tokenId(at));
|
|
298
|
+
return chain;
|
|
299
|
+
},
|
|
300
|
+
async createToken(identity, body) {
|
|
301
|
+
const { principal, author, actor } = identity;
|
|
302
|
+
const state = await read();
|
|
303
|
+
const now = Date.now();
|
|
304
|
+
const asked = body.expires === void 0 ? TOKEN_DEFAULT_MS : duration(body.expires);
|
|
305
|
+
if (asked <= 0 || asked > 7776e6) throw badRequest("a token lives up to 90 days: pass expires as 90d or less", "invalid_expires");
|
|
306
|
+
let expiresAt = now + asked;
|
|
307
|
+
let parent = null;
|
|
308
|
+
if (principal.token !== void 0) {
|
|
309
|
+
const index = indexOf$1(principal.token, "t");
|
|
310
|
+
parent = index === null ? null : state.tokens[index] ?? null;
|
|
311
|
+
if (!parent || !liveIn(state, parent.id)) throw unauthorized("invalid token: revoked, expired, or not this board's");
|
|
312
|
+
let depth = 0;
|
|
313
|
+
for (let at = parent; at; at = at.parent === null ? void 0 : state.tokens[at.parent]) depth++;
|
|
314
|
+
if (depth >= TOKEN_DEPTH_MAX) throw badRequest(`tokens minted with tokens go ${TOKEN_DEPTH_MAX} deep at most`, "invalid_token");
|
|
315
|
+
expiresAt = Math.min(expiresAt, parent.expiresAt);
|
|
316
|
+
}
|
|
317
|
+
let can;
|
|
318
|
+
try {
|
|
319
|
+
can = compileAccess(body.can ?? DEFAULT_CAN);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
throw badRequest(error.message, "invalid_scope");
|
|
322
|
+
}
|
|
323
|
+
const scope = scopeOf((principal.scope === void 0 ? FULL_ACCESS : compileAccess(parseScope(principal.scope))).narrow(can));
|
|
324
|
+
if (scope.length > 4e3) throw badRequest(`a token's can is ${SCOPE_MAX} characters at most as a scope: name fewer channels`, "invalid_scope");
|
|
325
|
+
const minter = {
|
|
326
|
+
id: principal.id,
|
|
327
|
+
kind: principal.kind,
|
|
328
|
+
handle: principal.handle,
|
|
329
|
+
...principal.facts ? { facts: principal.facts } : {},
|
|
330
|
+
scope
|
|
331
|
+
};
|
|
332
|
+
const pinned = principal.pinned;
|
|
333
|
+
const profile = pinned?.profile ?? body.profile ?? null;
|
|
334
|
+
const avatar = pinned?.profile ? pinned.avatar : profile !== null ? body.avatar ?? null : null;
|
|
335
|
+
const pinnedSession = pinned?.session ?? body.session ?? null;
|
|
336
|
+
const person = principal.kind === "person";
|
|
337
|
+
const base = author.profile === null ? actor.handle : actor.handle.slice(0, Math.max(0, actor.handle.length - author.profile.length - 1)) || principal.handle;
|
|
338
|
+
const handle = pinned?.handle ?? (person ? base : null);
|
|
339
|
+
const personName = pinned?.name ?? (person && author.profile === null ? actor.name : null);
|
|
340
|
+
const secret = newSecret();
|
|
341
|
+
const [row] = await session.append({
|
|
342
|
+
type: "token.minted",
|
|
343
|
+
payload: {
|
|
344
|
+
hash: await tokenHash(secret),
|
|
345
|
+
name: body.name,
|
|
346
|
+
minter,
|
|
347
|
+
profile,
|
|
348
|
+
avatar,
|
|
349
|
+
session: pinnedSession,
|
|
350
|
+
handle,
|
|
351
|
+
personName,
|
|
352
|
+
expiresAt,
|
|
353
|
+
parent: parent?.id ?? null,
|
|
354
|
+
by: actor.id
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
const minted = row && (await read()).tokens[row.index];
|
|
358
|
+
if (!minted) throw new Error("a token was minted but its log has no record of it");
|
|
359
|
+
const [view] = await viewsOf([minted]);
|
|
360
|
+
return {
|
|
361
|
+
token: view,
|
|
362
|
+
secret
|
|
363
|
+
};
|
|
364
|
+
},
|
|
365
|
+
async revokeToken(identity, { id }) {
|
|
366
|
+
const state = await read();
|
|
367
|
+
const index = indexOf$1(id, "t");
|
|
368
|
+
const row = index === null ? void 0 : state.tokens[index];
|
|
369
|
+
if (!row || !liveIn(state, row.id) || !manages(state, identity, row)) return { revoked: false };
|
|
370
|
+
await session.append({
|
|
371
|
+
type: "token.revoked",
|
|
372
|
+
payload: {
|
|
373
|
+
id: row.id,
|
|
374
|
+
by: identity.actor.id
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
return { revoked: true };
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
38
382
|
//#region src/server/feed.ts
|
|
39
383
|
/** Bookkeeping events, shown only with `all`: who's who, and delivery receipts. */
|
|
40
384
|
const HIDDEN = /* @__PURE__ */ new Set([
|
|
@@ -42,27 +386,14 @@ const HIDDEN = /* @__PURE__ */ new Set([
|
|
|
42
386
|
"message.synced",
|
|
43
387
|
"message.files_synced"
|
|
44
388
|
]);
|
|
45
|
-
/** The person a linked platform actor is, as views show it. */
|
|
46
|
-
function personView(actors, actor) {
|
|
47
|
-
const link = own(actors.links, actor);
|
|
48
|
-
const person = link ? own(actors.byId, link.to) : void 0;
|
|
49
|
-
return link ? {
|
|
50
|
-
id: link.to,
|
|
51
|
-
handle: person?.handle ?? link.to,
|
|
52
|
-
name: person?.name ?? link.to,
|
|
53
|
-
avatar: person?.avatar ?? null
|
|
54
|
-
} : void 0;
|
|
55
|
-
}
|
|
56
389
|
function actorView(actors, id) {
|
|
57
390
|
const author = own(actors.byId, id);
|
|
58
391
|
if (!author) return null;
|
|
59
|
-
const person = personView(actors, id);
|
|
60
392
|
return {
|
|
61
393
|
kind: author.kind,
|
|
62
394
|
handle: author.handle,
|
|
63
395
|
name: author.name,
|
|
64
|
-
avatar: author.avatar
|
|
65
|
-
...person ? { person } : {}
|
|
396
|
+
avatar: author.avatar
|
|
66
397
|
};
|
|
67
398
|
}
|
|
68
399
|
/** The board's own code, which reads every channel: it needs no messages to say so. */
|
|
@@ -125,11 +456,13 @@ function viewedBy(view, reader) {
|
|
|
125
456
|
}
|
|
126
457
|
/** A message's or a cursor's ID as clients see it. On this board it's the log index, in decimal. */
|
|
127
458
|
const ref = (index) => String(index);
|
|
459
|
+
/** A schedule's ID as clients see it: its index in session `schedules`, as `s12`, so it reads apart from message `12`. */
|
|
460
|
+
const scheduleRef = (index) => `s${index}`;
|
|
128
461
|
/** The log index a ref names on this board, or null for a string that names nothing here. */
|
|
129
462
|
const indexOf = (value) => /^(0|[1-9]\d{0,15})$/.test(value) ? Number(value) : null;
|
|
130
463
|
/** A message as `reader` sees it. Every view names its reader, so none shows a link's snapshot by default. */
|
|
131
464
|
function view(message, actors, reader) {
|
|
132
|
-
const { blind, version, schedule, ...shown } = message;
|
|
465
|
+
const { blind, version, revision, schedule, ...shown } = message;
|
|
133
466
|
return {
|
|
134
467
|
...shown,
|
|
135
468
|
...blind ? { blind } : {},
|
|
@@ -137,8 +470,9 @@ function view(message, actors, reader) {
|
|
|
137
470
|
re: message.re === null ? null : ref(message.re),
|
|
138
471
|
replyTo: message.replyTo === null ? null : ref(message.replyTo),
|
|
139
472
|
version: version ?? message.id,
|
|
473
|
+
revision: revision ?? message.id,
|
|
140
474
|
...schedule ? { schedule: {
|
|
141
|
-
id:
|
|
475
|
+
id: scheduleRef(schedule.id),
|
|
142
476
|
n: schedule.n
|
|
143
477
|
} } : {},
|
|
144
478
|
links: readableLinks(message.links, reader),
|
|
@@ -147,30 +481,17 @@ function view(message, actors, reader) {
|
|
|
147
481
|
author: actorView(actors, message.by.actor)
|
|
148
482
|
};
|
|
149
483
|
}
|
|
150
|
-
|
|
151
|
-
const hasLinks = (actors, id) => Object.values(actors.links).some((link) => link.to === id);
|
|
152
|
-
const actorSet = (value, actors) => own(actors.byId, value) || own(actors.links, value) || hasLinks(actors, value) ? /* @__PURE__ */ new Set([value]) : new Set(own(actors.byHandle, value.toLowerCase()) ?? []);
|
|
484
|
+
const actorSet = (value, actors) => own(actors.byId, value) ? /* @__PURE__ */ new Set([value]) : new Set(own(actors.byHandle, value.toLowerCase()) ?? []);
|
|
153
485
|
/**
|
|
154
|
-
* Who a `--by` or `--mentions` value covers. A person (`julian
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* or an unlinked platform actor is just itself.
|
|
486
|
+
* Who a `--by` or `--mentions` value covers. A person (`julian`) is everyone they
|
|
487
|
+
* are: their own actor, and their agents (`julian/claude`). An agent or a platform
|
|
488
|
+
* actor is just itself.
|
|
158
489
|
*/
|
|
159
490
|
function whoIs(value, actors) {
|
|
160
491
|
const ids = actorSet(value, actors);
|
|
161
|
-
const out = new Set(ids);
|
|
162
|
-
const principals = /* @__PURE__ */ new Set();
|
|
163
|
-
for (const id of ids) {
|
|
164
|
-
const kind = own(actors.byId, id)?.kind;
|
|
165
|
-
const person = own(actors.links, id) ? personOf(actors, id) : kind === "person" || hasLinks(actors, id) ? id : null;
|
|
166
|
-
if (person === null) continue;
|
|
167
|
-
principals.add(person);
|
|
168
|
-
out.add(person);
|
|
169
|
-
for (const linked of linkedTo(actors, person)) out.add(linked);
|
|
170
|
-
}
|
|
171
492
|
return {
|
|
172
|
-
actors:
|
|
173
|
-
principals
|
|
493
|
+
actors: ids,
|
|
494
|
+
principals: new Set([...ids].filter((id) => own(actors.byId, id)?.kind === "person"))
|
|
174
495
|
};
|
|
175
496
|
}
|
|
176
497
|
/** Whether `author` is one of `who`: its actor, or an agent of one of its people. */
|
|
@@ -191,7 +512,7 @@ function compileFilter(filters, context) {
|
|
|
191
512
|
const subtree = channel?.endsWith("/") ? channel.slice(0, -1) : null;
|
|
192
513
|
const by = filters.by === void 0 ? null : whoIs(filters.by, context.actors);
|
|
193
514
|
const re = filters.re === void 0 ? void 0 : indexOf(filters.re) ?? -1;
|
|
194
|
-
const mentions = filters.mentions === void 0 ? null : filters.mentions === "me" ? new Set(context.me === null ? [] : [context.me
|
|
515
|
+
const mentions = filters.mentions === void 0 ? null : filters.mentions === "me" ? new Set(context.me === null ? [] : [context.me]) : whoIs(filters.mentions, context.actors).actors;
|
|
195
516
|
return (message) => {
|
|
196
517
|
if (!message || !access.readable(message.channel)) return false;
|
|
197
518
|
if (channel !== void 0) {
|
|
@@ -244,7 +565,7 @@ function toMessageFilter(filters, context) {
|
|
|
244
565
|
principals: [...who.principals]
|
|
245
566
|
};
|
|
246
567
|
}
|
|
247
|
-
if (filters.mentions !== void 0) out.mentions = filters.mentions === "me" ? context.me === null ? [] : [context.me
|
|
568
|
+
if (filters.mentions !== void 0) out.mentions = filters.mentions === "me" ? context.me === null ? [] : [context.me] : [...whoIs(filters.mentions, context.actors).actors];
|
|
248
569
|
if (filters.from !== void 0) out.from = filters.from;
|
|
249
570
|
if (filters.text !== void 0) out.text = filters.text.toLowerCase();
|
|
250
571
|
if (filters.label?.length) out.labels = filters.label.map(([key, values]) => ({
|
|
@@ -325,7 +646,7 @@ function payloadView(payload, links) {
|
|
|
325
646
|
]) if (typeof shown[key] === "number") shown[key] = ref(shown[key]);
|
|
326
647
|
const schedule = shown.schedule;
|
|
327
648
|
if (schedule) shown.schedule = {
|
|
328
|
-
id:
|
|
649
|
+
id: scheduleRef(schedule.id),
|
|
329
650
|
n: schedule.n
|
|
330
651
|
};
|
|
331
652
|
if (links) shown.links = links;
|
|
@@ -681,13 +1002,15 @@ async function authorizeMessage(ctx, access, permission, id) {
|
|
|
681
1002
|
* read, it's yours only if it was posted without reading too: a write-only
|
|
682
1003
|
* source fixes what it wrote, but a principal ID it shares with a caller that
|
|
683
1004
|
* reads (a repo's workflows share one) doesn't reach that caller's messages.
|
|
1005
|
+
* A board token that pins a profile owns only what was written as it (`wroteAs`).
|
|
684
1006
|
* Someone else's answers 404 where you can't read it, and 403 where you can.
|
|
685
1007
|
*/
|
|
686
|
-
async function authorizeOwn(ctx,
|
|
1008
|
+
async function authorizeOwn(ctx, identity, id, verb) {
|
|
1009
|
+
const { principal } = identity;
|
|
687
1010
|
const message = await ctx.reads.message(id);
|
|
688
1011
|
if (!message) throw messageNotFound(id)();
|
|
689
1012
|
const readable = principal.access.readable(message.channel);
|
|
690
|
-
if (message.by
|
|
1013
|
+
if (!wroteAs(identity, message.by) || !readable && !message.blind) {
|
|
691
1014
|
if (!readable) throw messageNotFound(id)();
|
|
692
1015
|
throw forbidden(`only the author can ${verb} a message`);
|
|
693
1016
|
}
|
|
@@ -750,19 +1073,20 @@ async function writePost(ctx, input) {
|
|
|
750
1073
|
if (input.sync?.length) throw badRequest("sync attaches whole threads: use `board sync <id> <target>`");
|
|
751
1074
|
} else {
|
|
752
1075
|
if (!channel) throw badRequest("a post needs a channel");
|
|
1076
|
+
if (input.after !== void 0) throw badRequest("`after` is for replies", "invalid_after");
|
|
753
1077
|
thread = id;
|
|
754
1078
|
}
|
|
755
1079
|
spend(input.proofs.post, "post", { channel });
|
|
756
1080
|
if (hasLabels(input.labels)) spend(input.proofs.label, "label", { channel });
|
|
757
1081
|
if (input.sync?.length) spend(input.proofs.sync, "sync", { channel });
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
}
|
|
1082
|
+
const replayed = await replayOf(ctx, input, {
|
|
1083
|
+
channel,
|
|
1084
|
+
re,
|
|
1085
|
+
replyTo
|
|
1086
|
+
}, sent);
|
|
1087
|
+
if (replayed) return replayed;
|
|
1088
|
+
if (input.after !== void 0) {
|
|
1089
|
+
if ((await ctx.reads.message(input.after))?.thread !== thread) throw badRequest(`after names no message in #${re}'s thread`, "invalid_after");
|
|
766
1090
|
}
|
|
767
1091
|
return commitPost(ctx, input, {
|
|
768
1092
|
channel,
|
|
@@ -773,6 +1097,16 @@ async function writePost(ctx, input) {
|
|
|
773
1097
|
sent
|
|
774
1098
|
});
|
|
775
1099
|
}
|
|
1100
|
+
/** The post a caller's own ID already landed, as its retry gets it back: null when there's none. */
|
|
1101
|
+
async function replayOf(ctx, input, place, sent) {
|
|
1102
|
+
if (input.id === void 0) return null;
|
|
1103
|
+
const landed = await ctx.reads.byEventId(input.id);
|
|
1104
|
+
if (!landed) return null;
|
|
1105
|
+
if (landed.channel !== place.channel || landed.re !== place.re || landed.replyTo !== place.replyTo) throw duplicateId();
|
|
1106
|
+
const shown = await landedFor(ctx, input.identity, landed);
|
|
1107
|
+
if (!shown) throw duplicateId();
|
|
1108
|
+
return shownTo(shown, input.identity.principal.access, sent);
|
|
1109
|
+
}
|
|
776
1110
|
/**
|
|
777
1111
|
* A post that landed, as its author may have it back: only the same author's, and one in a
|
|
778
1112
|
* channel it can't read only if it posted without reading too. A caller that can't read gets back
|
|
@@ -833,28 +1167,45 @@ async function commitPost(ctx, input, placed) {
|
|
|
833
1167
|
by: identity.author
|
|
834
1168
|
}
|
|
835
1169
|
})));
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
...
|
|
839
|
-
|
|
1170
|
+
const events = [
|
|
1171
|
+
...seen ? [seen] : [],
|
|
1172
|
+
...named,
|
|
1173
|
+
{
|
|
1174
|
+
type: "message.posted",
|
|
1175
|
+
...id ? { id } : {},
|
|
1176
|
+
payload: {
|
|
1177
|
+
channel,
|
|
1178
|
+
text,
|
|
1179
|
+
re,
|
|
1180
|
+
replyTo,
|
|
1181
|
+
thread,
|
|
1182
|
+
labels: input.labels ?? {},
|
|
1183
|
+
noSync: input.noSync ?? false,
|
|
1184
|
+
mentions,
|
|
1185
|
+
by: identity.author,
|
|
1186
|
+
origin: input.origin ?? null,
|
|
1187
|
+
from: input.from ?? null,
|
|
1188
|
+
...input.attachments?.length ? { attachments: input.attachments } : {},
|
|
1189
|
+
...input.links?.length ? { links: input.links } : {},
|
|
1190
|
+
...input.schedule ? { schedule: input.schedule } : {},
|
|
1191
|
+
...identity.principal.access.readable(channel) ? {} : { blind: true },
|
|
1192
|
+
...input.importedBy !== void 0 ? { importedBy: input.importedBy } : {}
|
|
1193
|
+
}
|
|
1194
|
+
},
|
|
1195
|
+
...synced
|
|
1196
|
+
];
|
|
1197
|
+
let rows;
|
|
1198
|
+
try {
|
|
1199
|
+
rows = input.after === void 0 || re === null ? await ctx.session.append(...events) : await appendWhile(ctx, nothingAfter(ctx, re, input.after), events);
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
const replayed = error instanceof HttpError && error.code === "newer_message" ? await replayOf(ctx, input, {
|
|
840
1202
|
channel,
|
|
841
|
-
text,
|
|
842
1203
|
re,
|
|
843
|
-
replyTo
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
by: identity.author,
|
|
849
|
-
origin: input.origin ?? null,
|
|
850
|
-
from: input.from ?? null,
|
|
851
|
-
...input.attachments?.length ? { attachments: input.attachments } : {},
|
|
852
|
-
...input.links?.length ? { links: input.links } : {},
|
|
853
|
-
...input.schedule ? { schedule: input.schedule } : {},
|
|
854
|
-
...identity.principal.access.readable(channel) ? {} : { blind: true },
|
|
855
|
-
...input.importedBy !== void 0 ? { importedBy: input.importedBy } : {}
|
|
856
|
-
}
|
|
857
|
-
}, ...synced);
|
|
1204
|
+
replyTo
|
|
1205
|
+
}, sent) : null;
|
|
1206
|
+
if (replayed) return replayed;
|
|
1207
|
+
throw error;
|
|
1208
|
+
}
|
|
858
1209
|
const posted = rows.find((row) => row.type === "message.posted");
|
|
859
1210
|
await untilApplied(ctx, rows);
|
|
860
1211
|
const message = foldMessages({
|
|
@@ -923,6 +1274,42 @@ async function untilApplied(ctx, rows) {
|
|
|
923
1274
|
}
|
|
924
1275
|
/** How long a write waits for the tables before it answers anyway. */
|
|
925
1276
|
const LANDED_WAIT_MS = 1e4;
|
|
1277
|
+
/**
|
|
1278
|
+
* Appends `events` only while the board, as `holds` reads it, still allows them: `holds` checks
|
|
1279
|
+
* the tables, throws what refuses the write, and returns the index its reads reflect. The append
|
|
1280
|
+
* holds only while that index is still the log's last, so anything that lands in between,
|
|
1281
|
+
* anywhere on the board, moves it, and it looks again from there.
|
|
1282
|
+
*/
|
|
1283
|
+
async function appendWhile(ctx, holds, events) {
|
|
1284
|
+
return whileHeadMoves(async () => {
|
|
1285
|
+
await ctx.reads.ensure();
|
|
1286
|
+
return appendIfLast(ctx.session, await holds(), events);
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Runs `attempt`, a look and a conditional append, again each time the board moves on in
|
|
1291
|
+
* between, up to `CONDITIONAL_TRIES` times: how every conditional write here looks again.
|
|
1292
|
+
*/
|
|
1293
|
+
async function whileHeadMoves(attempt) {
|
|
1294
|
+
for (let tries = 1;; tries++) try {
|
|
1295
|
+
return await attempt();
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
if (!headMoved(error)) throw error;
|
|
1298
|
+
if (tries === CONDITIONAL_TRIES) throw new HttpError(503, "unavailable", "the board kept changing while it checked: try again");
|
|
1299
|
+
await new Promise((resolve) => setTimeout(resolve, Math.random() * 5 * tries));
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
/** How many times a conditional write looks again, as the board moves on, before it gives up. */
|
|
1303
|
+
const CONDITIONAL_TRIES = 10;
|
|
1304
|
+
/** A reply's `after`: nothing came after it in the thread, as of the index the read reflects. */
|
|
1305
|
+
const nothingAfter = (ctx, root, after) => async () => {
|
|
1306
|
+
const { messages: [newer], position } = await ctx.reads.tables.messages({ re: root }, {
|
|
1307
|
+
after,
|
|
1308
|
+
limit: 1
|
|
1309
|
+
});
|
|
1310
|
+
if (newer) throw new HttpError(409, "newer_message", `#${newer.id} came after #${after}`);
|
|
1311
|
+
return position;
|
|
1312
|
+
};
|
|
926
1313
|
/** The message a by-ID write targets, as the board looked it up, once its proof is spent on the message's channel. */
|
|
927
1314
|
function spent(authorized, permission) {
|
|
928
1315
|
spend(authorized.proof, permission, authorized.message);
|
|
@@ -931,7 +1318,10 @@ function spent(authorized, permission) {
|
|
|
931
1318
|
async function writeEdit(ctx, input) {
|
|
932
1319
|
const message = spent(input.authorized, "post");
|
|
933
1320
|
if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can edit a message");
|
|
934
|
-
if (message.deleted)
|
|
1321
|
+
if (message.deleted) {
|
|
1322
|
+
if (input.revision !== void 0 && input.revision !== (message.revision ?? message.id)) throw new HttpError(409, "revision_conflict", `#${message.id} was deleted since revision ${input.revision}`);
|
|
1323
|
+
throw badRequest("message was deleted", "message_deleted");
|
|
1324
|
+
}
|
|
935
1325
|
return commitEdit(ctx, input, message);
|
|
936
1326
|
}
|
|
937
1327
|
async function commitEdit(ctx, input, message) {
|
|
@@ -940,17 +1330,22 @@ async function commitEdit(ctx, input, message) {
|
|
|
940
1330
|
const { text, found } = await withEmailMentions(input.text);
|
|
941
1331
|
const named = found.map((actor) => actorSeen(actor, actors)).filter((event) => event !== null);
|
|
942
1332
|
const { mentions, unknown } = resolveMentions(text, knownWith(actors, found), seen ? input.identity.actor : void 0, input.from != null, input.identity.principal.access.readsAll);
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1333
|
+
const events = [
|
|
1334
|
+
...seen ? [seen] : [],
|
|
1335
|
+
...named,
|
|
1336
|
+
{
|
|
1337
|
+
type: "message.edited",
|
|
1338
|
+
payload: {
|
|
1339
|
+
id: message.id,
|
|
1340
|
+
thread: message.thread,
|
|
1341
|
+
text,
|
|
1342
|
+
mentions,
|
|
1343
|
+
by: input.identity.author,
|
|
1344
|
+
from: input.from ?? null
|
|
1345
|
+
}
|
|
952
1346
|
}
|
|
953
|
-
|
|
1347
|
+
];
|
|
1348
|
+
await untilApplied(ctx, input.revision === void 0 ? await ctx.session.append(...events) : await appendWhile(ctx, revisionIs(ctx, message.id, input.revision), events));
|
|
954
1349
|
return unknown.length && input.identity.principal.access.readable(message.channel) ? { unknownMentions: unknown } : {};
|
|
955
1350
|
}
|
|
956
1351
|
/** A message's links, replaced: a platform edit or unfurl changed what it links to. */
|
|
@@ -971,8 +1366,11 @@ async function writeLinks(ctx, input) {
|
|
|
971
1366
|
async function writeDelete(ctx, input) {
|
|
972
1367
|
const message = spent(input.authorized, "post");
|
|
973
1368
|
if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can delete a message");
|
|
974
|
-
if (message.deleted)
|
|
975
|
-
|
|
1369
|
+
if (message.deleted) {
|
|
1370
|
+
if (input.revision !== void 0 && input.revision !== (message.revision ?? message.id)) throw new HttpError(409, "revision_conflict", `#${message.id} was deleted since revision ${input.revision}`);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const deleted = [{
|
|
976
1374
|
type: "message.deleted",
|
|
977
1375
|
payload: {
|
|
978
1376
|
id: message.id,
|
|
@@ -980,8 +1378,17 @@ async function writeDelete(ctx, input) {
|
|
|
980
1378
|
by: input.identity.author,
|
|
981
1379
|
from: input.from ?? null
|
|
982
1380
|
}
|
|
983
|
-
}
|
|
1381
|
+
}];
|
|
1382
|
+
await untilApplied(ctx, input.revision === void 0 ? await ctx.session.append(...deleted) : await appendWhile(ctx, revisionIs(ctx, message.id, input.revision), deleted));
|
|
984
1383
|
}
|
|
1384
|
+
/** An edit's or a delete's `revision`: no edit or delete since, as of the index the read reflects. */
|
|
1385
|
+
const revisionIs = (ctx, id, revision) => async () => {
|
|
1386
|
+
const position = await ctx.reads.tables.position();
|
|
1387
|
+
const message = (await ctx.reads.tables.messagesAt([id], position)).get(id);
|
|
1388
|
+
const now = message ? message.revision ?? message.id : null;
|
|
1389
|
+
if (now !== revision) throw new HttpError(409, "revision_conflict", message?.deleted ? `#${id} was deleted since revision ${revision}` : `#${id} was edited since revision ${revision}: it's at ${now}`);
|
|
1390
|
+
return position;
|
|
1391
|
+
};
|
|
985
1392
|
async function writeReaction(ctx, input) {
|
|
986
1393
|
const message = spent(input.authorized, "react");
|
|
987
1394
|
if (message.deleted) throw badRequest("message was deleted", "message_deleted");
|
|
@@ -1003,7 +1410,7 @@ async function writeLabels(ctx, input) {
|
|
|
1003
1410
|
if (message.deleted) throw badRequest("message was deleted", "message_deleted");
|
|
1004
1411
|
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
1005
1412
|
const seen = seenFor(input.identity, actors);
|
|
1006
|
-
|
|
1413
|
+
const events = [...seen ? [seen] : [], {
|
|
1007
1414
|
type: "labels.changed",
|
|
1008
1415
|
payload: {
|
|
1009
1416
|
id: message.id,
|
|
@@ -1012,8 +1419,21 @@ async function writeLabels(ctx, input) {
|
|
|
1012
1419
|
unset: input.unset ?? [],
|
|
1013
1420
|
by: input.identity.author
|
|
1014
1421
|
}
|
|
1015
|
-
}
|
|
1422
|
+
}];
|
|
1423
|
+
await untilApplied(ctx, input.if !== void 0 && Object.keys(input.if).length > 0 ? await appendWhile(ctx, labelsAre(ctx, message.id, input.if), events) : await ctx.session.append(...events));
|
|
1016
1424
|
}
|
|
1425
|
+
/** A label write's `if`: each label is as it says, as of the index the read reflects. */
|
|
1426
|
+
const labelsAre = (ctx, id, expected) => async () => {
|
|
1427
|
+
const position = await ctx.reads.tables.position();
|
|
1428
|
+
const labels = (await ctx.reads.tables.messagesAt([id], position)).get(id)?.labels ?? {};
|
|
1429
|
+
for (const key of Object.keys(expected).sort()) {
|
|
1430
|
+
const now = own(labels, key);
|
|
1431
|
+
if (now === expected[key] || now === void 0 && expected[key] === null) continue;
|
|
1432
|
+
const wanted = expected[key];
|
|
1433
|
+
throw new HttpError(409, "label_conflict", wanted === null ? `${key} is set` : now === void 0 ? `${key} isn't set` : `${key} isn't ${wanted}`);
|
|
1434
|
+
}
|
|
1435
|
+
return position;
|
|
1436
|
+
};
|
|
1017
1437
|
/**
|
|
1018
1438
|
* Syncs a message's thread with a target, resolved by the sync that delivers it. Attaching twice
|
|
1019
1439
|
* is a no-op; a platform thread another board thread is synced with is a 409.
|
|
@@ -1037,6 +1457,135 @@ async function writeAttach(ctx, input) {
|
|
|
1037
1457
|
}));
|
|
1038
1458
|
return { attached: true };
|
|
1039
1459
|
}
|
|
1460
|
+
/**
|
|
1461
|
+
* A channel made before anything posts to it, or the one that's there, made by a post or another
|
|
1462
|
+
* create, which `created: false` says. It needs `read` and `post` there, refused alike whether it's
|
|
1463
|
+
* there or not: the answer says whether it was, and shows what it holds.
|
|
1464
|
+
*/
|
|
1465
|
+
async function writeChannel(ctx, input) {
|
|
1466
|
+
const { access } = input.identity.principal;
|
|
1467
|
+
authorize(access, "read", input.path);
|
|
1468
|
+
authorize(access, "post", input.path);
|
|
1469
|
+
const seen = seenFor(input.identity, (await ctx.session.state(actorsReducer)).state);
|
|
1470
|
+
const events = [...seen ? [seen] : [], {
|
|
1471
|
+
type: "channel.created",
|
|
1472
|
+
payload: {
|
|
1473
|
+
channel: input.path,
|
|
1474
|
+
by: input.identity.author
|
|
1475
|
+
}
|
|
1476
|
+
}];
|
|
1477
|
+
const made = await whileHeadMoves(async () => {
|
|
1478
|
+
const { state, index } = await ctx.session.state(channelsReducer, { through: "latest" });
|
|
1479
|
+
const found = own(state, input.path);
|
|
1480
|
+
return found ? { found } : { rows: await appendIfLast(ctx.session, index, events) };
|
|
1481
|
+
});
|
|
1482
|
+
if ("found" in made) return {
|
|
1483
|
+
channel: made.found,
|
|
1484
|
+
created: false
|
|
1485
|
+
};
|
|
1486
|
+
await untilApplied(ctx, made.rows);
|
|
1487
|
+
return {
|
|
1488
|
+
channel: {
|
|
1489
|
+
path: input.path,
|
|
1490
|
+
lastIndex: 0,
|
|
1491
|
+
messages: 0,
|
|
1492
|
+
threads: 0
|
|
1493
|
+
},
|
|
1494
|
+
created: true
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
/** The batch, only while `index` is still the session's last: A2's conditional append. */
|
|
1498
|
+
const appendIfLast = (session, index, events) => session.append.ifLastIndex(index, ...events);
|
|
1499
|
+
/** What A2 throws when the session's last index isn't the one a conditional append named. */
|
|
1500
|
+
const headMoved = (error) => error?.code === "LAST_INDEX_MISMATCH";
|
|
1501
|
+
//#endregion
|
|
1502
|
+
//#region src/server/registry.ts
|
|
1503
|
+
/** A principal as a registration keeps it: enough to work out its access again at each match. */
|
|
1504
|
+
const storedPrincipal = (principal) => ({
|
|
1505
|
+
id: principal.id,
|
|
1506
|
+
kind: principal.kind,
|
|
1507
|
+
handle: principal.handle,
|
|
1508
|
+
...principal.facts ? { facts: principal.facts } : {},
|
|
1509
|
+
...principal.scope !== void 0 ? { scope: principal.scope } : {},
|
|
1510
|
+
...principal.token !== void 0 ? { token: principal.token } : {}
|
|
1511
|
+
});
|
|
1512
|
+
/** What a registration shows: the principal's token facts stay on the board. */
|
|
1513
|
+
const viewOf = ({ principal: _principal, ...view }) => view;
|
|
1514
|
+
/**
|
|
1515
|
+
* The same on both ends: a board on `http()` hashes its watchers, and whether its sync subscribes,
|
|
1516
|
+
* and the board it uses hashes what it holds.
|
|
1517
|
+
*/
|
|
1518
|
+
function watchersHash(watch, sync = []) {
|
|
1519
|
+
return createHash("sha256").update(canonicalWatchers(watch, sync)).digest("hex").slice(0, 32);
|
|
1520
|
+
}
|
|
1521
|
+
function foldRegistry(state, event) {
|
|
1522
|
+
if (event.type === "watchers.registered") {
|
|
1523
|
+
const { owner, principal, url, watch, sync, hash } = event.payload;
|
|
1524
|
+
const copied = Object.fromEntries(Object.entries(watch).map(([id, spec]) => [id, {
|
|
1525
|
+
tail: spec.tail,
|
|
1526
|
+
when: spec.when
|
|
1527
|
+
}]));
|
|
1528
|
+
state.byOwner[owner] = {
|
|
1529
|
+
owner,
|
|
1530
|
+
principal: structuredClone(principal),
|
|
1531
|
+
url,
|
|
1532
|
+
watch: copied,
|
|
1533
|
+
sync: (sync ?? []).map((entry) => ({ ...entry })),
|
|
1534
|
+
hash
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
return state;
|
|
1538
|
+
}
|
|
1539
|
+
const registryReducer = reducer({
|
|
1540
|
+
contract: registryContract,
|
|
1541
|
+
name: "board.registry.v1",
|
|
1542
|
+
initialState: { byOwner: {} }
|
|
1543
|
+
}).fold(foldRegistry);
|
|
1544
|
+
/** Watchers registered with this board by boards on `http()`. */
|
|
1545
|
+
function createRegistry(options) {
|
|
1546
|
+
const server = createServer({
|
|
1547
|
+
contract: registryContract,
|
|
1548
|
+
...options.store ? { store: options.store } : {},
|
|
1549
|
+
...options.scheduler ? { scheduler: options.scheduler } : {}
|
|
1550
|
+
});
|
|
1551
|
+
const session = server.session(REGISTRY_SESSION);
|
|
1552
|
+
const state = async () => (await session.state(registryReducer)).state;
|
|
1553
|
+
return {
|
|
1554
|
+
server,
|
|
1555
|
+
state,
|
|
1556
|
+
/**
|
|
1557
|
+
* Replaces `principal`'s registration. One that changes nothing writes nothing, so asking
|
|
1558
|
+
* again is free, unless the principal's token facts changed: those decide what it may read.
|
|
1559
|
+
*/
|
|
1560
|
+
async register(principal, input) {
|
|
1561
|
+
const owner = principal.id;
|
|
1562
|
+
const hash = watchersHash(input.watch, input.sync);
|
|
1563
|
+
const current = (await state()).byOwner[owner];
|
|
1564
|
+
if (current && current.url === input.url && current.hash === hash && JSON.stringify(current.principal) === JSON.stringify(principal)) return viewOf(current);
|
|
1565
|
+
await session.append({
|
|
1566
|
+
type: "watchers.registered",
|
|
1567
|
+
payload: {
|
|
1568
|
+
owner,
|
|
1569
|
+
principal,
|
|
1570
|
+
url: input.url,
|
|
1571
|
+
watch: input.watch,
|
|
1572
|
+
...input.sync.length > 0 ? { sync: input.sync } : {},
|
|
1573
|
+
hash
|
|
1574
|
+
}
|
|
1575
|
+
});
|
|
1576
|
+
return {
|
|
1577
|
+
owner,
|
|
1578
|
+
url: input.url,
|
|
1579
|
+
watch: input.watch,
|
|
1580
|
+
sync: input.sync,
|
|
1581
|
+
hash
|
|
1582
|
+
};
|
|
1583
|
+
},
|
|
1584
|
+
async list() {
|
|
1585
|
+
return Object.values((await state()).byOwner).sort((a, b) => a.owner.localeCompare(b.owner)).map(viewOf);
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1040
1589
|
//#endregion
|
|
1041
1590
|
//#region src/server/schedules.ts
|
|
1042
1591
|
/** Vercel Queues delays at most 6 days: a send further out than this goes in hops. */
|
|
@@ -1132,12 +1681,16 @@ function nextSend(schedule, n, at, now) {
|
|
|
1132
1681
|
* needs `post` there now, as it would to create it. Where it can't read, the
|
|
1133
1682
|
* schedule is its own only if it was created without reading too, so a
|
|
1134
1683
|
* principal ID shared with a caller that reads doesn't reach that caller's.
|
|
1684
|
+
* A board token that pins a profile owns only what was scheduled as it (`wroteAs`).
|
|
1135
1685
|
*/
|
|
1136
|
-
const createdBy = (schedule,
|
|
1686
|
+
const createdBy = (schedule, identity) => wroteAs(identity, {
|
|
1687
|
+
principal: schedule.as.principal.id,
|
|
1688
|
+
actor: schedule.as.actor.id
|
|
1689
|
+
}) && identity.principal.access.can("post", schedule.channel) && (schedule.blind || identity.principal.access.readable(schedule.channel));
|
|
1137
1690
|
const scheduleView = (schedule, actors) => {
|
|
1138
1691
|
const author = actors.byId[schedule.as.actor.id] ?? schedule.as.actor;
|
|
1139
1692
|
return {
|
|
1140
|
-
id:
|
|
1693
|
+
id: scheduleRef(schedule.id),
|
|
1141
1694
|
channel: schedule.channel,
|
|
1142
1695
|
re: schedule.re === null ? null : ref(schedule.re),
|
|
1143
1696
|
text: schedule.text,
|
|
@@ -1408,7 +1961,10 @@ function createSchedules(options) {
|
|
|
1408
1961
|
if (every !== null && every > 316224e5) throw badRequest("`every` is at most a year");
|
|
1409
1962
|
const first = body.at !== void 0 ? Date.parse(body.at) : now + duration(body.in ?? body.every);
|
|
1410
1963
|
if (first < now - 6e4) throw badRequest("`at` is in the past");
|
|
1411
|
-
const
|
|
1964
|
+
const asked = body.until !== void 0 ? Date.parse(body.until) : body.for !== void 0 ? first + duration(body.for) : null;
|
|
1965
|
+
const expires = identity.principal.token !== void 0 ? identity.principal.expiresAt?.getTime() ?? null : null;
|
|
1966
|
+
if (expires !== null && first > expires) throw badRequest(`this token expires ${new Date(expires).toISOString()}, before the schedule's first send: schedule it with a credential that lasts`, "token_expires_first");
|
|
1967
|
+
const until = expires !== null && (asked === null || asked > expires) ? expires : asked;
|
|
1412
1968
|
if (until !== null && until < first) throw badRequest("the schedule stops before its first send");
|
|
1413
1969
|
if (first > now + 316224e5 || until !== null && until > now + 316224e5) throw badRequest("a schedule reaches at most a year out: its first send and its stop");
|
|
1414
1970
|
const { state: known } = await writes.session.state(actorsReducer);
|
|
@@ -1437,13 +1993,7 @@ function createSchedules(options) {
|
|
|
1437
1993
|
times: body.times ?? null,
|
|
1438
1994
|
as: {
|
|
1439
1995
|
author: identity.author,
|
|
1440
|
-
principal:
|
|
1441
|
-
id: identity.principal.id,
|
|
1442
|
-
kind: identity.principal.kind,
|
|
1443
|
-
handle: identity.principal.handle,
|
|
1444
|
-
...identity.principal.facts ? { facts: identity.principal.facts } : {},
|
|
1445
|
-
...identity.principal.scope !== void 0 ? { scope: identity.principal.scope } : {}
|
|
1446
|
-
},
|
|
1996
|
+
principal: storedPrincipal(identity.principal),
|
|
1447
1997
|
actor: identity.actor
|
|
1448
1998
|
},
|
|
1449
1999
|
...identity.principal.access.readable(channel) ? {} : { blind: true }
|
|
@@ -1470,7 +2020,7 @@ function createSchedules(options) {
|
|
|
1470
2020
|
const schedule = state.byId[id];
|
|
1471
2021
|
const missing = () => notFound(`schedule not found: s${id}`, "schedule_not_found");
|
|
1472
2022
|
if (!schedule) throw missing();
|
|
1473
|
-
if (!createdBy(schedule, identity
|
|
2023
|
+
if (!createdBy(schedule, identity)) authorize(identity.principal.access, "manage", schedule.channel, missing);
|
|
1474
2024
|
const canceled = schedule.next !== null;
|
|
1475
2025
|
if (canceled) await session.append({
|
|
1476
2026
|
type: "schedule.canceled",
|
|
@@ -2146,93 +2696,6 @@ function createWatchers(options) {
|
|
|
2146
2696
|
};
|
|
2147
2697
|
}
|
|
2148
2698
|
//#endregion
|
|
2149
|
-
//#region src/server/registry.ts
|
|
2150
|
-
/** A principal as a registration keeps it: enough to work out its access again at each match. */
|
|
2151
|
-
const storedPrincipal = (principal) => ({
|
|
2152
|
-
id: principal.id,
|
|
2153
|
-
kind: principal.kind,
|
|
2154
|
-
handle: principal.handle,
|
|
2155
|
-
...principal.facts ? { facts: principal.facts } : {},
|
|
2156
|
-
...principal.scope !== void 0 ? { scope: principal.scope } : {}
|
|
2157
|
-
});
|
|
2158
|
-
/** What a registration shows: the principal's token facts stay on the board. */
|
|
2159
|
-
const viewOf = ({ principal: _principal, ...view }) => view;
|
|
2160
|
-
/**
|
|
2161
|
-
* The same on both ends: a board on `http()` hashes its watchers, and whether its sync subscribes,
|
|
2162
|
-
* and the board it uses hashes what it holds.
|
|
2163
|
-
*/
|
|
2164
|
-
function watchersHash(watch, sync = []) {
|
|
2165
|
-
return createHash("sha256").update(canonicalWatchers(watch, sync)).digest("hex").slice(0, 32);
|
|
2166
|
-
}
|
|
2167
|
-
function foldRegistry(state, event) {
|
|
2168
|
-
if (event.type === "watchers.registered") {
|
|
2169
|
-
const { owner, principal, url, watch, sync, hash } = event.payload;
|
|
2170
|
-
const copied = Object.fromEntries(Object.entries(watch).map(([id, spec]) => [id, {
|
|
2171
|
-
tail: spec.tail,
|
|
2172
|
-
when: spec.when
|
|
2173
|
-
}]));
|
|
2174
|
-
state.byOwner[owner] = {
|
|
2175
|
-
owner,
|
|
2176
|
-
principal: structuredClone(principal),
|
|
2177
|
-
url,
|
|
2178
|
-
watch: copied,
|
|
2179
|
-
sync: (sync ?? []).map((entry) => ({ ...entry })),
|
|
2180
|
-
hash
|
|
2181
|
-
};
|
|
2182
|
-
}
|
|
2183
|
-
return state;
|
|
2184
|
-
}
|
|
2185
|
-
const registryReducer = reducer({
|
|
2186
|
-
contract: registryContract,
|
|
2187
|
-
name: "board.registry.v1",
|
|
2188
|
-
initialState: { byOwner: {} }
|
|
2189
|
-
}).fold(foldRegistry);
|
|
2190
|
-
/** Watchers registered with this board by boards on `http()`. */
|
|
2191
|
-
function createRegistry(options) {
|
|
2192
|
-
const server = createServer({
|
|
2193
|
-
contract: registryContract,
|
|
2194
|
-
...options.store ? { store: options.store } : {},
|
|
2195
|
-
...options.scheduler ? { scheduler: options.scheduler } : {}
|
|
2196
|
-
});
|
|
2197
|
-
const session = server.session(REGISTRY_SESSION);
|
|
2198
|
-
const state = async () => (await session.state(registryReducer)).state;
|
|
2199
|
-
return {
|
|
2200
|
-
server,
|
|
2201
|
-
state,
|
|
2202
|
-
/**
|
|
2203
|
-
* Replaces `principal`'s registration. One that changes nothing writes nothing, so asking
|
|
2204
|
-
* again is free, unless the principal's token facts changed: those decide what it may read.
|
|
2205
|
-
*/
|
|
2206
|
-
async register(principal, input) {
|
|
2207
|
-
const owner = principal.id;
|
|
2208
|
-
const hash = watchersHash(input.watch, input.sync);
|
|
2209
|
-
const current = (await state()).byOwner[owner];
|
|
2210
|
-
if (current && current.url === input.url && current.hash === hash && JSON.stringify(current.principal) === JSON.stringify(principal)) return viewOf(current);
|
|
2211
|
-
await session.append({
|
|
2212
|
-
type: "watchers.registered",
|
|
2213
|
-
payload: {
|
|
2214
|
-
owner,
|
|
2215
|
-
principal,
|
|
2216
|
-
url: input.url,
|
|
2217
|
-
watch: input.watch,
|
|
2218
|
-
...input.sync.length > 0 ? { sync: input.sync } : {},
|
|
2219
|
-
hash
|
|
2220
|
-
}
|
|
2221
|
-
});
|
|
2222
|
-
return {
|
|
2223
|
-
owner,
|
|
2224
|
-
url: input.url,
|
|
2225
|
-
watch: input.watch,
|
|
2226
|
-
sync: input.sync,
|
|
2227
|
-
hash
|
|
2228
|
-
};
|
|
2229
|
-
},
|
|
2230
|
-
async list() {
|
|
2231
|
-
return Object.values((await state()).byOwner).sort((a, b) => a.owner.localeCompare(b.owner)).map(viewOf);
|
|
2232
|
-
}
|
|
2233
|
-
};
|
|
2234
|
-
}
|
|
2235
|
-
//#endregion
|
|
2236
2699
|
//#region src/server/transport.ts
|
|
2237
2700
|
/** Addresses a wake-up never reaches: this machine, private networks, link-local, and the rest that aren't the internet. */
|
|
2238
2701
|
const NOT_PUBLIC = new BlockList();
|
|
@@ -2349,9 +2812,17 @@ function messageAt(value) {
|
|
|
2349
2812
|
if (index === null || index === 0) throw notFound(`message not found: #${value}`, "message_not_found");
|
|
2350
2813
|
return index;
|
|
2351
2814
|
}
|
|
2352
|
-
/**
|
|
2815
|
+
/**
|
|
2816
|
+
* A reply's `after`, as the index of a message. One that isn't one is 0, which names no message:
|
|
2817
|
+
* the post refuses it, after a retry that landed gets its reply back.
|
|
2818
|
+
*/
|
|
2819
|
+
const afterAt = (value) => value === void 0 ? void 0 : indexOf(value) ?? 0;
|
|
2820
|
+
/**
|
|
2821
|
+
* A schedule's ID, `s12`, as its index in session `schedules`. `12` from a client that strips the
|
|
2822
|
+
* `s`, and `ss12` from an older CLI that adds one to what this board hands out, name it too.
|
|
2823
|
+
*/
|
|
2353
2824
|
function scheduleAt(value) {
|
|
2354
|
-
const bare = value.replace(/^s/, "");
|
|
2825
|
+
const bare = value.replace(/^s{1,2}/, "");
|
|
2355
2826
|
const index = indexOf(bare);
|
|
2356
2827
|
if (index === null || index === 0) throw notFound(`schedule not found: s${bare}`, "schedule_not_found");
|
|
2357
2828
|
return index;
|
|
@@ -2426,17 +2897,6 @@ function externalIdentity(author, records) {
|
|
|
2426
2897
|
...records ? {} : { records: false }
|
|
2427
2898
|
};
|
|
2428
2899
|
}
|
|
2429
|
-
/**
|
|
2430
|
-
* A one-time code for linking an account: `vb-` and eight letters and digits that
|
|
2431
|
-
* can't be misread. Only its hash is kept.
|
|
2432
|
-
*/
|
|
2433
|
-
const CODE_LETTERS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
2434
|
-
const CODE_TTL_MS = 6e5;
|
|
2435
|
-
const newCode = () => {
|
|
2436
|
-
const letters = [...randomBytes(8)].map((byte) => CODE_LETTERS[byte % 31]).join("");
|
|
2437
|
-
return `vb-${letters.slice(0, 4)}-${letters.slice(4)}`;
|
|
2438
|
-
};
|
|
2439
|
-
const hashCode = (code) => createHash("sha256").update(code.toUpperCase()).digest("base64url");
|
|
2440
2900
|
/** The board events sync delivers: what a platform shows, and a thread's new targets. */
|
|
2441
2901
|
const DELIVERED = [
|
|
2442
2902
|
"thread.synced",
|
|
@@ -2589,10 +3049,11 @@ function build(options, binding) {
|
|
|
2589
3049
|
...options.scheduler ? { scheduler: options.scheduler } : {},
|
|
2590
3050
|
consumers: [inbound]
|
|
2591
3051
|
});
|
|
2592
|
-
const
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
3052
|
+
const access = createTrust({
|
|
3053
|
+
store: log,
|
|
3054
|
+
issuers: () => binding.issuers(),
|
|
3055
|
+
accessNow: (principal) => binding.accessNow(principal)
|
|
3056
|
+
});
|
|
2596
3057
|
const inboxes = inboxServer.session(INBOXES_SESSION);
|
|
2597
3058
|
/** The inboxes this instance knows have an `inbox.opened`, so most pushes write nothing more. */
|
|
2598
3059
|
const opened = /* @__PURE__ */ new Set();
|
|
@@ -2606,8 +3067,8 @@ function build(options, binding) {
|
|
|
2606
3067
|
* Whether `principal` may have a file: one it uploaded, or one a message it
|
|
2607
3068
|
* can read carries. A deleted message drops its files, so it no longer counts.
|
|
2608
3069
|
*/
|
|
2609
|
-
async function attachmentReadable(principal, id,
|
|
2610
|
-
if (principal.access.readsAll ||
|
|
3070
|
+
async function attachmentReadable(principal, id, uploader) {
|
|
3071
|
+
if (principal.access.readsAll || ownUpload(uploader, principal)) return true;
|
|
2611
3072
|
await reads.ensure();
|
|
2612
3073
|
return (await reads.tables.withAttachment(id)).some((message) => principal.access.readable(message.channel));
|
|
2613
3074
|
}
|
|
@@ -2648,8 +3109,8 @@ function build(options, binding) {
|
|
|
2648
3109
|
}
|
|
2649
3110
|
/** Files a write carries, as the board stored them: each one the caller may have, or 404 as if it didn't exist. */
|
|
2650
3111
|
const attachmentsFor = (principal, ids) => Promise.all([...new Set(ids ?? [])].map(async (id) => {
|
|
2651
|
-
const { attachment,
|
|
2652
|
-
if (!await attachmentReadable(principal, id,
|
|
3112
|
+
const { attachment, ...uploader } = await readUpload(blob, id);
|
|
3113
|
+
if (!await attachmentReadable(principal, id, uploader)) throw attachmentNotFound(id);
|
|
2653
3114
|
return attachment;
|
|
2654
3115
|
}));
|
|
2655
3116
|
/**
|
|
@@ -2722,6 +3183,7 @@ function build(options, binding) {
|
|
|
2722
3183
|
noSync: body.noSync,
|
|
2723
3184
|
id: body.id,
|
|
2724
3185
|
sync: body.sync,
|
|
3186
|
+
after: afterAt(body.after),
|
|
2725
3187
|
...schedule && !fromRequest(caller) ? {
|
|
2726
3188
|
schedule,
|
|
2727
3189
|
lenientMentions: true
|
|
@@ -2749,10 +3211,7 @@ function build(options, binding) {
|
|
|
2749
3211
|
audience: url,
|
|
2750
3212
|
team_id: teamId,
|
|
2751
3213
|
project_id: id,
|
|
2752
|
-
sync:
|
|
2753
|
-
key,
|
|
2754
|
-
platform
|
|
2755
|
-
}))
|
|
3214
|
+
sync: await syncEntries()
|
|
2756
3215
|
};
|
|
2757
3216
|
},
|
|
2758
3217
|
async me(caller) {
|
|
@@ -2778,6 +3237,12 @@ function build(options, binding) {
|
|
|
2778
3237
|
cursor: ref(index)
|
|
2779
3238
|
};
|
|
2780
3239
|
},
|
|
3240
|
+
async createChannel(caller, { path }) {
|
|
3241
|
+
return writeChannel(writes, {
|
|
3242
|
+
identity: await identityOf(caller),
|
|
3243
|
+
path
|
|
3244
|
+
});
|
|
3245
|
+
},
|
|
2781
3246
|
async actors(caller) {
|
|
2782
3247
|
const { principal, actor } = await identityOf(caller);
|
|
2783
3248
|
const { state, index } = await session.state(actorsReducer);
|
|
@@ -2788,31 +3253,7 @@ function build(options, binding) {
|
|
|
2788
3253
|
seen = (id) => ids.has(id);
|
|
2789
3254
|
}
|
|
2790
3255
|
return {
|
|
2791
|
-
actors: Object.values(state.byId).filter((a) => seen === null || seen(a.id)).sort((a, b) => a.handle.localeCompare(b.handle)).map(({ host: _host, ...a }) =>
|
|
2792
|
-
const person = personView(state, a.id);
|
|
2793
|
-
return person ? {
|
|
2794
|
-
...a,
|
|
2795
|
-
person
|
|
2796
|
-
} : a;
|
|
2797
|
-
}),
|
|
2798
|
-
cursor: ref(index)
|
|
2799
|
-
};
|
|
2800
|
-
},
|
|
2801
|
-
async identity(caller) {
|
|
2802
|
-
const { principal } = await identityOf(caller);
|
|
2803
|
-
const { state, index } = await session.state(actorsReducer);
|
|
2804
|
-
const links = linkedTo(state, principal.id).map((id) => ({
|
|
2805
|
-
actor: id,
|
|
2806
|
-
handle: state.byId[id]?.handle ?? null,
|
|
2807
|
-
name: state.byId[id]?.name ?? null,
|
|
2808
|
-
method: state.links[id].method,
|
|
2809
|
-
at: state.links[id].at
|
|
2810
|
-
}));
|
|
2811
|
-
const unlinked = (await linkable()).filter((entry) => !links.some((link) => link.actor.startsWith(`${entry.platform}:user:`))).map((entry) => entry.key);
|
|
2812
|
-
return {
|
|
2813
|
-
principal: principal.id,
|
|
2814
|
-
links,
|
|
2815
|
-
unlinked,
|
|
3256
|
+
actors: Object.values(state.byId).filter((a) => seen === null || seen(a.id)).sort((a, b) => a.handle.localeCompare(b.handle)).map(({ host: _host, ...a }) => a),
|
|
2816
3257
|
cursor: ref(index)
|
|
2817
3258
|
};
|
|
2818
3259
|
},
|
|
@@ -2880,25 +3321,27 @@ function build(options, binding) {
|
|
|
2880
3321
|
async post(caller, parsed) {
|
|
2881
3322
|
return postNow(caller, await identityOf(caller), parsed);
|
|
2882
3323
|
},
|
|
2883
|
-
async edit(caller, { id, text }) {
|
|
3324
|
+
async edit(caller, { id, text, revision }) {
|
|
2884
3325
|
const identity = await identityOf(caller);
|
|
2885
|
-
const authorized = await authorizeOwn(writes, identity
|
|
3326
|
+
const authorized = await authorizeOwn(writes, identity, messageAt(id), "edit");
|
|
2886
3327
|
const { unknownMentions } = await writeEdit(writes, {
|
|
2887
3328
|
identity,
|
|
2888
3329
|
authorized,
|
|
2889
|
-
text
|
|
3330
|
+
text,
|
|
3331
|
+
revision
|
|
2890
3332
|
});
|
|
2891
3333
|
return {
|
|
2892
3334
|
ok: true,
|
|
2893
3335
|
...unknownMentions ? { unknownMentions } : {}
|
|
2894
3336
|
};
|
|
2895
3337
|
},
|
|
2896
|
-
async delete(caller, { id }) {
|
|
3338
|
+
async delete(caller, { id, revision }) {
|
|
2897
3339
|
const identity = await identityOf(caller);
|
|
2898
|
-
const authorized = await authorizeOwn(writes, identity
|
|
3340
|
+
const authorized = await authorizeOwn(writes, identity, messageAt(id), "delete");
|
|
2899
3341
|
await writeDelete(writes, {
|
|
2900
3342
|
identity,
|
|
2901
|
-
authorized
|
|
3343
|
+
authorized,
|
|
3344
|
+
revision
|
|
2902
3345
|
});
|
|
2903
3346
|
return { ok: true };
|
|
2904
3347
|
},
|
|
@@ -2913,14 +3356,15 @@ function build(options, binding) {
|
|
|
2913
3356
|
});
|
|
2914
3357
|
return { ok: true };
|
|
2915
3358
|
},
|
|
2916
|
-
async label(caller, { id, set, unset }) {
|
|
3359
|
+
async label(caller, { id, set, unset, if: expected }) {
|
|
2917
3360
|
const identity = await identityOf(caller);
|
|
2918
3361
|
const authorized = await authorizeMessage(writes, identity.principal.access, "label", messageAt(id));
|
|
2919
3362
|
await writeLabels(writes, {
|
|
2920
3363
|
identity,
|
|
2921
3364
|
authorized,
|
|
2922
3365
|
set,
|
|
2923
|
-
unset
|
|
3366
|
+
unset,
|
|
3367
|
+
if: expected
|
|
2924
3368
|
});
|
|
2925
3369
|
return { ok: true };
|
|
2926
3370
|
},
|
|
@@ -2941,7 +3385,10 @@ function build(options, binding) {
|
|
|
2941
3385
|
const at = messageAt(query.re);
|
|
2942
3386
|
root = (await authorizeMessage(writes, principal.access, "read", at)).message.re ?? at;
|
|
2943
3387
|
}
|
|
2944
|
-
const visible = (schedule) => principal.access.readable(schedule.channel) || createdBy(schedule,
|
|
3388
|
+
const visible = (schedule) => principal.access.readable(schedule.channel) || createdBy(schedule, {
|
|
3389
|
+
principal,
|
|
3390
|
+
actor
|
|
3391
|
+
});
|
|
2945
3392
|
return { schedules: (await schedules.list({
|
|
2946
3393
|
...query,
|
|
2947
3394
|
...root !== void 0 ? { root } : {}
|
|
@@ -2955,6 +3402,12 @@ function build(options, binding) {
|
|
|
2955
3402
|
schedule: blindSchedule(answer.schedule, identity.principal.access, identity.actor.id)
|
|
2956
3403
|
};
|
|
2957
3404
|
},
|
|
3405
|
+
grants: async (caller) => access.grants(await identityOf(caller)),
|
|
3406
|
+
createGrant: async (caller, body) => access.createGrant(await identityOf(caller), body),
|
|
3407
|
+
revokeGrant: async (caller, input) => access.revokeGrant(await identityOf(caller), input),
|
|
3408
|
+
tokens: async (caller) => access.tokens(await identityOf(caller)),
|
|
3409
|
+
createToken: async (caller, body) => access.createToken(await identityOf(caller), body),
|
|
3410
|
+
revokeToken: async (caller, input) => access.revokeToken(await identityOf(caller), input),
|
|
2958
3411
|
async upload(caller, { data, name, type }) {
|
|
2959
3412
|
const { principal } = await identityOf(caller);
|
|
2960
3413
|
if (!principal.access.anywhere("post") && !principal.access.anywhere("sync")) throw forbidden("this token can't post anywhere, so it can't upload");
|
|
@@ -2963,7 +3416,8 @@ function build(options, binding) {
|
|
|
2963
3416
|
data,
|
|
2964
3417
|
name,
|
|
2965
3418
|
type,
|
|
2966
|
-
uploadedBy: principal.id
|
|
3419
|
+
uploadedBy: principal.id,
|
|
3420
|
+
uploadedWith: await access.chain(principal)
|
|
2967
3421
|
}, max) };
|
|
2968
3422
|
},
|
|
2969
3423
|
async uploadStart(caller, { name, type, size }) {
|
|
@@ -2973,17 +3427,18 @@ function build(options, binding) {
|
|
|
2973
3427
|
name: name ?? null,
|
|
2974
3428
|
type: type ?? null,
|
|
2975
3429
|
size,
|
|
2976
|
-
uploadedBy: principal.id
|
|
3430
|
+
uploadedBy: principal.id,
|
|
3431
|
+
uploadedWith: await access.chain(principal)
|
|
2977
3432
|
});
|
|
2978
3433
|
},
|
|
2979
3434
|
async uploadFinish(caller, { id }) {
|
|
2980
3435
|
const { principal } = await identityOf(caller);
|
|
2981
|
-
return { attachment: await finishUpload(blob, id, principal
|
|
3436
|
+
return { attachment: await finishUpload(blob, id, principal) };
|
|
2982
3437
|
},
|
|
2983
3438
|
async attachment(caller, { id }) {
|
|
2984
3439
|
const { principal } = await identityOf(caller);
|
|
2985
|
-
const { attachment,
|
|
2986
|
-
if (!await attachmentReadable(principal, attachment.id,
|
|
3440
|
+
const { attachment, ...uploader } = await readUpload(blob, id);
|
|
3441
|
+
if (!await attachmentReadable(principal, attachment.id, uploader)) throw attachmentNotFound(attachment.id);
|
|
2987
3442
|
return {
|
|
2988
3443
|
attachment,
|
|
2989
3444
|
data: await attachmentBody(blob, attachment.id)
|
|
@@ -3194,6 +3649,7 @@ function build(options, binding) {
|
|
|
3194
3649
|
origin: body.origin,
|
|
3195
3650
|
from: body.from,
|
|
3196
3651
|
...body.origin && !ownSync(caller) ? { importedBy: principal.id } : {},
|
|
3652
|
+
after: afterAt(body.after),
|
|
3197
3653
|
lenientMentions: true,
|
|
3198
3654
|
attachments,
|
|
3199
3655
|
links: await linksOf(principal, body.links)
|
|
@@ -3222,7 +3678,8 @@ function build(options, binding) {
|
|
|
3222
3678
|
authorized: proof,
|
|
3223
3679
|
text,
|
|
3224
3680
|
from: body.from,
|
|
3225
|
-
asAuthor
|
|
3681
|
+
asAuthor,
|
|
3682
|
+
revision: body.revision
|
|
3226
3683
|
});
|
|
3227
3684
|
return ref(found.id);
|
|
3228
3685
|
};
|
|
@@ -3240,7 +3697,8 @@ function build(options, binding) {
|
|
|
3240
3697
|
identity: author,
|
|
3241
3698
|
authorized: await authorized("post"),
|
|
3242
3699
|
from: body.from,
|
|
3243
|
-
asAuthor
|
|
3700
|
+
asAuthor,
|
|
3701
|
+
revision: body.revision
|
|
3244
3702
|
});
|
|
3245
3703
|
else if (body.op === "react") await writeReaction(writes, {
|
|
3246
3704
|
identity: author,
|
|
@@ -3253,7 +3711,8 @@ function build(options, binding) {
|
|
|
3253
3711
|
identity: author,
|
|
3254
3712
|
authorized: await authorized("label"),
|
|
3255
3713
|
set: body.set,
|
|
3256
|
-
unset: body.unset
|
|
3714
|
+
unset: body.unset,
|
|
3715
|
+
if: body.if
|
|
3257
3716
|
});
|
|
3258
3717
|
else await writeLinks(writes, {
|
|
3259
3718
|
identity: author,
|
|
@@ -3337,12 +3796,6 @@ function build(options, binding) {
|
|
|
3337
3796
|
const { tables } = store;
|
|
3338
3797
|
let thread = null;
|
|
3339
3798
|
let message = null;
|
|
3340
|
-
let holder = null;
|
|
3341
|
-
if (query.code !== void 0) {
|
|
3342
|
-
const issued = (await codes.state(identityCodesReducer)).state[hashCode(query.code)];
|
|
3343
|
-
const keys = new Set((await entriesOf(caller, principal.id)).map((entry) => entry.key));
|
|
3344
|
-
if (issued && issued.usedBy === null && Date.parse(issued.expiresAt) >= Date.now() && keys.has(issued.sync)) holder = issued.principal;
|
|
3345
|
-
}
|
|
3346
3799
|
if (query.post !== void 0) {
|
|
3347
3800
|
const landed = await reads.byEventId(importId(caller, principal.id, query.post), signal);
|
|
3348
3801
|
if (landed && principal.access.can("sync", landed.channel)) message = ref(landed.id);
|
|
@@ -3361,22 +3814,9 @@ function build(options, binding) {
|
|
|
3361
3814
|
if (found && principal.access.can("sync", found.channel)) message = ref(found.id);
|
|
3362
3815
|
}
|
|
3363
3816
|
}
|
|
3364
|
-
const { state: actors } = query.account !== void 0 || query.person !== void 0 ? await session.state(actorsReducer) : { state: null };
|
|
3365
|
-
const platforms = actors ? (await entriesOf(caller, principal.id)).map((entry) => `${entry.platform}:`) : [];
|
|
3366
|
-
const ours = (account) => platforms.some((platform) => account.startsWith(platform));
|
|
3367
|
-
const link = query.account !== void 0 && ours(query.account) ? actors?.links[query.account] : void 0;
|
|
3368
3817
|
return {
|
|
3369
3818
|
thread,
|
|
3370
|
-
message
|
|
3371
|
-
person: link ? {
|
|
3372
|
-
id: link.to,
|
|
3373
|
-
method: link.method
|
|
3374
|
-
} : null,
|
|
3375
|
-
accounts: query.person === void 0 || !actors ? [] : linkedTo(actors, query.person).filter(ours).map((id) => ({
|
|
3376
|
-
id,
|
|
3377
|
-
method: actors.links[id].method
|
|
3378
|
-
})),
|
|
3379
|
-
holder
|
|
3819
|
+
message
|
|
3380
3820
|
};
|
|
3381
3821
|
},
|
|
3382
3822
|
async syncCopies(caller, query) {
|
|
@@ -3394,14 +3834,6 @@ function build(options, binding) {
|
|
|
3394
3834
|
}
|
|
3395
3835
|
return { copies: out };
|
|
3396
3836
|
},
|
|
3397
|
-
async syncRoots(caller, { by, since }) {
|
|
3398
|
-
const { principal } = await needSync(caller, "look up a sync");
|
|
3399
|
-
await reads.ensure();
|
|
3400
|
-
const roots = (await store.tables.recentRoots(by, since)).filter((root) => principal.access.can("sync", root.channel));
|
|
3401
|
-
const { state: actors } = await session.state(actorsReducer);
|
|
3402
|
-
const reader = await reads.reader(principal.access, roots);
|
|
3403
|
-
return { roots: roots.map((root) => view(root, actors, reader)) };
|
|
3404
|
-
},
|
|
3405
3837
|
async inboxPush(caller, { key, events }) {
|
|
3406
3838
|
const { principal } = await needSync(caller, "push to an inbox", true);
|
|
3407
3839
|
for (const { event } of events) for (const location of [
|
|
@@ -3504,73 +3936,6 @@ function build(options, binding) {
|
|
|
3504
3936
|
});
|
|
3505
3937
|
}
|
|
3506
3938
|
return { events: entries.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, limit ?? 50) };
|
|
3507
|
-
},
|
|
3508
|
-
async syncCode(caller, { sync }) {
|
|
3509
|
-
const identity = await identityOf(caller);
|
|
3510
|
-
if (identity.principal.kind !== "person") throw forbidden("only people link platform accounts");
|
|
3511
|
-
const entry = (await linkable()).find((candidate) => candidate.key === sync);
|
|
3512
|
-
if (!entry) throw notFound(`no sync entry named ${sync}`);
|
|
3513
|
-
const principal = identity.principal.id;
|
|
3514
|
-
const code = newCode();
|
|
3515
|
-
const expiresAt = new Date(Date.now() + CODE_TTL_MS).toISOString();
|
|
3516
|
-
await codes.append({
|
|
3517
|
-
type: "identity.code_issued",
|
|
3518
|
-
payload: {
|
|
3519
|
-
hash: hashCode(code),
|
|
3520
|
-
principal,
|
|
3521
|
-
sync,
|
|
3522
|
-
expiresAt
|
|
3523
|
-
}
|
|
3524
|
-
});
|
|
3525
|
-
const signIn = entry.signIn && entry.url !== null ? `${entry.url}/identity/sign-in?${new URLSearchParams({
|
|
3526
|
-
sync,
|
|
3527
|
-
code
|
|
3528
|
-
})}` : null;
|
|
3529
|
-
return {
|
|
3530
|
-
code,
|
|
3531
|
-
expiresAt,
|
|
3532
|
-
...signIn ? { signIn } : {}
|
|
3533
|
-
};
|
|
3534
|
-
},
|
|
3535
|
-
async syncAccount(caller, body) {
|
|
3536
|
-
const { principal } = await needSync(caller, "link accounts", true);
|
|
3537
|
-
const { account } = body;
|
|
3538
|
-
await speaksFor(caller, principal.id, account, body.sync);
|
|
3539
|
-
const hash = hashCode(body.code);
|
|
3540
|
-
const issued = (await codes.state(identityCodesReducer)).state[hash];
|
|
3541
|
-
const bad = () => notFound("that code isn't live: it expired, was used, or was issued for another sync entry", "bad_code");
|
|
3542
|
-
if (!issued || issued.sync !== body.sync) throw bad();
|
|
3543
|
-
const usedHere = (used) => used?.usedBy === account.id && used.usedIn === (body.message ?? null);
|
|
3544
|
-
if (issued.usedBy === null ? Date.parse(issued.expiresAt) < Date.now() : !usedHere(issued)) throw bad();
|
|
3545
|
-
await codes.append({
|
|
3546
|
-
type: "identity.code_used",
|
|
3547
|
-
id: `used:${hash}`,
|
|
3548
|
-
payload: {
|
|
3549
|
-
hash,
|
|
3550
|
-
actor: account.id,
|
|
3551
|
-
...body.message !== void 0 ? { message: body.message } : {}
|
|
3552
|
-
}
|
|
3553
|
-
});
|
|
3554
|
-
if (!usedHere((await codes.state(identityCodesReducer)).state[hash])) throw bad();
|
|
3555
|
-
const linked = externalIdentity(account, recordsActors(caller, principal));
|
|
3556
|
-
return {
|
|
3557
|
-
person: issued.principal,
|
|
3558
|
-
link: await link(linked, issued.principal, body.method ?? "code", `link:${hash}`)
|
|
3559
|
-
};
|
|
3560
|
-
},
|
|
3561
|
-
async syncUnlink(caller, { account }) {
|
|
3562
|
-
const { principal, author } = await identityOf(caller);
|
|
3563
|
-
const { state } = await session.state(actorsReducer);
|
|
3564
|
-
const found = state.links[account];
|
|
3565
|
-
if (!found || found.to !== principal.id && !principal.access.everywhere("sync")) throw notFound(`${account} isn't linked`, "not_linked");
|
|
3566
|
-
await session.append({
|
|
3567
|
-
type: "actor.unlinked",
|
|
3568
|
-
payload: {
|
|
3569
|
-
actor: account,
|
|
3570
|
-
by: author
|
|
3571
|
-
}
|
|
3572
|
-
});
|
|
3573
|
-
return { ok: true };
|
|
3574
3939
|
}
|
|
3575
3940
|
};
|
|
3576
3941
|
/**
|
|
@@ -3590,35 +3955,27 @@ function build(options, binding) {
|
|
|
3590
3955
|
return ((await registry.state()).byOwner[principal]?.sync ?? []).filter((entry) => !binding.sync.some((own) => own.key === entry.key));
|
|
3591
3956
|
}
|
|
3592
3957
|
/**
|
|
3593
|
-
* The sync entries this board
|
|
3594
|
-
*
|
|
3595
|
-
* A key its own sync has is its own. A key two registered syncs claim is neither's to sign people
|
|
3596
|
-
* in for: a person links it by posting the code where either syncs.
|
|
3958
|
+
* The sync entries this board lists in its discovery: its own sync's, then those of the syncs
|
|
3959
|
+
* registered here that may still sync. A key its own sync has is its own.
|
|
3597
3960
|
*/
|
|
3598
|
-
async function
|
|
3961
|
+
async function syncEntries() {
|
|
3599
3962
|
const found = /* @__PURE__ */ new Map();
|
|
3600
|
-
for (const
|
|
3601
|
-
|
|
3602
|
-
|
|
3963
|
+
for (const { key, platform } of binding.sync) found.set(key, {
|
|
3964
|
+
key,
|
|
3965
|
+
platform
|
|
3603
3966
|
});
|
|
3604
|
-
const claimed = /* @__PURE__ */ new Map();
|
|
3605
3967
|
for (const registration of Object.values((await registry.state()).byOwner)) {
|
|
3606
3968
|
if (registration.sync.length === 0) continue;
|
|
3607
3969
|
if (!(await binding.accessNow(registration.principal))?.anywhere("sync")) continue;
|
|
3608
|
-
for (const
|
|
3609
|
-
|
|
3610
|
-
|
|
3970
|
+
for (const { key, platform } of registration.sync) if (!found.has(key)) found.set(key, {
|
|
3971
|
+
key,
|
|
3972
|
+
platform
|
|
3611
3973
|
});
|
|
3612
3974
|
}
|
|
3613
|
-
for (const [key, claims] of claimed) found.set(key, claims.length === 1 ? claims[0] : {
|
|
3614
|
-
...claims[0],
|
|
3615
|
-
signIn: false,
|
|
3616
|
-
url: null
|
|
3617
|
-
});
|
|
3618
3975
|
return [...found.values()];
|
|
3619
3976
|
}
|
|
3620
3977
|
/**
|
|
3621
|
-
* Whether a caller may write as
|
|
3978
|
+
* Whether a caller may write as `author`: a platform user, never anyone this board
|
|
3622
3979
|
* signs in, on a platform it syncs: the one of entry `from`, when it names one.
|
|
3623
3980
|
*/
|
|
3624
3981
|
async function speaksFor(caller, principal, author, from) {
|
|
@@ -3787,43 +4144,6 @@ function build(options, binding) {
|
|
|
3787
4144
|
});
|
|
3788
4145
|
opened.add(channel);
|
|
3789
4146
|
}
|
|
3790
|
-
/** Records that platform user `identity` is `to`, unless it already is. `id` makes a retry a no-op. */
|
|
3791
|
-
async function link(identity, to, method, id) {
|
|
3792
|
-
const { actor: account, author: by } = identity;
|
|
3793
|
-
const { state: actors } = await session.state(actorsReducer);
|
|
3794
|
-
const seen = identity.records === false ? null : actorSeen(account, actors);
|
|
3795
|
-
const existing = actors.links[account.id];
|
|
3796
|
-
if (existing?.to === to && !id) {
|
|
3797
|
-
if (seen) await session.append(seen);
|
|
3798
|
-
return {
|
|
3799
|
-
actor: account.id,
|
|
3800
|
-
handle: account.handle,
|
|
3801
|
-
name: account.name,
|
|
3802
|
-
method: existing.method,
|
|
3803
|
-
at: existing.at
|
|
3804
|
-
};
|
|
3805
|
-
}
|
|
3806
|
-
await session.append(...seen ? [{
|
|
3807
|
-
...seen,
|
|
3808
|
-
...id ? { id: `${id}:seen` } : {}
|
|
3809
|
-
}] : [], {
|
|
3810
|
-
type: "actor.linked",
|
|
3811
|
-
...id ? { id } : {},
|
|
3812
|
-
payload: {
|
|
3813
|
-
actor: account.id,
|
|
3814
|
-
to,
|
|
3815
|
-
method,
|
|
3816
|
-
by
|
|
3817
|
-
}
|
|
3818
|
-
});
|
|
3819
|
-
return {
|
|
3820
|
-
actor: account.id,
|
|
3821
|
-
handle: account.handle,
|
|
3822
|
-
name: account.name,
|
|
3823
|
-
method,
|
|
3824
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3825
|
-
};
|
|
3826
|
-
}
|
|
3827
4147
|
const servers = [
|
|
3828
4148
|
server,
|
|
3829
4149
|
inboxServer,
|
|
@@ -3834,6 +4154,7 @@ function build(options, binding) {
|
|
|
3834
4154
|
return {
|
|
3835
4155
|
methods,
|
|
3836
4156
|
schedules,
|
|
4157
|
+
trust: access.trust,
|
|
3837
4158
|
/** The scheduler's callback, for every A2 server behind the board. */
|
|
3838
4159
|
scheduler: options.scheduler ? options.scheduler.handler(...servers) : async (_request) => toErrorResponse(notFound("this board has no scheduler")),
|
|
3839
4160
|
servers,
|
|
@@ -3879,6 +4200,11 @@ function a2(options = {}) {
|
|
|
3879
4200
|
...Object.fromEntries(METHODS.map((name) => [name, async (caller, input, signal) => (await ready()).methods[name](caller, input, signal)])),
|
|
3880
4201
|
routes: [],
|
|
3881
4202
|
keeps: true,
|
|
4203
|
+
trust: {
|
|
4204
|
+
grants: async () => (await ready()).trust.grants(),
|
|
4205
|
+
token: async (hash) => (await ready()).trust.token(hash),
|
|
4206
|
+
live: async (id) => (await ready()).trust.live(id)
|
|
4207
|
+
},
|
|
3882
4208
|
bind(given) {
|
|
3883
4209
|
if (binding) throw new Error("a2: this backend already serves a board: create one a2() per board()");
|
|
3884
4210
|
binding = given;
|