@joe-broadhead/messaging 0.7.0 → 0.7.1
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/CHANGELOG.md +45 -0
- package/dist/bin/messaging-mcp.js +649 -12
- package/dist/bin/messaging-relay.js +774 -20
- package/dist/bin/messaging.js +748 -15
- package/dist/bin/messagingd.js +785 -18
- package/dist/lib/modules/core/agent-tools.js +23 -4
- package/dist/lib/modules/core/conversation/context.js +80 -0
- package/dist/lib/modules/core/conversation/event.js +100 -0
- package/dist/lib/modules/core/conversation/ids.js +38 -0
- package/dist/lib/modules/core/conversation/link.js +35 -0
- package/dist/lib/modules/core/conversation/log.js +766 -0
- package/dist/lib/modules/core/conversation/ratchet.js +243 -0
- package/dist/lib/modules/core/conversation/seal.js +198 -0
- package/dist/lib/modules/core/index.js +10 -1
- package/dist/lib/modules/core/schema/common.js +56 -0
- package/dist/lib/modules/core/schema/conversation.js +292 -0
- package/dist/lib/modules/core/schema/errors.js +39 -0
- package/dist/lib/modules/core/schema/index.js +1 -0
- package/dist/lib/modules/core/testing.js +1 -0
- package/dist/lib/modules/core/version.js +1 -1
- package/dist/spec/messaging-v1.md +324 -6
- package/dist/types/core/agent-tools.d.ts +20 -2
- package/dist/types/core/conversation/context.d.ts +56 -0
- package/dist/types/core/conversation/event.d.ts +36 -0
- package/dist/types/core/conversation/ids.d.ts +18 -0
- package/dist/types/core/conversation/link.d.ts +16 -0
- package/dist/types/core/conversation/log.d.ts +142 -0
- package/dist/types/core/conversation/ratchet.d.ts +95 -0
- package/dist/types/core/conversation/seal.d.ts +47 -0
- package/dist/types/core/index.d.ts +8 -1
- package/dist/types/core/schema/common.d.ts +56 -0
- package/dist/types/core/schema/conversation.d.ts +1657 -0
- package/dist/types/core/schema/envelope.d.ts +6 -0
- package/dist/types/core/schema/errors.d.ts +38 -0
- package/dist/types/core/schema/index.d.ts +1 -0
- package/dist/types/core/schema/ipc/device.d.ts +6 -0
- package/dist/types/core/schema/ipc/shapes.d.ts +3 -0
- package/dist/types/core/schema/ipc/surfaces.d.ts +6 -0
- package/dist/types/core/schema/ipc.d.ts +15 -0
- package/dist/types/core/schema/relay.d.ts +12 -0
- package/dist/types/core/testing.d.ts +1 -0
- package/dist/types/core/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conversation log as a pure reducer (spec §16.7). `ConvLog.apply` checks one relay-sequenced entry against the
|
|
3
|
+
* state of every earlier one, the chain, the signature and the authority rules, and appends it; a failed `apply`
|
|
4
|
+
* changes nothing. Membership, roles, epochs, settings, seats and the agent-context policy are a function of the
|
|
5
|
+
* log: `at(seq)` gives them at every position, and every member with the same prefix computes the same.
|
|
6
|
+
*
|
|
7
|
+
* Message rules (edit, delete, react, ...) need the sealed body: a member passes the opened body with the entry.
|
|
8
|
+
* Without it (the relay, or a device without the key) the header rules still run.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "../../vendor/zod/index.js";
|
|
11
|
+
import { b64u } from "../b64u.js";
|
|
12
|
+
import { asMessagingError, parseWith } from "../check.js";
|
|
13
|
+
import { canonicalize } from "../jcs.js";
|
|
14
|
+
import { Hash as HashSchema, LIMITS } from "../schema/common.js";
|
|
15
|
+
import { ConvHead as ConvHeadSchema, ConvId as ConvIdSchema, MsgBody as MsgBodySchema, } from "../schema/conversation.js";
|
|
16
|
+
import { MessagingError } from "../schema/errors.js";
|
|
17
|
+
import { RealmId as RealmIdSchema } from "../schema/realm.js";
|
|
18
|
+
import { admitConvEvent, convEntryHash, convEventSigned, convGenesisHash, convLinkHash, deepFreeze } from "./event.js";
|
|
19
|
+
import { dmConvId, participantAgent, participantOf, participantPerson, selfConvId } from "./ids.js";
|
|
20
|
+
const STRICTNESS = { none: 0, seats: 1, members: 2 };
|
|
21
|
+
const BRIDGE_STRICTNESS = { none: 0, admins: 1, members: 2 };
|
|
22
|
+
const isAdminRole = (role) => role === "owner" || role === "admin";
|
|
23
|
+
const fail = (code, message) => {
|
|
24
|
+
throw new MessagingError(code, message);
|
|
25
|
+
};
|
|
26
|
+
function draftOf(s) {
|
|
27
|
+
return {
|
|
28
|
+
...s,
|
|
29
|
+
participants: new Map(s.participants.map((p) => [p.p, { ...p }])),
|
|
30
|
+
votes: new Map(s.votes.map(({ person, ...vote }) => [person, vote])),
|
|
31
|
+
rekeyDue: new Set(s.rekeyDue),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function freezeDraft(d) {
|
|
35
|
+
const participants = [...d.participants.values()].sort((a, b) => (a.p < b.p ? -1 : a.p > b.p ? 1 : 0));
|
|
36
|
+
const persons = new Set(participants.map((p) => p.person));
|
|
37
|
+
for (const person of [...d.votes.keys()])
|
|
38
|
+
if (!persons.has(person))
|
|
39
|
+
d.votes.delete(person);
|
|
40
|
+
const votes = [...d.votes].sort(([a], [b]) => (a < b ? -1 : 1)).map(([person, vote]) => ({ person, ...vote }));
|
|
41
|
+
const voted = d.kind === "dm" || d.kind === "group";
|
|
42
|
+
const agentContext = voted
|
|
43
|
+
? votes.reduce((min, v) => (v.agentContext !== undefined && STRICTNESS[v.agentContext] < STRICTNESS[min] ? v.agentContext : min), "members")
|
|
44
|
+
: d.agentContext;
|
|
45
|
+
const bridges = voted
|
|
46
|
+
? [...persons].reduce((min, person) => {
|
|
47
|
+
const vote = d.votes.get(person)?.bridges ?? "none";
|
|
48
|
+
return BRIDGE_STRICTNESS[vote] < BRIDGE_STRICTNESS[min] ? vote : min;
|
|
49
|
+
}, "members")
|
|
50
|
+
: d.bridges;
|
|
51
|
+
const snapshot = { ...d, participants, votes, agentContext, bridges, rekeyDue: [...d.rekeyDue].sort() };
|
|
52
|
+
if (snapshot.visibility === undefined)
|
|
53
|
+
delete snapshot.visibility;
|
|
54
|
+
if (snapshot.org === undefined)
|
|
55
|
+
delete snapshot.org;
|
|
56
|
+
return deepFreeze(snapshot);
|
|
57
|
+
}
|
|
58
|
+
function participantFrom(seat, state, since) {
|
|
59
|
+
const person = participantPerson(seat.p);
|
|
60
|
+
const agent = participantAgent(seat.p);
|
|
61
|
+
const record = { p: seat.p, person, role: seat.role, state, since };
|
|
62
|
+
if (agent !== undefined)
|
|
63
|
+
record.agent = agent;
|
|
64
|
+
if (seat.device !== undefined)
|
|
65
|
+
record.device = seat.device;
|
|
66
|
+
if (seat.respondTo !== undefined)
|
|
67
|
+
record.respondTo = seat.respondTo;
|
|
68
|
+
return record;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A verified conversation log. Every method throws only MessagingError. Entries are kept as frozen copies of the
|
|
72
|
+
* received JSON, so hashes cover exactly what was received.
|
|
73
|
+
*/
|
|
74
|
+
export class ConvLog {
|
|
75
|
+
cid;
|
|
76
|
+
realm;
|
|
77
|
+
/** h(0), h(1), ...; an expired entry keeps its hash. */
|
|
78
|
+
#hashes;
|
|
79
|
+
#entries = [];
|
|
80
|
+
/** seq of each op entry, ascending. */
|
|
81
|
+
#ops = [];
|
|
82
|
+
/** seqs of entries whose content expired: an op head must never name one. */
|
|
83
|
+
#expired = new Set();
|
|
84
|
+
#snapshots = [];
|
|
85
|
+
#messages = new Map();
|
|
86
|
+
#pinned = 0;
|
|
87
|
+
constructor(cid, realm) {
|
|
88
|
+
this.cid = cid;
|
|
89
|
+
this.realm = realm;
|
|
90
|
+
this.#hashes = [convGenesisHash(cid)];
|
|
91
|
+
}
|
|
92
|
+
/** An empty log of `cid` in `realm`: its head is {0, h(0)}. */
|
|
93
|
+
static open(cid, realm) {
|
|
94
|
+
try {
|
|
95
|
+
return new ConvLog(parseWith(ConvIdSchema, cid, "conversation id"), parseWith(RealmIdSchema, realm, "realm"));
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
throw asMessagingError(error, "conversation log");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Replays `entries` (each with the opened body of a `msg`, when the caller has it). Throws the first failure. */
|
|
102
|
+
static fromEntries(cid, realm, entries) {
|
|
103
|
+
const log = ConvLog.open(cid, realm);
|
|
104
|
+
for (const { entry, body } of entries)
|
|
105
|
+
log.apply(entry, body === undefined ? {} : { body });
|
|
106
|
+
return log;
|
|
107
|
+
}
|
|
108
|
+
head() {
|
|
109
|
+
const seq = this.#hashes.length - 1;
|
|
110
|
+
return { seq, hash: this.#hashes[seq] };
|
|
111
|
+
}
|
|
112
|
+
/** The last op's seq and hash, or {0, h(0)}: what an author puts in `opHead`. */
|
|
113
|
+
opHead() {
|
|
114
|
+
const seq = this.#ops[this.#ops.length - 1] ?? 0;
|
|
115
|
+
return { seq, hash: this.#hashes[seq] };
|
|
116
|
+
}
|
|
117
|
+
/** h(seq), for 0 ≤ seq ≤ head. */
|
|
118
|
+
hashAt(seq) {
|
|
119
|
+
return Number.isSafeInteger(seq) && seq >= 0 ? this.#hashes[seq] : undefined;
|
|
120
|
+
}
|
|
121
|
+
entries() {
|
|
122
|
+
return [...this.#entries];
|
|
123
|
+
}
|
|
124
|
+
/** The state at the head; undefined before `create`. */
|
|
125
|
+
state() {
|
|
126
|
+
return this.#snapshots[this.#snapshots.length - 1]?.snapshot;
|
|
127
|
+
}
|
|
128
|
+
/** The state after the entry at `seq`, as a log holding only entries 1..seq has it. */
|
|
129
|
+
at(seq) {
|
|
130
|
+
let found;
|
|
131
|
+
for (const s of this.#snapshots) {
|
|
132
|
+
if (s.seq > seq)
|
|
133
|
+
break;
|
|
134
|
+
found = s.snapshot;
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
/** What a member knows of the event `id`. */
|
|
139
|
+
message(id) {
|
|
140
|
+
const m = this.#messages.get(id);
|
|
141
|
+
return m && { ...m, reactions: m.reactions.map((r) => ({ key: r.key, by: [...r.by] })) };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Gossip (spec §16.4): `ahead` past the head (fetch up to it first); `consistent` when h(seq) is ours; `diverged`
|
|
145
|
+
* when this log has another hash at that seq, a fork whose evidence is the signed event that named it.
|
|
146
|
+
*/
|
|
147
|
+
relate(head) {
|
|
148
|
+
try {
|
|
149
|
+
const seen = parseWith(ConvHeadSchema, head, "conversation head");
|
|
150
|
+
if (seen.seq > this.head().seq)
|
|
151
|
+
return { relation: "ahead" };
|
|
152
|
+
const ours = this.#hashes[seen.seq];
|
|
153
|
+
return ours === seen.hash ? { relation: "consistent" } : { relation: "diverged", ours };
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
throw asMessagingError(error, "conversation head");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Checks one entry and appends it, in order: I-JSON and size (`invalid_request`, `too_large`); schema, realm and
|
|
161
|
+
* cid (`invalid_request`); the chain, seq = head + 1 and prev = h(head) (`conv_conflict`); the signature
|
|
162
|
+
* (`bad_signature`); the live checks given (`expired`, `unknown_device`); a reused id (`duplicate`); then the
|
|
163
|
+
* authority rules of spec §16.7. Returns the entry's h(seq).
|
|
164
|
+
*/
|
|
165
|
+
apply(raw, options = {}) {
|
|
166
|
+
try {
|
|
167
|
+
return this.#apply(raw, options);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
throw asMessagingError(error, "conversation entry");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Appends an entry whose content the relay expired (spec §16.4, "Retention"), by its digest: the chain stays
|
|
175
|
+
* verifiable and the state does not change. An op is never expired: a later op head that names this seq fails.
|
|
176
|
+
*/
|
|
177
|
+
applyExpired(raw) {
|
|
178
|
+
try {
|
|
179
|
+
const entry = parseWith(ExpiredSchema, raw, "expired entry");
|
|
180
|
+
const head = this.head();
|
|
181
|
+
if (entry.seq !== head.seq + 1 || entry.prev !== head.hash)
|
|
182
|
+
fail("conv_conflict", `entry does not extend head ${head.seq}`);
|
|
183
|
+
if (head.seq === 0)
|
|
184
|
+
fail("invalid_request", "the create entry never expires");
|
|
185
|
+
const hash = convLinkHash(entry.prev, b64u.decode(entry.digest));
|
|
186
|
+
this.#hashes.push(hash);
|
|
187
|
+
this.#entries.push(entry);
|
|
188
|
+
this.#expired.add(entry.seq);
|
|
189
|
+
return hash;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
throw asMessagingError(error, "expired entry");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
#apply(raw, options) {
|
|
196
|
+
const live = options.live ?? {};
|
|
197
|
+
canonicalize(raw);
|
|
198
|
+
const outer = parseWith(EntryShape, raw, "conversation entry");
|
|
199
|
+
const event = admitConvEvent(outer.event);
|
|
200
|
+
const entry = deepFreeze({ seq: outer.seq, prev: outer.prev, event });
|
|
201
|
+
if (event.realm !== this.realm)
|
|
202
|
+
fail("invalid_request", "event is for another realm");
|
|
203
|
+
if (event.cid !== this.cid)
|
|
204
|
+
fail("invalid_request", "event is for another conversation");
|
|
205
|
+
const head = this.head();
|
|
206
|
+
if (entry.seq !== head.seq + 1 || entry.prev !== head.hash)
|
|
207
|
+
fail("conv_conflict", `entry does not extend head ${head.seq}`);
|
|
208
|
+
if (!convEventSigned(event))
|
|
209
|
+
fail("bad_signature", "conversation event signature is invalid");
|
|
210
|
+
if (live.now !== undefined && Math.abs(event.ts - live.now) > (live.skewMs ?? LIMITS.clockSkewMs))
|
|
211
|
+
fail("expired", "event ts is outside the clock skew window");
|
|
212
|
+
if (live.isDevice && !live.isDevice(event.by.person, event.by.device))
|
|
213
|
+
fail("unknown_device", "the author device is not a current device of its person");
|
|
214
|
+
if (this.#messages.has(event.id))
|
|
215
|
+
fail("duplicate", "event id already used in this conversation");
|
|
216
|
+
if (event.seen && event.seen.seq >= entry.seq)
|
|
217
|
+
fail("invalid_request", "seen names a position at or past the event");
|
|
218
|
+
const commit = this.#authorize(entry, options);
|
|
219
|
+
const hash = convEntryHash(entry.prev, event);
|
|
220
|
+
commit();
|
|
221
|
+
this.#hashes.push(hash);
|
|
222
|
+
this.#entries.push(entry);
|
|
223
|
+
return hash;
|
|
224
|
+
}
|
|
225
|
+
#authorize(entry, options) {
|
|
226
|
+
const { event, seq } = entry;
|
|
227
|
+
const current = this.state();
|
|
228
|
+
const author = participantOf(event.by);
|
|
229
|
+
if (current === undefined) {
|
|
230
|
+
if (event.class !== "op" || event.body.op !== "create")
|
|
231
|
+
fail("conv_conflict", "the first entry must be create");
|
|
232
|
+
return this.#create(event, seq, options.live ?? {});
|
|
233
|
+
}
|
|
234
|
+
if (event.class === "op") {
|
|
235
|
+
const op = this.opHead();
|
|
236
|
+
if (event.opHead.seq !== op.seq || event.opHead.hash !== op.hash)
|
|
237
|
+
fail("conv_conflict", "op head is not the current one");
|
|
238
|
+
if (event.by.agent !== undefined)
|
|
239
|
+
fail("forbidden", "an op is a person's: an agent never writes one");
|
|
240
|
+
const d = draftOf(current);
|
|
241
|
+
const extra = this.#op(d, event, seq, options.live ?? {});
|
|
242
|
+
return () => {
|
|
243
|
+
d.changed = seq;
|
|
244
|
+
this.#push(seq, freezeDraft(d));
|
|
245
|
+
this.#ops.push(seq);
|
|
246
|
+
this.#record(event, seq, author);
|
|
247
|
+
extra?.();
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
this.#checkOpHead(event);
|
|
251
|
+
if (event.class === "keys")
|
|
252
|
+
return this.#keys(current, event, seq, author, options.live ?? {});
|
|
253
|
+
return this.#msg(current, event, seq, author, options);
|
|
254
|
+
}
|
|
255
|
+
#checkOpHead(event) {
|
|
256
|
+
const { seq, hash } = event.opHead;
|
|
257
|
+
const op = this.opHead();
|
|
258
|
+
if (seq > op.seq || this.#hashes[seq] !== hash || (seq !== 0 && !this.#ops.includes(seq)))
|
|
259
|
+
fail("conv_conflict", "op head names no op of this log");
|
|
260
|
+
}
|
|
261
|
+
#push(seq, snapshot) {
|
|
262
|
+
this.#snapshots.push({ seq, snapshot });
|
|
263
|
+
}
|
|
264
|
+
#record(event, seq, author, extra = {}) {
|
|
265
|
+
this.#messages.set(event.id, { id: event.id, seq, class: event.class, author, deleted: false, pinned: false, reactions: [], ...extra });
|
|
266
|
+
}
|
|
267
|
+
// ---- ops ----
|
|
268
|
+
#create(event, seq, live) {
|
|
269
|
+
const op = event.body;
|
|
270
|
+
if (event.opHead.seq !== 0 || event.opHead.hash !== this.#hashes[0])
|
|
271
|
+
fail("conv_conflict", "create's op head is h(0)");
|
|
272
|
+
if (event.by.agent !== undefined)
|
|
273
|
+
fail("forbidden", "an op is a person's: an agent never writes one");
|
|
274
|
+
const by = event.by.person;
|
|
275
|
+
const seats = op.participants;
|
|
276
|
+
const unique = new Set(seats.map((s) => s.p));
|
|
277
|
+
const d = {
|
|
278
|
+
cid: this.cid,
|
|
279
|
+
kind: op.kind,
|
|
280
|
+
history: op.history,
|
|
281
|
+
retainDays: op.retainDays,
|
|
282
|
+
post: op.post ?? "all",
|
|
283
|
+
invite: op.invite ?? "all",
|
|
284
|
+
pins: op.pins ?? "all",
|
|
285
|
+
archived: false,
|
|
286
|
+
epoch: 0,
|
|
287
|
+
epochSince: seq,
|
|
288
|
+
epochTs: event.ts,
|
|
289
|
+
rekeyDue: new Set(),
|
|
290
|
+
participants: new Map(),
|
|
291
|
+
agentContext: "members",
|
|
292
|
+
bridges: "none",
|
|
293
|
+
votes: new Map(),
|
|
294
|
+
changed: seq,
|
|
295
|
+
};
|
|
296
|
+
if (op.visibility !== undefined)
|
|
297
|
+
d.visibility = op.visibility;
|
|
298
|
+
if (op.org !== undefined)
|
|
299
|
+
d.org = op.org;
|
|
300
|
+
const state = (s) => {
|
|
301
|
+
const agentOf = participantAgent(s.p) === undefined ? undefined : participantPerson(s.p);
|
|
302
|
+
if (agentOf !== undefined)
|
|
303
|
+
return agentOf === by ? "member" : "invited";
|
|
304
|
+
return s.role === "guest" && s.p !== by ? "invited" : "member";
|
|
305
|
+
};
|
|
306
|
+
switch (op.kind) {
|
|
307
|
+
case "dm": {
|
|
308
|
+
if (seats.length !== 2)
|
|
309
|
+
fail("invalid_request", "a dm lists exactly its two participants");
|
|
310
|
+
if (seats.some((s) => s.role !== "member"))
|
|
311
|
+
fail("invalid_request", "both participants of a dm are members");
|
|
312
|
+
if (op.visibility !== undefined || op.org !== undefined || op.history !== "none" || op.sealedMeta !== undefined) {
|
|
313
|
+
fail("invalid_request", "a dm has no visibility, org or meta, and history none");
|
|
314
|
+
}
|
|
315
|
+
if (op.post !== undefined || op.invite !== undefined || op.pins !== undefined)
|
|
316
|
+
fail("invalid_request", "a dm has no permits");
|
|
317
|
+
if (this.cid !== dmConvId(seats[0].p, seats[1].p))
|
|
318
|
+
fail("invalid_request", "a dm's cid is the one its participants derive");
|
|
319
|
+
if (!seats.some((s) => participantPerson(s.p) === by))
|
|
320
|
+
fail("forbidden", "a dm is created by the person of one of its participants");
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case "self": {
|
|
324
|
+
if (seats.length !== 1 || seats[0].p !== by || seats[0].role !== "owner")
|
|
325
|
+
fail("invalid_request", "a self conversation lists only its person, as owner");
|
|
326
|
+
if (this.cid !== selfConvId(by))
|
|
327
|
+
fail("invalid_request", "a self conversation's cid is its person's");
|
|
328
|
+
if (op.visibility !== undefined || op.org !== undefined || op.history !== "shared" || op.sealedMeta !== undefined) {
|
|
329
|
+
fail("invalid_request", "a self conversation has no visibility, org or meta, and history shared");
|
|
330
|
+
}
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case "group":
|
|
334
|
+
case "channel": {
|
|
335
|
+
if (this.cid.startsWith("d_") || this.cid.startsWith("s_"))
|
|
336
|
+
fail("invalid_request", "a group or channel id is c_ and a ULID");
|
|
337
|
+
if (unique.size !== seats.length)
|
|
338
|
+
fail("invalid_request", "a participant is listed twice");
|
|
339
|
+
if (!seats.some((s) => s.p === by && s.role === "owner"))
|
|
340
|
+
fail("forbidden", "the creator is a participant, as owner");
|
|
341
|
+
if (op.kind === "group") {
|
|
342
|
+
if (op.visibility !== undefined || op.org !== undefined)
|
|
343
|
+
fail("invalid_request", "a group has no visibility or org");
|
|
344
|
+
if (seats.length < 3)
|
|
345
|
+
fail("invalid_request", "a group has at least 3 participants");
|
|
346
|
+
if (seats.length > LIMITS.groupParticipants)
|
|
347
|
+
fail("too_large", "a group has at most LIMITS.groupParticipants participants");
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
if (op.visibility === undefined)
|
|
351
|
+
fail("invalid_request", "a channel states its visibility");
|
|
352
|
+
if (op.visibility === "public" && op.org === undefined)
|
|
353
|
+
fail("invalid_request", "a public channel names its org");
|
|
354
|
+
}
|
|
355
|
+
this.#checkSeats(seats, by, live, op.org ?? live.orgOf?.(by));
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const s of seats)
|
|
360
|
+
if (!d.participants.has(s.p))
|
|
361
|
+
d.participants.set(s.p, participantFrom(s, state(s), seq));
|
|
362
|
+
return () => {
|
|
363
|
+
this.#push(seq, freezeDraft(d));
|
|
364
|
+
this.#ops.push(seq);
|
|
365
|
+
this.#record(event, seq, participantOf(event.by));
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/** Seats someone else adds: an agent of another person gets no device or respondTo; a person of another org is a guest. */
|
|
369
|
+
#checkSeats(seats, by, live, org) {
|
|
370
|
+
for (const s of seats) {
|
|
371
|
+
const person = participantPerson(s.p);
|
|
372
|
+
if (participantAgent(s.p) !== undefined && person !== by && (s.device !== undefined || s.respondTo !== undefined)) {
|
|
373
|
+
fail("forbidden", "only an agent's own person seats it");
|
|
374
|
+
}
|
|
375
|
+
if (live.orgOf && participantAgent(s.p) === undefined && s.role !== "guest" && person !== by && live.orgOf(person) !== org) {
|
|
376
|
+
fail("forbidden", "a person of another org is added as a guest");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
#op(d, event, seq, live) {
|
|
381
|
+
const op = event.body;
|
|
382
|
+
const by = event.by.person;
|
|
383
|
+
const me = d.participants.get(by);
|
|
384
|
+
const fixed = d.kind === "dm" || d.kind === "self";
|
|
385
|
+
const requireMember = () => {
|
|
386
|
+
if (!me)
|
|
387
|
+
fail("not_member", "the author is not a participant");
|
|
388
|
+
if (me.state !== "member")
|
|
389
|
+
fail("forbidden", "the author has not accepted");
|
|
390
|
+
return me;
|
|
391
|
+
};
|
|
392
|
+
const requireAdmin = () => {
|
|
393
|
+
const rec = requireMember();
|
|
394
|
+
if (!isAdminRole(rec.role))
|
|
395
|
+
fail("forbidden", "only an owner or admin may do this");
|
|
396
|
+
return rec;
|
|
397
|
+
};
|
|
398
|
+
const owners = () => [...d.participants.values()].filter((p) => p.role === "owner" && p.agent === undefined);
|
|
399
|
+
const removeWithAgents = (p) => {
|
|
400
|
+
const target = d.participants.get(p);
|
|
401
|
+
d.participants.delete(p);
|
|
402
|
+
if (target.agent === undefined)
|
|
403
|
+
for (const other of [...d.participants.keys()])
|
|
404
|
+
if (participantPerson(other) === target.person)
|
|
405
|
+
d.participants.delete(other);
|
|
406
|
+
};
|
|
407
|
+
switch (op.op) {
|
|
408
|
+
case "create":
|
|
409
|
+
return fail("conv_conflict", "the conversation already exists");
|
|
410
|
+
case "add": {
|
|
411
|
+
if (fixed)
|
|
412
|
+
fail("forbidden", `a ${d.kind}'s participants are fixed`);
|
|
413
|
+
const rec = requireMember();
|
|
414
|
+
if (rec.role === "guest")
|
|
415
|
+
fail("forbidden", "a guest cannot invite");
|
|
416
|
+
if (d.invite === "admins" && !isAdminRole(rec.role))
|
|
417
|
+
fail("forbidden", "only an owner or admin may invite");
|
|
418
|
+
if (new Set(op.participants.map((s) => s.p)).size !== op.participants.length)
|
|
419
|
+
fail("invalid_request", "a participant is listed twice");
|
|
420
|
+
for (const s of op.participants) {
|
|
421
|
+
if (d.participants.has(s.p))
|
|
422
|
+
fail("forbidden", "already a participant");
|
|
423
|
+
if (s.role === "owner" && rec.role !== "owner")
|
|
424
|
+
fail("forbidden", "only an owner adds an owner");
|
|
425
|
+
if (s.role === "admin" && rec.role !== "owner")
|
|
426
|
+
fail("forbidden", "only an owner adds an admin");
|
|
427
|
+
}
|
|
428
|
+
this.#checkSeats(op.participants, by, live, d.org ?? live.orgOf?.(by));
|
|
429
|
+
const max = d.kind === "group" ? LIMITS.groupParticipants : LIMITS.convParticipants;
|
|
430
|
+
if (d.participants.size + op.participants.length > max)
|
|
431
|
+
fail("too_large", `a ${d.kind} is full`);
|
|
432
|
+
for (const s of op.participants) {
|
|
433
|
+
const agentOf = participantAgent(s.p) === undefined ? undefined : participantPerson(s.p);
|
|
434
|
+
const state = agentOf !== undefined ? (agentOf === by ? "member" : "invited") : s.role === "guest" ? "invited" : "member";
|
|
435
|
+
d.participants.set(s.p, participantFrom(s, state, seq));
|
|
436
|
+
}
|
|
437
|
+
if (d.kind === "group")
|
|
438
|
+
d.rekeyDue.add("added");
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
case "accept": {
|
|
442
|
+
if (!me)
|
|
443
|
+
fail("not_member", "the author is not a participant");
|
|
444
|
+
if (me.state !== "invited")
|
|
445
|
+
fail("invalid_state", "the author is not invited");
|
|
446
|
+
me.state = "member";
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
case "join": {
|
|
450
|
+
if (d.kind !== "channel" || d.visibility !== "public")
|
|
451
|
+
fail("forbidden", "only a public channel may be joined");
|
|
452
|
+
if (me)
|
|
453
|
+
fail("invalid_state", "already a participant");
|
|
454
|
+
if (live.orgOf && live.orgOf(by) !== d.org)
|
|
455
|
+
fail("forbidden", "the channel is open to another org");
|
|
456
|
+
if (d.participants.size + 1 > LIMITS.convParticipants)
|
|
457
|
+
fail("too_large", "the channel is full");
|
|
458
|
+
d.participants.set(by, participantFrom({ p: by, role: "member" }, "member", seq));
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
case "leave": {
|
|
462
|
+
if (fixed)
|
|
463
|
+
fail("forbidden", `a ${d.kind}'s participants are fixed`);
|
|
464
|
+
if (!me)
|
|
465
|
+
fail("not_member", "the author is not a participant");
|
|
466
|
+
const others = [...d.participants.values()].some((p) => p.agent === undefined && p.person !== by);
|
|
467
|
+
if (me.role === "owner" && owners().length === 1 && others)
|
|
468
|
+
fail("forbidden", "the last owner names another before leaving");
|
|
469
|
+
removeWithAgents(by);
|
|
470
|
+
d.rekeyDue.add("removed");
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
case "remove": {
|
|
474
|
+
if (fixed)
|
|
475
|
+
fail("forbidden", `a ${d.kind}'s participants are fixed`);
|
|
476
|
+
if (new Set(op.participants).size !== op.participants.length)
|
|
477
|
+
fail("invalid_request", "a participant is listed twice");
|
|
478
|
+
for (const p of op.participants) {
|
|
479
|
+
const target = d.participants.get(p);
|
|
480
|
+
if (!target)
|
|
481
|
+
fail("not_found", "not a participant");
|
|
482
|
+
if (target.agent !== undefined && target.person === by)
|
|
483
|
+
continue;
|
|
484
|
+
const rec = requireAdmin();
|
|
485
|
+
if (target.role === "owner" && rec.role !== "owner")
|
|
486
|
+
fail("forbidden", "an admin cannot remove an owner");
|
|
487
|
+
}
|
|
488
|
+
for (const p of op.participants)
|
|
489
|
+
if (d.participants.has(p))
|
|
490
|
+
removeWithAgents(p);
|
|
491
|
+
if (owners().length === 0 && [...d.participants.values()].some((p) => p.agent === undefined))
|
|
492
|
+
fail("forbidden", "the last owner cannot be removed");
|
|
493
|
+
d.rekeyDue.add("removed");
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
case "role": {
|
|
497
|
+
if (fixed)
|
|
498
|
+
fail("forbidden", `a ${d.kind}'s roles are fixed`);
|
|
499
|
+
const rec = requireAdmin();
|
|
500
|
+
const target = d.participants.get(op.p);
|
|
501
|
+
if (!target)
|
|
502
|
+
fail("not_found", "not a participant");
|
|
503
|
+
if (rec.role === "admin" && (target.role === "owner" || (op.role !== "member" && op.role !== "guest"))) {
|
|
504
|
+
fail("forbidden", "an admin sets only member or guest, and never an owner's role");
|
|
505
|
+
}
|
|
506
|
+
if (target.role === "owner" && op.role !== "owner" && owners().length === 1)
|
|
507
|
+
fail("forbidden", "the last owner cannot be demoted");
|
|
508
|
+
target.role = op.role;
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
case "seat": {
|
|
512
|
+
const target = d.participants.get(op.p);
|
|
513
|
+
if (!target)
|
|
514
|
+
fail("not_found", "not a participant");
|
|
515
|
+
if (target.agent === undefined)
|
|
516
|
+
fail("invalid_request", "only an agent is seated");
|
|
517
|
+
if (target.person !== by)
|
|
518
|
+
fail("forbidden", "only the agent's own person seats it");
|
|
519
|
+
target.state = "member";
|
|
520
|
+
if (op.device !== undefined)
|
|
521
|
+
target.device = op.device;
|
|
522
|
+
if (op.respondTo !== undefined)
|
|
523
|
+
target.respondTo = op.respondTo;
|
|
524
|
+
return undefined;
|
|
525
|
+
}
|
|
526
|
+
case "settings": {
|
|
527
|
+
if (fixed)
|
|
528
|
+
fail("forbidden", `a ${d.kind} has no settings`);
|
|
529
|
+
requireAdmin();
|
|
530
|
+
if (op.visibility !== undefined) {
|
|
531
|
+
if (d.kind !== "channel")
|
|
532
|
+
fail("invalid_request", "only a channel has a visibility");
|
|
533
|
+
if (op.visibility === "public" && d.org === undefined)
|
|
534
|
+
fail("invalid_state", "a channel with no org cannot be public");
|
|
535
|
+
d.visibility = op.visibility;
|
|
536
|
+
}
|
|
537
|
+
if (op.history !== undefined && op.history !== d.history) {
|
|
538
|
+
d.history = op.history;
|
|
539
|
+
d.rekeyDue.add("settings");
|
|
540
|
+
}
|
|
541
|
+
if (op.retainDays !== undefined)
|
|
542
|
+
d.retainDays = op.retainDays;
|
|
543
|
+
if (op.post !== undefined)
|
|
544
|
+
d.post = op.post;
|
|
545
|
+
if (op.invite !== undefined)
|
|
546
|
+
d.invite = op.invite;
|
|
547
|
+
if (op.pins !== undefined)
|
|
548
|
+
d.pins = op.pins;
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
case "archive": {
|
|
552
|
+
if (fixed)
|
|
553
|
+
fail("forbidden", `a ${d.kind} is never archived`);
|
|
554
|
+
requireAdmin();
|
|
555
|
+
d.archived = op.on;
|
|
556
|
+
return undefined;
|
|
557
|
+
}
|
|
558
|
+
case "rekey": {
|
|
559
|
+
if (![...d.participants.values()].some((p) => p.person === by && p.state === "member"))
|
|
560
|
+
fail("not_member", "the author's person is not a participant");
|
|
561
|
+
if (op.epoch !== d.epoch + 1)
|
|
562
|
+
fail("invalid_request", "a rekey starts the next epoch");
|
|
563
|
+
if (op.cause === "schedule") {
|
|
564
|
+
if (event.ts - d.epochTs < LIMITS.rekeyScheduleMs)
|
|
565
|
+
fail("invalid_state", "the epoch is not yet due for a scheduled rekey");
|
|
566
|
+
}
|
|
567
|
+
else if (op.cause !== "device" && !d.rekeyDue.has(op.cause)) {
|
|
568
|
+
fail("invalid_state", "no rekey is due for this cause");
|
|
569
|
+
}
|
|
570
|
+
d.epoch = op.epoch;
|
|
571
|
+
d.epochSince = seq;
|
|
572
|
+
d.epochTs = event.ts;
|
|
573
|
+
d.rekeyDue.clear();
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// ---- keys and messages ----
|
|
579
|
+
#keys(s, event, seq, author, live) {
|
|
580
|
+
if (event.by.agent !== undefined)
|
|
581
|
+
fail("forbidden", "key shares are a device's: an agent never writes one");
|
|
582
|
+
const persons = new Set(s.participants.map((p) => p.person));
|
|
583
|
+
if (!s.participants.some((p) => p.person === event.by.person && p.state === "member"))
|
|
584
|
+
fail("not_member", "the author's person is not a participant");
|
|
585
|
+
for (const share of event.body.shares) {
|
|
586
|
+
if (!persons.has(share.to.person))
|
|
587
|
+
fail("forbidden", "a share goes only to a device of a participant person");
|
|
588
|
+
if (live.isDevice && !live.isDevice(share.to.person, share.to.device))
|
|
589
|
+
fail("unknown_device", "a share goes only to a current device");
|
|
590
|
+
}
|
|
591
|
+
return () => this.#record(event, seq, author);
|
|
592
|
+
}
|
|
593
|
+
#msg(s, event, seq, author, options) {
|
|
594
|
+
const rec = s.participants.find((p) => p.p === author);
|
|
595
|
+
if (!rec)
|
|
596
|
+
fail("not_member", "the author is not a participant");
|
|
597
|
+
if (rec.state !== "member")
|
|
598
|
+
fail("forbidden", "the author has not accepted");
|
|
599
|
+
if (event.epoch !== s.epoch)
|
|
600
|
+
fail("stale_epoch", `the current epoch is ${s.epoch}`);
|
|
601
|
+
if (s.rekeyDue.length > 0)
|
|
602
|
+
fail("stale_epoch", "a rekey is due before the next message");
|
|
603
|
+
if (options.body === undefined)
|
|
604
|
+
return () => this.#record(event, seq, author);
|
|
605
|
+
const body = parseWith(MsgBodySchema, options.body, "message body");
|
|
606
|
+
return this.#body(s, rec, body, event, seq, author);
|
|
607
|
+
}
|
|
608
|
+
#target(id) {
|
|
609
|
+
const target = this.#messages.get(id);
|
|
610
|
+
if (!target || target.class !== "msg")
|
|
611
|
+
fail("not_found", "no such message in this conversation");
|
|
612
|
+
return target;
|
|
613
|
+
}
|
|
614
|
+
#body(s, rec, body, event, seq, author) {
|
|
615
|
+
const record = (extra = {}) => this.#record(event, seq, author, { t: body.t, ...extra });
|
|
616
|
+
const mayPost = () => {
|
|
617
|
+
if (s.archived)
|
|
618
|
+
fail("forbidden", "the conversation is archived");
|
|
619
|
+
if (s.post === "admins" && !isAdminRole(rec.role))
|
|
620
|
+
fail("forbidden", "only an owner or admin may post");
|
|
621
|
+
};
|
|
622
|
+
const live = (target) => {
|
|
623
|
+
if (target.deleted)
|
|
624
|
+
fail("invalid_state", "the message is deleted");
|
|
625
|
+
};
|
|
626
|
+
if ((s.kind === "self") !== (body.t === "sync"))
|
|
627
|
+
fail("invalid_request", "sync bodies go in the self conversation, and only they");
|
|
628
|
+
switch (body.t) {
|
|
629
|
+
case "line": {
|
|
630
|
+
mayPost();
|
|
631
|
+
if (body.via !== undefined && event.by.agent !== undefined)
|
|
632
|
+
fail("forbidden", "only a person writes from a linked channel");
|
|
633
|
+
if (body.thread !== undefined)
|
|
634
|
+
this.#target(body.thread);
|
|
635
|
+
if (body.answers !== undefined)
|
|
636
|
+
this.#target(body.answers);
|
|
637
|
+
return () => record();
|
|
638
|
+
}
|
|
639
|
+
case "edit": {
|
|
640
|
+
if (s.archived)
|
|
641
|
+
fail("forbidden", "the conversation is archived");
|
|
642
|
+
const target = this.#target(body.target);
|
|
643
|
+
if (target.t !== undefined && target.t !== "line")
|
|
644
|
+
fail("invalid_state", "only a line is edited");
|
|
645
|
+
live(target);
|
|
646
|
+
if (target.author !== author)
|
|
647
|
+
fail("forbidden", "only the line's author edits it");
|
|
648
|
+
return () => record();
|
|
649
|
+
}
|
|
650
|
+
case "delete": {
|
|
651
|
+
const target = this.#target(body.target);
|
|
652
|
+
if (target.t !== undefined && target.t !== "line" && target.t !== "task")
|
|
653
|
+
fail("invalid_state", "only a line or a task event is deleted");
|
|
654
|
+
live(target);
|
|
655
|
+
if (target.author !== author && !isAdminRole(rec.role))
|
|
656
|
+
fail("forbidden", "only the author, an owner or an admin deletes");
|
|
657
|
+
return () => {
|
|
658
|
+
record();
|
|
659
|
+
if (target.pinned)
|
|
660
|
+
this.#pinned--;
|
|
661
|
+
target.deleted = true;
|
|
662
|
+
target.pinned = false;
|
|
663
|
+
target.reactions = [];
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
case "react": {
|
|
667
|
+
mayPost();
|
|
668
|
+
const target = this.#target(body.target);
|
|
669
|
+
live(target);
|
|
670
|
+
const entry = target.reactions.find((r) => r.key === body.key);
|
|
671
|
+
if (body.on && !entry && target.reactions.length >= LIMITS.reactionsPerEvent)
|
|
672
|
+
fail("too_large", "the message has LIMITS.reactionsPerEvent reaction keys");
|
|
673
|
+
return () => {
|
|
674
|
+
record();
|
|
675
|
+
if (body.on) {
|
|
676
|
+
if (!entry)
|
|
677
|
+
target.reactions.push({ key: body.key, by: [author] });
|
|
678
|
+
else if (!entry.by.includes(author))
|
|
679
|
+
entry.by.push(author);
|
|
680
|
+
}
|
|
681
|
+
else if (entry) {
|
|
682
|
+
entry.by = entry.by.filter((p) => p !== author);
|
|
683
|
+
if (entry.by.length === 0)
|
|
684
|
+
target.reactions = target.reactions.filter((r) => r !== entry);
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
case "pin": {
|
|
689
|
+
if (s.archived)
|
|
690
|
+
fail("forbidden", "the conversation is archived");
|
|
691
|
+
if (s.pins === "admins" && !isAdminRole(rec.role))
|
|
692
|
+
fail("forbidden", "only an owner or admin may pin");
|
|
693
|
+
const target = this.#target(body.target);
|
|
694
|
+
live(target);
|
|
695
|
+
if (body.on && !target.pinned && this.#pinned >= LIMITS.pins)
|
|
696
|
+
fail("too_large", "the conversation has LIMITS.pins pins");
|
|
697
|
+
return () => {
|
|
698
|
+
record();
|
|
699
|
+
if (body.on !== target.pinned)
|
|
700
|
+
this.#pinned += body.on ? 1 : -1;
|
|
701
|
+
target.pinned = body.on;
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
case "task": {
|
|
705
|
+
const task = body.task;
|
|
706
|
+
if (task.k === "request") {
|
|
707
|
+
mayPost();
|
|
708
|
+
const to = s.participants.find((p) => p.p === task.to);
|
|
709
|
+
if (!to || to.state !== "member")
|
|
710
|
+
fail("not_found", "the task's agent is not a participant");
|
|
711
|
+
return () => record({ taskRoot: true, taskTo: task.to });
|
|
712
|
+
}
|
|
713
|
+
const root = this.#target(task.root);
|
|
714
|
+
if (root.t !== undefined && !root.taskRoot)
|
|
715
|
+
fail("invalid_state", "the root is not a task request");
|
|
716
|
+
if ((task.k === "status" || task.k === "artifact") && root.taskTo !== undefined && root.taskTo !== author)
|
|
717
|
+
fail("forbidden", "only the task's agent reports on it");
|
|
718
|
+
if ((task.k === "cancel" || task.k === "followUp") && root.author !== author)
|
|
719
|
+
fail("forbidden", "only the requester cancels or follows up");
|
|
720
|
+
return () => record();
|
|
721
|
+
}
|
|
722
|
+
case "receipt": {
|
|
723
|
+
this.#target(body.target);
|
|
724
|
+
return () => record();
|
|
725
|
+
}
|
|
726
|
+
case "meta": {
|
|
727
|
+
if (s.kind === "dm")
|
|
728
|
+
fail("forbidden", "a dm has no meta");
|
|
729
|
+
if (s.kind === "channel" && !isAdminRole(rec.role))
|
|
730
|
+
fail("forbidden", "only an owner or admin sets a channel's meta");
|
|
731
|
+
return () => record();
|
|
732
|
+
}
|
|
733
|
+
case "policy": {
|
|
734
|
+
if (event.by.agent !== undefined)
|
|
735
|
+
fail("forbidden", "a policy is a person's: an agent never sets one");
|
|
736
|
+
if (s.kind === "channel" && !isAdminRole(rec.role))
|
|
737
|
+
fail("forbidden", "only an owner or admin sets a channel's policy");
|
|
738
|
+
const d = draftOf(s);
|
|
739
|
+
if (s.kind === "channel") {
|
|
740
|
+
if (body.agentContext !== undefined)
|
|
741
|
+
d.agentContext = body.agentContext;
|
|
742
|
+
if (body.bridges !== undefined)
|
|
743
|
+
d.bridges = body.bridges;
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
const vote = { ...d.votes.get(event.by.person) };
|
|
747
|
+
if (body.agentContext !== undefined)
|
|
748
|
+
vote.agentContext = body.agentContext;
|
|
749
|
+
if (body.bridges !== undefined)
|
|
750
|
+
vote.bridges = body.bridges;
|
|
751
|
+
d.votes.set(event.by.person, vote);
|
|
752
|
+
}
|
|
753
|
+
d.changed = seq;
|
|
754
|
+
const next = freezeDraft(d);
|
|
755
|
+
return () => {
|
|
756
|
+
record();
|
|
757
|
+
this.#push(seq, next);
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
case "sync":
|
|
761
|
+
return () => record();
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const EntryShape = z.strictObject({ seq: z.number().int().min(1), prev: HashSchema, event: z.unknown() });
|
|
766
|
+
const ExpiredSchema = z.strictObject({ seq: z.number().int().min(1), prev: HashSchema, digest: HashSchema });
|