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