@joe-broadhead/messaging 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +259 -2
  2. package/dist/bin/messaging-mcp.js +5484 -3858
  3. package/dist/bin/messaging-relay.js +4728 -1136
  4. package/dist/bin/messaging.js +4616 -2807
  5. package/dist/bin/messagingd.js +16451 -9444
  6. package/dist/lib/modules/core/agent/operations.js +19 -327
  7. package/dist/lib/modules/core/agent/present.js +15 -55
  8. package/dist/lib/modules/core/agent-tools.js +46 -143
  9. package/dist/lib/modules/core/consent.js +1 -1
  10. package/dist/lib/modules/core/conversation/context.js +80 -0
  11. package/dist/lib/modules/core/conversation/event.js +125 -0
  12. package/dist/lib/modules/core/conversation/guest-invitation.js +42 -0
  13. package/dist/lib/modules/core/conversation/ids.js +38 -0
  14. package/dist/lib/modules/core/conversation/link.js +35 -0
  15. package/dist/lib/modules/core/conversation/log.js +988 -0
  16. package/dist/lib/modules/core/conversation/ratchet.js +243 -0
  17. package/dist/lib/modules/core/conversation/request-log.js +158 -0
  18. package/dist/lib/modules/core/conversation/request-seal.js +41 -0
  19. package/dist/lib/modules/core/conversation/requests.js +87 -0
  20. package/dist/lib/modules/core/conversation/seal.js +251 -0
  21. package/dist/lib/modules/core/conversation-agent-tools.js +69 -0
  22. package/dist/lib/modules/core/federation/directory-proof.js +83 -0
  23. package/dist/lib/modules/core/federation/proof-state.js +167 -0
  24. package/dist/lib/modules/core/federation/roster-proof.js +74 -0
  25. package/dist/lib/modules/core/index.js +16 -2
  26. package/dist/lib/modules/core/jelly-host.js +5 -5
  27. package/dist/lib/modules/core/schema/common.js +85 -3
  28. package/dist/lib/modules/core/schema/consent.js +5 -4
  29. package/dist/lib/modules/core/schema/contact-request.js +22 -0
  30. package/dist/lib/modules/core/schema/conv-frames.js +94 -0
  31. package/dist/lib/modules/core/schema/conversation.js +326 -0
  32. package/dist/lib/modules/core/schema/envelope.js +8 -24
  33. package/dist/lib/modules/core/schema/errors.js +42 -2
  34. package/dist/lib/modules/core/schema/guest-invitation.js +13 -0
  35. package/dist/lib/modules/core/schema/index.js +6 -0
  36. package/dist/lib/modules/core/schema/ipc/consent.js +13 -9
  37. package/dist/lib/modules/core/schema/ipc/conversation-views.js +80 -0
  38. package/dist/lib/modules/core/schema/ipc/conversations.js +128 -0
  39. package/dist/lib/modules/core/schema/ipc/device.js +2 -0
  40. package/dist/lib/modules/core/schema/ipc/jelly.js +3 -38
  41. package/dist/lib/modules/core/schema/ipc/shapes.js +14 -72
  42. package/dist/lib/modules/core/schema/ipc/surfaces.js +12 -195
  43. package/dist/lib/modules/core/schema/ipc/tasks.js +31 -0
  44. package/dist/lib/modules/core/schema/ipc.js +18 -44
  45. package/dist/lib/modules/core/testing.js +1 -0
  46. package/dist/lib/modules/core/version.js +1 -1
  47. package/dist/lib/modules/ipc/client.js +10 -2
  48. package/dist/lib/modules/ipc/server.js +14 -5
  49. package/dist/lib/modules/ipc/testing.js +13 -23
  50. package/dist/spec/messaging-v1.md +835 -380
  51. package/dist/third-party-notices.md +1 -1
  52. package/dist/third-party.json +1 -1
  53. package/dist/types/core/agent/operations.d.ts +5 -52
  54. package/dist/types/core/agent/present.d.ts +6 -21
  55. package/dist/types/core/agent-tools.d.ts +277 -438
  56. package/dist/types/core/conversation/context.d.ts +56 -0
  57. package/dist/types/core/conversation/event.d.ts +36 -0
  58. package/dist/types/core/conversation/guest-invitation.d.ts +8 -0
  59. package/dist/types/core/conversation/ids.d.ts +18 -0
  60. package/dist/types/core/conversation/link.d.ts +16 -0
  61. package/dist/types/core/conversation/log.d.ts +157 -0
  62. package/dist/types/core/conversation/ratchet.d.ts +95 -0
  63. package/dist/types/core/conversation/request-log.d.ts +24 -0
  64. package/dist/types/core/conversation/request-seal.d.ts +6 -0
  65. package/dist/types/core/conversation/requests.d.ts +14 -0
  66. package/dist/types/core/conversation/seal.d.ts +70 -0
  67. package/dist/types/core/conversation-agent-tools.d.ts +918 -0
  68. package/dist/types/core/federation/directory-proof.d.ts +8 -0
  69. package/dist/types/core/federation/proof-state.d.ts +114 -0
  70. package/dist/types/core/federation/roster-proof.d.ts +7 -0
  71. package/dist/types/core/index.d.ts +15 -3
  72. package/dist/types/core/jelly-host.d.ts +1 -1
  73. package/dist/types/core/schema/common.d.ts +85 -4
  74. package/dist/types/core/schema/consent.d.ts +7 -7
  75. package/dist/types/core/schema/contact-request.d.ts +264 -0
  76. package/dist/types/core/schema/conv-frames.d.ts +1200 -0
  77. package/dist/types/core/schema/conversation.d.ts +2803 -0
  78. package/dist/types/core/schema/envelope.d.ts +89 -130
  79. package/dist/types/core/schema/errors.d.ts +40 -0
  80. package/dist/types/core/schema/guest-invitation.d.ts +51 -0
  81. package/dist/types/core/schema/index.d.ts +6 -0
  82. package/dist/types/core/schema/ipc/consent.d.ts +89 -49
  83. package/dist/types/core/schema/ipc/conversation-views.d.ts +1023 -0
  84. package/dist/types/core/schema/ipc/conversations.d.ts +3987 -0
  85. package/dist/types/core/schema/ipc/device.d.ts +10 -0
  86. package/dist/types/core/schema/ipc/jelly.d.ts +2 -105
  87. package/dist/types/core/schema/ipc/shapes.d.ts +16 -95
  88. package/dist/types/core/schema/ipc/surfaces.d.ts +28 -424
  89. package/dist/types/core/schema/ipc/tasks.d.ts +208 -0
  90. package/dist/types/core/schema/ipc.d.ts +2684 -523
  91. package/dist/types/core/schema/relay.d.ts +20 -4
  92. package/dist/types/core/testing.d.ts +1 -0
  93. package/dist/types/core/version.d.ts +1 -1
  94. package/dist/types/ipc/client.d.ts +4 -1
  95. package/dist/types/ipc/server.d.ts +10 -1
  96. package/dist/types/ipc/testing.d.ts +21 -8
  97. package/package.json +1 -1
@@ -0,0 +1,988 @@
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 { ConvExpiredEntry as ExpiredSchema, 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
+ import { verifyGuestInvitation } from "./guest-invitation.js";
21
+ import { checkRequestEvent, requestLogState } from "./request-log.js";
22
+ import { RequestImport } from "../schema/contact-request.js";
23
+ const STRICTNESS = { none: 0, seats: 1, members: 2 };
24
+ const BRIDGE_STRICTNESS = { none: 0, admins: 1, members: 2 };
25
+ const isAdminRole = (role) => role === "owner" || role === "admin";
26
+ const fail = (code, message) => {
27
+ throw new MessagingError(code, message);
28
+ };
29
+ function draftOf(s) {
30
+ return {
31
+ ...s,
32
+ participants: new Map(s.participants.map((p) => [p.p, { ...p }])),
33
+ votes: new Map(s.votes.map(({ person, ...vote }) => [person, vote])),
34
+ rekeyDue: new Set(s.rekeyDue),
35
+ };
36
+ }
37
+ function freezeDraft(d) {
38
+ const participants = [...d.participants.values()].sort((a, b) => (a.p < b.p ? -1 : a.p > b.p ? 1 : 0));
39
+ const persons = new Set(participants.map((p) => p.person));
40
+ for (const person of [...d.votes.keys()])
41
+ if (!persons.has(person))
42
+ d.votes.delete(person);
43
+ const votes = [...d.votes].sort(([a], [b]) => (a < b ? -1 : 1)).map(([person, vote]) => ({ person, ...vote }));
44
+ const voted = d.kind === "dm" || d.kind === "group";
45
+ const agentContext = voted
46
+ ? votes.reduce((min, v) => (v.agentContext !== undefined && STRICTNESS[v.agentContext] < STRICTNESS[min] ? v.agentContext : min), "members")
47
+ : d.agentContext;
48
+ const bridges = voted
49
+ ? [...persons].reduce((min, person) => {
50
+ const vote = d.votes.get(person)?.bridges ?? "none";
51
+ return BRIDGE_STRICTNESS[vote] < BRIDGE_STRICTNESS[min] ? vote : min;
52
+ }, "members")
53
+ : d.bridges;
54
+ const snapshot = { ...d, participants, votes, agentContext, bridges, rekeyDue: [...d.rekeyDue].sort() };
55
+ if (snapshot.visibility === undefined)
56
+ delete snapshot.visibility;
57
+ if (snapshot.org === undefined)
58
+ delete snapshot.org;
59
+ return deepFreeze(snapshot);
60
+ }
61
+ function participantFrom(seat, state, since) {
62
+ const person = participantPerson(seat.p);
63
+ const agent = participantAgent(seat.p);
64
+ const record = { p: seat.p, person, role: seat.role, state, since };
65
+ if (agent !== undefined)
66
+ record.agent = agent;
67
+ if (seat.device !== undefined)
68
+ record.device = seat.device;
69
+ if (seat.respondTo !== undefined)
70
+ record.respondTo = seat.respondTo;
71
+ return record;
72
+ }
73
+ /**
74
+ * A verified conversation log. Every method throws only MessagingError. Entries are kept as frozen copies of the
75
+ * received JSON, so hashes cover exactly what was received.
76
+ */
77
+ export class ConvLog {
78
+ cid;
79
+ realm;
80
+ /** h(0), h(1), ...; an expired entry keeps its hash. */
81
+ #hashes;
82
+ #entries = [];
83
+ /** Opened bodies, when available, for a replay after a device cut. */
84
+ #bodies = new Map();
85
+ /** Authenticated chain links awaiting a current-device check in the member's verified realm. */
86
+ #held = new Set();
87
+ #ids = new Set();
88
+ /** A device's first authenticated revocation point in this conversation. */
89
+ #revoked = new Map();
90
+ /** seq of each op entry, ascending. */
91
+ #ops = [];
92
+ /** seqs of entries whose content expired: an op head must never name one. */
93
+ #expired = new Set();
94
+ #snapshots = [];
95
+ #messages = new Map();
96
+ #pinned = 0;
97
+ constructor(cid, realm) {
98
+ this.cid = cid;
99
+ this.realm = realm;
100
+ this.#hashes = [convGenesisHash(cid)];
101
+ }
102
+ /** An empty log of `cid` in `realm`: its head is {0, h(0)}. */
103
+ static open(cid, realm) {
104
+ try {
105
+ return new ConvLog(parseWith(ConvIdSchema, cid, "conversation id"), parseWith(RealmIdSchema, realm, "realm"));
106
+ }
107
+ catch (error) {
108
+ throw asMessagingError(error, "conversation log");
109
+ }
110
+ }
111
+ /** Replays a prefix; callers with a verified realm supply its removed-device check for every device rekey. */
112
+ static fromEntries(cid, realm, entries, checks = {}, onInvalidBody) {
113
+ return ConvLog.#replay(cid, realm, entries, undefined, checks, onInvalidBody);
114
+ }
115
+ /** Replays a complete prefix, retaining chain links for cut and expired entries. */
116
+ static #replay(cid, realm, entries, lastLive, checks = {}, onInvalidBody) {
117
+ const cuts = [];
118
+ // Later cuts can invalidate an earlier rekey too. Walk backwards so a cut rekey never cuts another device.
119
+ for (const { entry, held } of [...entries].reverse()) {
120
+ if (held)
121
+ continue; // An unresolved author cannot cut another device's state.
122
+ if (!entry || typeof entry !== "object" || !("event" in entry))
123
+ continue;
124
+ const outer = parseWith(EntryShape, entry, "conversation entry");
125
+ const event = admitConvEvent(outer.event);
126
+ if (event.class !== "op" || event.body.op !== "rekey" || event.body.cause !== "device" || event.body.until === undefined)
127
+ continue;
128
+ if (cuts.some((c) => event.by.device === c.device && outer.seq > c.until && outer.seq < c.rekey))
129
+ continue;
130
+ if (event.realm !== realm || event.cid !== cid || !convEventSigned(event))
131
+ fail("bad_signature", "invalid device rekey");
132
+ if (event.body.until >= outer.seq)
133
+ fail("invalid_request", "a device cut precedes its rekey");
134
+ cuts.push({ device: event.body.device, until: event.body.until, rekey: outer.seq });
135
+ }
136
+ const log = ConvLog.open(cid, realm);
137
+ for (let i = 0; i < entries.length; i++) {
138
+ const { entry, body, held } = entries[i];
139
+ if (entry && typeof entry === "object" && "digest" in entry && !("event" in entry)) {
140
+ log.applyExpired(entry);
141
+ continue;
142
+ }
143
+ const outer = parseWith(EntryShape, entry, "conversation entry");
144
+ const event = admitConvEvent(outer.event);
145
+ const cut = cuts.some((c) => event.by.device === c.device && outer.seq > c.until && outer.seq < c.rekey);
146
+ if (cut || held) {
147
+ log.#applyCut(outer);
148
+ if (held && !cut)
149
+ log.#held.add(outer.seq);
150
+ }
151
+ else {
152
+ const live = i === entries.length - 1 && lastLive ? lastLive : checks;
153
+ try {
154
+ log.#apply(entry, { ...(body === undefined ? {} : { body }), live });
155
+ }
156
+ catch (error) {
157
+ if (!(error instanceof MessagingError) || !onInvalidBody || body === undefined || event.class !== "msg")
158
+ throw error;
159
+ // Retrying without the body distinguishes plaintext authority failures from structural failures.
160
+ log.#apply(entry, { live });
161
+ onInvalidBody(outer.seq);
162
+ }
163
+ }
164
+ }
165
+ return log;
166
+ }
167
+ /** Keeps a cut event's authenticated hash and op position, never its state or opened body. */
168
+ #applyCut(raw) {
169
+ const event = admitConvEvent(raw.event);
170
+ if (event.class === "keys" || event.realm !== this.realm || event.cid !== this.cid)
171
+ fail("invalid_request", "invalid cut entry");
172
+ const head = this.head();
173
+ if (raw.seq !== head.seq + 1 || raw.prev !== head.hash)
174
+ fail("conv_conflict", "cut entry does not extend the log");
175
+ if (!convEventSigned(event))
176
+ fail("bad_signature", "cut entry signature is invalid");
177
+ if (this.#ids.has(event.id))
178
+ fail("duplicate", "event id already used in this conversation");
179
+ if (this.#revoked.has(event.by.device))
180
+ fail("unknown_device", "the author's device was rekeyed out");
181
+ if (event.seen && event.seen.seq >= raw.seq)
182
+ fail("invalid_request", "seen names a position at or past the event");
183
+ if (event.class === "op")
184
+ this.#ops.push(raw.seq);
185
+ this.#ids.add(event.id);
186
+ this.#hashes.push(convEntryHash(raw.prev, event));
187
+ this.#entries.push(deepFreeze({ ...raw, event }));
188
+ }
189
+ #replace(other) {
190
+ this.#hashes.length = 0;
191
+ for (const v of other.#hashes)
192
+ this.#hashes.push(v);
193
+ this.#entries.length = 0;
194
+ for (const v of other.#entries)
195
+ this.#entries.push(v);
196
+ this.#ops.length = 0;
197
+ for (const v of other.#ops)
198
+ this.#ops.push(v);
199
+ this.#snapshots.length = 0;
200
+ for (const v of other.#snapshots)
201
+ this.#snapshots.push(v);
202
+ this.#expired.clear();
203
+ for (const v of other.#expired)
204
+ this.#expired.add(v);
205
+ this.#ids.clear();
206
+ for (const v of other.#ids)
207
+ this.#ids.add(v);
208
+ this.#revoked.clear();
209
+ for (const [k, v] of other.#revoked)
210
+ this.#revoked.set(k, v);
211
+ this.#messages.clear();
212
+ for (const [k, v] of other.#messages)
213
+ this.#messages.set(k, v);
214
+ this.#bodies.clear();
215
+ for (const [k, v] of other.#bodies)
216
+ this.#bodies.set(k, v);
217
+ this.#held.clear();
218
+ for (const seq of other.#held)
219
+ this.#held.add(seq);
220
+ this.#pinned = other.#pinned;
221
+ }
222
+ head() {
223
+ const seq = this.#hashes.length - 1;
224
+ return { seq, hash: this.#hashes[seq] };
225
+ }
226
+ /** The last op's seq and hash, or {0, h(0)}: what an author puts in `opHead`. */
227
+ opHead() {
228
+ const seq = this.#ops[this.#ops.length - 1] ?? 0;
229
+ return { seq, hash: this.#hashes[seq] };
230
+ }
231
+ /** h(seq), for 0 ≤ seq ≤ head. */
232
+ hashAt(seq) {
233
+ return Number.isSafeInteger(seq) && seq >= 0 ? this.#hashes[seq] : undefined;
234
+ }
235
+ entries() {
236
+ return [...this.#entries];
237
+ }
238
+ /** Held links are in the hash chain, but contribute neither state nor messages. */
239
+ held() { return [...this.#held]; }
240
+ /** The state at the head; undefined before `create`. */
241
+ state() {
242
+ return this.#snapshots[this.#snapshots.length - 1]?.snapshot;
243
+ }
244
+ /** The state after the entry at `seq`, as a log holding only entries 1..seq has it. */
245
+ at(seq) {
246
+ let found;
247
+ for (const s of this.#snapshots) {
248
+ if (s.seq > seq)
249
+ break;
250
+ found = s.snapshot;
251
+ }
252
+ return found;
253
+ }
254
+ /** What a member knows of the event `id`. */
255
+ message(id) {
256
+ const m = this.#messages.get(id);
257
+ return m && { ...m, reactions: m.reactions.map((r) => ({ key: r.key, by: [...r.by] })) };
258
+ }
259
+ /**
260
+ * Gossip (spec §16.4): `ahead` past the head (fetch up to it first); `consistent` when h(seq) is ours; `diverged`
261
+ * when this log has another hash at that seq, a fork whose evidence is the signed event that named it.
262
+ */
263
+ relate(head) {
264
+ try {
265
+ const seen = parseWith(ConvHeadSchema, head, "conversation head");
266
+ if (seen.seq > this.head().seq)
267
+ return { relation: "ahead" };
268
+ const ours = this.#hashes[seen.seq];
269
+ return ours === seen.hash ? { relation: "consistent" } : { relation: "diverged", ours };
270
+ }
271
+ catch (error) {
272
+ throw asMessagingError(error, "conversation head");
273
+ }
274
+ }
275
+ /**
276
+ * Checks one entry and appends it, in order: I-JSON and size (`invalid_request`, `too_large`); schema, realm and
277
+ * cid (`invalid_request`); the chain, seq = head + 1 and prev = h(head) (`conv_conflict`); the signature
278
+ * (`bad_signature`); the live checks given (`expired`, `unknown_device`); a reused id (`duplicate`); then the
279
+ * authority rules of spec §16.7. Returns the entry's h(seq).
280
+ */
281
+ apply(raw, options = {}) {
282
+ try {
283
+ const shape = EntryShape.safeParse(raw);
284
+ if (shape.success) {
285
+ const event = admitConvEvent(shape.data.event);
286
+ if (event.class === "op" && event.body.op === "rekey" && event.body.cause === "device" && event.body.until !== undefined) {
287
+ const entries = this.#entries.map((entry) => ({ entry, held: this.#held.has(entry.seq), ...(this.#bodies.has(entry.seq) ? { body: this.#bodies.get(entry.seq) } : {}) }));
288
+ entries.push({ entry: raw, ...(options.body === undefined ? {} : { body: options.body }) });
289
+ const checks = { isRemovedDevice: options.live?.isRemovedDevice, requestBinding: options.live?.requestBinding };
290
+ const rebuilt = ConvLog.#replay(this.cid, this.realm, entries, options.live, checks);
291
+ this.#replace(rebuilt);
292
+ return this.head().hash;
293
+ }
294
+ }
295
+ return this.#apply(raw, options);
296
+ }
297
+ catch (error) {
298
+ throw asMessagingError(error, "conversation entry");
299
+ }
300
+ }
301
+ /**
302
+ * Appends a `msg` entry whose content the relay expired (spec §16.4, "Retention"), by its digest: the chain stays
303
+ * verifiable and the state does not change. The create (seq 1) never expires (`invalid_request`), nor does any op:
304
+ * a later op head that names this seq fails.
305
+ */
306
+ applyExpired(raw) {
307
+ try {
308
+ const entry = parseWith(ExpiredSchema, raw, "expired entry");
309
+ const head = this.head();
310
+ if (entry.seq !== head.seq + 1 || entry.prev !== head.hash)
311
+ fail("conv_conflict", `entry does not extend head ${head.seq}`);
312
+ const hash = convLinkHash(entry.prev, b64u.decode(entry.digest));
313
+ this.#hashes.push(hash);
314
+ this.#entries.push(entry);
315
+ this.#expired.add(entry.seq);
316
+ return hash;
317
+ }
318
+ catch (error) {
319
+ throw asMessagingError(error, "expired entry");
320
+ }
321
+ }
322
+ /**
323
+ * Checks a key share against this log (spec §16.6); a share is not an entry, so nothing changes. In order: I-JSON,
324
+ * size and schema, realm and cid (`invalid_request`, `too_large`); the signature (`bad_signature`); the live checks
325
+ * given (`expired`, `unknown_device`); `by.agent` absent (`forbidden`); `opHead` names an op of this log
326
+ * (`conv_conflict`); then, in the state at `opHead`, the author's person is a member participant person
327
+ * (`not_member`) and the recipient's person a participant person (`forbidden`), and live, the recipient device is a
328
+ * current device of that person (`unknown_device`). Returns the frozen event.
329
+ */
330
+ checkShare(raw, live = {}) {
331
+ try {
332
+ const event = admitConvEvent(raw);
333
+ if (event.class !== "keys")
334
+ fail("invalid_request", "not a key share");
335
+ const share = event;
336
+ if (share.realm !== this.realm)
337
+ fail("invalid_request", "share is for another realm");
338
+ if (share.cid !== this.cid)
339
+ fail("invalid_request", "share is for another conversation");
340
+ if (!convEventSigned(share))
341
+ fail("bad_signature", "key share signature is invalid");
342
+ if (live.now !== undefined && Math.abs(share.ts - live.now) > (live.skewMs ?? LIMITS.clockSkewMs))
343
+ fail("expired", "share ts is outside the clock skew window");
344
+ if (live.isDevice && !live.isDevice(share.by.person, share.by.device))
345
+ fail("unknown_device", "the author device is not a current device of its person");
346
+ if (share.by.agent !== undefined)
347
+ fail("forbidden", "key shares are a device's: an agent never writes one");
348
+ this.#checkOpHead(share);
349
+ const revokedAt = this.#revoked.get(share.by.device);
350
+ if (revokedAt !== undefined && share.opHead.seq >= revokedAt)
351
+ fail("unknown_device", "the share author's device was rekeyed out");
352
+ const s = this.at(share.opHead.seq);
353
+ if (s === undefined)
354
+ fail("conv_conflict", "a share names an op of the conversation");
355
+ if (!s.participants.some((p) => p.person === share.by.person && p.state === "member"))
356
+ fail("not_member", "the author's person is not a participant");
357
+ const to = share.body.to;
358
+ if (!s.participants.some((p) => p.person === to.person && p.state === "member"))
359
+ fail("forbidden", "a share goes only to an accepted participant person");
360
+ if ((s.kind === "group" || s.history === "none") && s.rekeyDue.includes("added"))
361
+ fail("stale_epoch", "acceptance requires a rekey before sharing keys");
362
+ if (live.isDevice && !live.isDevice(to.person, to.device))
363
+ fail("unknown_device", "a share goes only to a current device");
364
+ return share;
365
+ }
366
+ catch (error) {
367
+ throw asMessagingError(error, "key share");
368
+ }
369
+ }
370
+ #apply(raw, options) {
371
+ const live = options.live ?? {};
372
+ canonicalize(raw);
373
+ const outer = parseWith(EntryShape, raw, "conversation entry");
374
+ const admitted = admitConvEvent(outer.event);
375
+ if (admitted.class === "keys")
376
+ fail("invalid_request", "a key share is not an entry of the log");
377
+ const event = admitted;
378
+ const entry = deepFreeze({ seq: outer.seq, prev: outer.prev, event });
379
+ if (event.realm !== this.realm)
380
+ fail("invalid_request", "event is for another realm");
381
+ if (event.cid !== this.cid)
382
+ fail("invalid_request", "event is for another conversation");
383
+ const head = this.head();
384
+ if (entry.seq !== head.seq + 1 || entry.prev !== head.hash)
385
+ fail("conv_conflict", `entry does not extend head ${head.seq}`);
386
+ if (!convEventSigned(event))
387
+ fail("bad_signature", "conversation event signature is invalid");
388
+ if (live.now !== undefined && Math.abs(event.ts - live.now) > (live.skewMs ?? LIMITS.clockSkewMs))
389
+ fail("expired", "event ts is outside the clock skew window");
390
+ if (live.isDevice && !live.isDevice(event.by.person, event.by.device))
391
+ fail("unknown_device", "the author device is not a current device of its person");
392
+ if (this.#ids.has(event.id))
393
+ fail("duplicate", "event id already used in this conversation");
394
+ if (this.#revoked.has(event.by.device))
395
+ fail("unknown_device", "the author's device was rekeyed out");
396
+ if (event.seen && event.seen.seq >= entry.seq)
397
+ fail("invalid_request", "seen names a position at or past the event");
398
+ if (live.requestBinding)
399
+ checkRequestEvent(this, outer.event, live.requestBinding, options.body);
400
+ // Keep our own parsed body: callers may mutate their input after apply, but a later cut must replay the
401
+ // originally accepted plaintext. Parse before committing so a failure cannot leave a partial append.
402
+ const imported = live.requestBinding && event.class === "msg" && event.request !== undefined;
403
+ const body = options.body === undefined || event.class !== "msg" ? undefined
404
+ : imported ? parseWith(RequestImport, options.body, "request import") : parseWith(MsgBodySchema, options.body, "message body");
405
+ const importedDelete = live.requestBinding && body?.t === "delete" && requestLogState(this, live.requestBinding).reservations.has(body.target) ? body.target : undefined;
406
+ // Portable imports retain their wrapper author. Their proof, rather than MsgBody, supplies source authority.
407
+ const commit = this.#authorize(entry, { ...options, body: imported || importedDelete ? undefined : body });
408
+ const hash = convEntryHash(entry.prev, event);
409
+ commit();
410
+ if (importedDelete) {
411
+ const target = this.#messages.get(importedDelete);
412
+ if (target)
413
+ target.deleted = true;
414
+ }
415
+ this.#hashes.push(hash);
416
+ this.#entries.push(entry);
417
+ this.#ids.add(event.id);
418
+ if (body !== undefined)
419
+ this.#bodies.set(entry.seq, body);
420
+ return hash;
421
+ }
422
+ #authorize(entry, options) {
423
+ const { event, seq } = entry;
424
+ const current = this.state();
425
+ const author = participantOf(event.by);
426
+ if (current === undefined) {
427
+ if (event.class !== "op" || event.body.op !== "create")
428
+ fail("conv_conflict", "the first entry must be create");
429
+ return this.#create(event, seq, options.live ?? {});
430
+ }
431
+ if (event.class === "op") {
432
+ const op = this.opHead();
433
+ if (event.opHead.seq !== op.seq || event.opHead.hash !== op.hash)
434
+ fail("conv_conflict", "op head is not the current one");
435
+ if (event.by.agent !== undefined)
436
+ fail("forbidden", "an op is a person's: an agent never writes one");
437
+ const d = draftOf(current);
438
+ const extra = this.#op(d, event, seq, options.live ?? {});
439
+ return () => {
440
+ d.changed = seq;
441
+ this.#push(seq, freezeDraft(d));
442
+ this.#ops.push(seq);
443
+ this.#record(event, seq, author);
444
+ extra?.();
445
+ };
446
+ }
447
+ this.#checkOpHead(event);
448
+ return this.#msg(current, event, seq, author, options);
449
+ }
450
+ #checkOpHead(event) {
451
+ const { seq, hash } = event.opHead;
452
+ const op = this.opHead();
453
+ if (seq > op.seq || this.#hashes[seq] !== hash || (seq !== 0 && !this.#ops.includes(seq)))
454
+ fail("conv_conflict", "op head names no op of this log");
455
+ }
456
+ #push(seq, snapshot) {
457
+ this.#snapshots.push({ seq, snapshot });
458
+ }
459
+ #record(event, seq, author, extra = {}) {
460
+ this.#messages.set(event.id, { id: event.id, seq, class: event.class, author, deleted: false, pinned: false, reactions: [], ...extra });
461
+ }
462
+ // ---- ops ----
463
+ #create(event, seq, live) {
464
+ const op = event.body;
465
+ if (event.opHead.seq !== 0 || event.opHead.hash !== this.#hashes[0])
466
+ fail("conv_conflict", "create's op head is h(0)");
467
+ if (event.by.agent !== undefined)
468
+ fail("forbidden", "an op is a person's: an agent never writes one");
469
+ const by = event.by.person;
470
+ const seats = op.participants;
471
+ const unique = new Set(seats.map((s) => s.p));
472
+ const d = {
473
+ cid: this.cid,
474
+ kind: op.kind,
475
+ history: op.history,
476
+ retainDays: op.retainDays,
477
+ post: op.post ?? "all",
478
+ invite: op.invite ?? "all",
479
+ pins: op.pins ?? "all",
480
+ archived: false,
481
+ epoch: 0,
482
+ epochSince: seq,
483
+ epochTs: event.ts,
484
+ rekeyDue: new Set(),
485
+ participants: new Map(),
486
+ agentContext: "members",
487
+ bridges: "none",
488
+ votes: new Map(),
489
+ changed: seq,
490
+ };
491
+ if (op.visibility !== undefined)
492
+ d.visibility = op.visibility;
493
+ if (op.org !== undefined)
494
+ d.org = op.org;
495
+ const state = (s) => {
496
+ const agentOf = participantAgent(s.p) === undefined ? undefined : participantPerson(s.p);
497
+ if (agentOf !== undefined)
498
+ return agentOf === by ? "member" : "invited";
499
+ return s.role === "guest" && s.p !== by ? "invited" : "member";
500
+ };
501
+ switch (op.kind) {
502
+ case "dm": {
503
+ if (seats.length !== 2)
504
+ fail("invalid_request", "a dm lists exactly its two participants");
505
+ if (seats.some((s) => s.role !== "member"))
506
+ fail("invalid_request", "both participants of a dm are members");
507
+ if (op.visibility !== undefined || op.org !== undefined || op.history !== "none" || op.sealedMeta !== undefined) {
508
+ fail("invalid_request", "a dm has no visibility, org or meta, and history none");
509
+ }
510
+ if (op.post !== undefined || op.invite !== undefined || op.pins !== undefined)
511
+ fail("invalid_request", "a dm has no permits");
512
+ if (this.cid !== dmConvId(seats[0].p, seats[1].p))
513
+ fail("invalid_request", "a dm's cid is the one its participants derive");
514
+ if (!seats.some((s) => participantPerson(s.p) === by))
515
+ fail("forbidden", "a dm is created by the person of one of its participants");
516
+ break;
517
+ }
518
+ case "self": {
519
+ if (seats.length !== 1 || seats[0].p !== by || seats[0].role !== "owner")
520
+ fail("invalid_request", "a self conversation lists only its person, as owner");
521
+ if (this.cid !== selfConvId(by))
522
+ fail("invalid_request", "a self conversation's cid is its person's");
523
+ if (op.visibility !== undefined || op.org !== undefined || op.history !== "shared" || op.sealedMeta !== undefined) {
524
+ fail("invalid_request", "a self conversation has no visibility, org or meta, and history shared");
525
+ }
526
+ break;
527
+ }
528
+ case "group":
529
+ case "channel": {
530
+ if (this.cid.startsWith("d_") || this.cid.startsWith("s_"))
531
+ fail("invalid_request", "a group or channel id is c_ and a ULID");
532
+ if (unique.size !== seats.length)
533
+ fail("invalid_request", "a participant is listed twice");
534
+ if (!seats.some((s) => s.p === by && s.role === "owner"))
535
+ fail("forbidden", "the creator is a participant, as owner");
536
+ if (op.kind === "group") {
537
+ if (op.visibility !== undefined || op.org !== undefined)
538
+ fail("invalid_request", "a group has no visibility or org");
539
+ if (seats.length < 3)
540
+ fail("invalid_request", "a group has at least 3 participants");
541
+ if (seats.length > LIMITS.groupParticipants)
542
+ fail("too_large", "a group has at most LIMITS.groupParticipants participants");
543
+ }
544
+ else {
545
+ if (op.visibility === undefined)
546
+ fail("invalid_request", "a channel states its visibility");
547
+ if (op.visibility === "public" && op.org === undefined)
548
+ fail("invalid_request", "a public channel names its org");
549
+ }
550
+ this.#checkSeats(seats, by, live, op.org ?? live.orgOf?.(by));
551
+ break;
552
+ }
553
+ }
554
+ for (const s of seats)
555
+ if (!d.participants.has(s.p))
556
+ d.participants.set(s.p, participantFrom(s, state(s), seq));
557
+ return () => {
558
+ this.#push(seq, freezeDraft(d));
559
+ this.#ops.push(seq);
560
+ this.#record(event, seq, participantOf(event.by));
561
+ };
562
+ }
563
+ /** Seats someone else adds: an agent of another person gets no device or respondTo; a person of another org is a guest. */
564
+ #checkSeats(seats, by, live, org) {
565
+ for (const s of seats) {
566
+ const person = participantPerson(s.p);
567
+ if (participantAgent(s.p) !== undefined && person !== by && (s.device !== undefined || s.respondTo !== undefined)) {
568
+ fail("forbidden", "only an agent's own person seats it");
569
+ }
570
+ if (live.orgOf && s.role !== "guest" && person !== by && (org === undefined || live.orgOf(person) !== org)) {
571
+ fail("forbidden", "a person of another org is added as a guest");
572
+ }
573
+ const owner = seats.find((p) => p.p === person) ?? this.state()?.participants.find((p) => p.p === person);
574
+ if (participantAgent(s.p) !== undefined && owner?.role === "guest" && s.role !== "guest")
575
+ fail("forbidden", "a guest's agent inherits the guest role");
576
+ }
577
+ }
578
+ #op(d, event, seq, live) {
579
+ const op = event.body;
580
+ const by = event.by.person;
581
+ const me = d.participants.get(by);
582
+ const fixed = d.kind === "dm" || d.kind === "self";
583
+ const requireMember = () => {
584
+ if (!me)
585
+ fail("not_member", "the author is not a participant");
586
+ if (me.state !== "member")
587
+ fail("forbidden", "the author has not accepted");
588
+ return me;
589
+ };
590
+ const requireAdmin = () => {
591
+ const rec = requireMember();
592
+ if (!isAdminRole(rec.role))
593
+ fail("forbidden", "only an owner or admin may do this");
594
+ return rec;
595
+ };
596
+ const owners = () => [...d.participants.values()].filter((p) => p.role === "owner" && p.agent === undefined);
597
+ // A sealed op is sealed as a message is: never under a key a removed device holds (spec §16.7).
598
+ const sealedUnder = (epoch) => {
599
+ if (epoch !== d.epoch)
600
+ fail("stale_epoch", `the current epoch is ${d.epoch}`);
601
+ if (d.rekeyDue.size > 0)
602
+ fail("stale_epoch", "a rekey is due before the next sealed op");
603
+ };
604
+ const removeWithAgents = (p) => {
605
+ const target = d.participants.get(p);
606
+ d.participants.delete(p);
607
+ if (target.agent === undefined)
608
+ for (const other of [...d.participants.keys()])
609
+ if (participantPerson(other) === target.person)
610
+ d.participants.delete(other);
611
+ };
612
+ switch (op.op) {
613
+ case "create":
614
+ return fail("conv_conflict", "the conversation already exists");
615
+ case "add": {
616
+ if (fixed)
617
+ fail("forbidden", `a ${d.kind}'s participants are fixed`);
618
+ const rec = requireMember();
619
+ if (rec.role === "guest")
620
+ fail("forbidden", "a guest cannot invite");
621
+ if (d.invite === "admins" && !isAdminRole(rec.role))
622
+ fail("forbidden", "only an owner or admin may invite");
623
+ if (new Set(op.participants.map((s) => s.p)).size !== op.participants.length)
624
+ fail("invalid_request", "a participant is listed twice");
625
+ for (const s of op.participants) {
626
+ if (d.participants.has(s.p))
627
+ fail("forbidden", "already a participant");
628
+ if (s.role === "owner" && rec.role !== "owner")
629
+ fail("forbidden", "only an owner adds an owner");
630
+ if (s.role === "admin" && rec.role !== "owner")
631
+ fail("forbidden", "only an owner adds an admin");
632
+ }
633
+ this.#checkSeats(op.participants, by, live, d.org ?? live.orgOf?.(by));
634
+ const max = d.kind === "group" ? LIMITS.groupParticipants : LIMITS.convParticipants;
635
+ if (d.participants.size + op.participants.length > max)
636
+ fail("too_large", `a ${d.kind} is full`);
637
+ for (const s of op.participants) {
638
+ const agentOf = participantAgent(s.p) === undefined ? undefined : participantPerson(s.p);
639
+ const state = agentOf !== undefined ? (agentOf === by ? "member" : "invited") : s.role === "guest" ? "invited" : "member";
640
+ d.participants.set(s.p, participantFrom(s, state, seq));
641
+ }
642
+ if ((d.kind === "group" || d.history === "none") && op.participants.some((s) => d.participants.get(s.p).state === "member"))
643
+ d.rekeyDue.add("added");
644
+ return undefined;
645
+ }
646
+ case "accept": {
647
+ if (!me)
648
+ fail("not_member", "the author is not a participant");
649
+ if (me.state !== "invited")
650
+ fail("invalid_state", "the author is not invited");
651
+ if (me.role === "guest") {
652
+ if (op.invitation === undefined)
653
+ fail("forbidden", "a guest accepts a signed exposure preview");
654
+ verifyGuestInvitation(this, op.invitation, by, event.ts);
655
+ }
656
+ me.state = "member";
657
+ me.since = seq;
658
+ if (d.kind === "group" || d.history === "none")
659
+ d.rekeyDue.add("added");
660
+ return undefined;
661
+ }
662
+ case "join": {
663
+ if (d.kind !== "channel" || d.visibility !== "public")
664
+ fail("forbidden", "only a public channel may be joined");
665
+ if (me)
666
+ fail("invalid_state", "already a participant");
667
+ if (live.orgOf && live.orgOf(by) !== d.org)
668
+ fail("forbidden", "the channel is open to another org");
669
+ if (d.participants.size + 1 > LIMITS.convParticipants)
670
+ fail("too_large", "the channel is full");
671
+ d.participants.set(by, participantFrom({ p: by, role: "member" }, "member", seq));
672
+ if (d.history === "none")
673
+ d.rekeyDue.add("added");
674
+ return undefined;
675
+ }
676
+ case "leave": {
677
+ if (fixed)
678
+ fail("forbidden", `a ${d.kind}'s participants are fixed`);
679
+ if (!me)
680
+ fail("not_member", "the author is not a participant");
681
+ const others = [...d.participants.values()].some((p) => p.agent === undefined && p.person !== by);
682
+ if (me.role === "owner" && owners().length === 1 && others)
683
+ fail("forbidden", "the last owner names another before leaving");
684
+ removeWithAgents(by);
685
+ d.rekeyDue.add("removed");
686
+ return undefined;
687
+ }
688
+ case "remove": {
689
+ if (fixed)
690
+ fail("forbidden", `a ${d.kind}'s participants are fixed`);
691
+ if (new Set(op.participants).size !== op.participants.length)
692
+ fail("invalid_request", "a participant is listed twice");
693
+ for (const p of op.participants) {
694
+ const target = d.participants.get(p);
695
+ if (!target)
696
+ fail("not_found", "not a participant");
697
+ if (target.agent !== undefined && target.person === by)
698
+ continue;
699
+ const rec = requireAdmin();
700
+ if (target.role === "owner" && rec.role !== "owner")
701
+ fail("forbidden", "an admin cannot remove an owner");
702
+ }
703
+ for (const p of op.participants)
704
+ if (d.participants.has(p))
705
+ removeWithAgents(p);
706
+ if (owners().length === 0 && [...d.participants.values()].some((p) => p.agent === undefined))
707
+ fail("forbidden", "the last owner cannot be removed");
708
+ d.rekeyDue.add("removed");
709
+ return undefined;
710
+ }
711
+ case "role": {
712
+ if (fixed)
713
+ fail("forbidden", `a ${d.kind}'s roles are fixed`);
714
+ const rec = requireAdmin();
715
+ const target = d.participants.get(op.p);
716
+ if (!target)
717
+ fail("not_found", "not a participant");
718
+ if (target.role === "guest" && op.role !== "guest")
719
+ fail("forbidden", "a guest cannot be promoted");
720
+ if (target.agent !== undefined && d.participants.get(target.person)?.role === "guest" && op.role !== "guest")
721
+ fail("forbidden", "a guest's agent inherits the guest role");
722
+ if (rec.role === "admin" && (target.role === "owner" || (op.role !== "member" && op.role !== "guest"))) {
723
+ fail("forbidden", "an admin sets only member or guest, and never an owner's role");
724
+ }
725
+ if (target.role === "owner" && op.role !== "owner" && owners().length === 1)
726
+ fail("forbidden", "the last owner cannot be demoted");
727
+ target.role = op.role;
728
+ return undefined;
729
+ }
730
+ case "seat": {
731
+ const target = d.participants.get(op.p);
732
+ if (!target)
733
+ fail("not_found", "not a participant");
734
+ if (target.agent === undefined)
735
+ fail("invalid_request", "only an agent is seated");
736
+ if (target.person !== by)
737
+ fail("forbidden", "only the agent's own person seats it");
738
+ if (target.role === "guest" && (me?.state !== "member" || me.role !== "guest"))
739
+ fail("forbidden", "a guest agent's owner must be an accepted, visible guest");
740
+ if (target.state === "invited") {
741
+ target.since = seq;
742
+ if (d.kind === "group" || d.history === "none")
743
+ d.rekeyDue.add("added");
744
+ }
745
+ target.state = "member";
746
+ if (op.device !== undefined)
747
+ target.device = op.device;
748
+ if (op.respondTo !== undefined)
749
+ target.respondTo = op.respondTo;
750
+ return undefined;
751
+ }
752
+ case "settings": {
753
+ if (fixed)
754
+ fail("forbidden", `a ${d.kind} has no settings`);
755
+ requireAdmin();
756
+ if (op.visibility !== undefined) {
757
+ if (d.kind !== "channel")
758
+ fail("invalid_request", "only a channel has a visibility");
759
+ if (op.visibility === "public" && d.org === undefined)
760
+ fail("invalid_state", "a channel with no org cannot be public");
761
+ d.visibility = op.visibility;
762
+ }
763
+ if (op.history !== undefined && op.history !== d.history) {
764
+ d.history = op.history;
765
+ d.rekeyDue.add("settings");
766
+ }
767
+ if (op.retainDays !== undefined)
768
+ d.retainDays = op.retainDays;
769
+ if (op.post !== undefined)
770
+ d.post = op.post;
771
+ if (op.invite !== undefined)
772
+ d.invite = op.invite;
773
+ if (op.pins !== undefined)
774
+ d.pins = op.pins;
775
+ return undefined;
776
+ }
777
+ case "archive": {
778
+ if (fixed)
779
+ fail("forbidden", `a ${d.kind} is never archived`);
780
+ requireAdmin();
781
+ d.archived = op.on;
782
+ return undefined;
783
+ }
784
+ case "rekey": {
785
+ if (![...d.participants.values()].some((p) => p.person === by && p.state === "member"))
786
+ fail("not_member", "the author's person is not a participant");
787
+ if (op.epoch !== d.epoch + 1)
788
+ fail("invalid_request", "a rekey starts the next epoch");
789
+ if (op.cause === "device") {
790
+ if (op.until !== undefined && op.until >= seq)
791
+ fail("invalid_request", "a device cut precedes its rekey");
792
+ if (live.isRemovedDevice && !live.isRemovedDevice(op.device))
793
+ fail("forbidden", "the device is not removed in the verified realm");
794
+ }
795
+ if (op.cause === "schedule") {
796
+ if (event.ts - d.epochTs < LIMITS.rekeyScheduleMs)
797
+ fail("invalid_state", "the epoch is not yet due for a scheduled rekey");
798
+ }
799
+ else if (op.cause !== "device" && !d.rekeyDue.has(op.cause)) {
800
+ fail("invalid_state", "no rekey is due for this cause");
801
+ }
802
+ d.epoch = op.epoch;
803
+ d.epochSince = seq;
804
+ d.epochTs = event.ts;
805
+ d.rekeyDue.clear();
806
+ return op.cause === "device" ? () => { if (!this.#revoked.has(op.device))
807
+ this.#revoked.set(op.device, seq); } : undefined;
808
+ }
809
+ case "policy": {
810
+ if (d.kind === "self")
811
+ fail("forbidden", "a self conversation has no policy");
812
+ const rec = requireMember();
813
+ if (d.kind === "channel") {
814
+ if (!isAdminRole(rec.role))
815
+ fail("forbidden", "only an owner or admin sets a channel's policy");
816
+ if (op.agentContext !== undefined)
817
+ d.agentContext = op.agentContext;
818
+ if (op.bridges !== undefined)
819
+ d.bridges = op.bridges;
820
+ }
821
+ else {
822
+ const vote = { ...d.votes.get(by) };
823
+ if (op.agentContext !== undefined)
824
+ vote.agentContext = op.agentContext;
825
+ if (op.bridges !== undefined)
826
+ vote.bridges = op.bridges;
827
+ d.votes.set(by, vote);
828
+ }
829
+ return undefined;
830
+ }
831
+ case "meta": {
832
+ if (d.kind === "dm" || d.kind === "self")
833
+ fail("forbidden", `a ${d.kind} has no meta`);
834
+ const rec = requireMember();
835
+ if (d.kind === "channel" && !isAdminRole(rec.role))
836
+ fail("forbidden", "only an owner or admin sets a channel's meta");
837
+ sealedUnder(op.epoch);
838
+ return undefined;
839
+ }
840
+ case "setting": {
841
+ if (d.kind !== "self")
842
+ fail("forbidden", "a setting goes in the self conversation only");
843
+ requireMember();
844
+ sealedUnder(op.epoch);
845
+ return undefined;
846
+ }
847
+ }
848
+ }
849
+ // ---- keys and messages ----
850
+ #msg(s, event, seq, author, options) {
851
+ const rec = s.participants.find((p) => p.p === author);
852
+ if (!rec)
853
+ fail("not_member", "the author is not a participant");
854
+ if (rec.state !== "member")
855
+ fail("forbidden", "the author has not accepted");
856
+ if (event.epoch !== s.epoch)
857
+ fail("stale_epoch", `the current epoch is ${s.epoch}`);
858
+ if (s.rekeyDue.length > 0)
859
+ fail("stale_epoch", "a rekey is due before the next message");
860
+ if (options.body === undefined)
861
+ return () => this.#record(event, seq, author);
862
+ const body = parseWith(MsgBodySchema, options.body, "message body");
863
+ return this.#body(s, rec, body, event, seq, author);
864
+ }
865
+ #target(id) {
866
+ const target = this.#messages.get(id);
867
+ if (!target || target.class !== "msg")
868
+ fail("not_found", "no such message in this conversation");
869
+ return target;
870
+ }
871
+ #body(s, rec, body, event, seq, author) {
872
+ const record = (extra = {}) => this.#record(event, seq, author, { t: body.t, ...extra });
873
+ const mayPost = () => {
874
+ if (s.archived)
875
+ fail("forbidden", "the conversation is archived");
876
+ if (s.post === "admins" && !isAdminRole(rec.role))
877
+ fail("forbidden", "only an owner or admin may post");
878
+ };
879
+ const live = (target) => {
880
+ if (target.deleted)
881
+ fail("invalid_state", "the message is deleted");
882
+ };
883
+ if ((s.kind === "self") !== (body.t === "sync"))
884
+ fail("invalid_request", "sync bodies go in the self conversation, and only they");
885
+ switch (body.t) {
886
+ case "line": {
887
+ mayPost();
888
+ if (body.via !== undefined && event.by.agent !== undefined)
889
+ fail("forbidden", "only a person writes from a linked channel");
890
+ if (body.thread !== undefined)
891
+ this.#target(body.thread);
892
+ if (body.answers !== undefined)
893
+ this.#target(body.answers);
894
+ return () => record();
895
+ }
896
+ case "edit": {
897
+ if (s.archived)
898
+ fail("forbidden", "the conversation is archived");
899
+ const target = this.#target(body.target);
900
+ if (target.t !== undefined && target.t !== "line")
901
+ fail("invalid_state", "only a line is edited");
902
+ live(target);
903
+ if (target.author !== author)
904
+ fail("forbidden", "only the line's author edits it");
905
+ return () => record();
906
+ }
907
+ case "delete": {
908
+ const target = this.#target(body.target);
909
+ if (target.t !== undefined && target.t !== "line" && target.t !== "task")
910
+ fail("invalid_state", "only a line or a task event is deleted");
911
+ live(target);
912
+ if (target.author !== author && !isAdminRole(rec.role))
913
+ fail("forbidden", "only the author, an owner or an admin deletes");
914
+ return () => {
915
+ record();
916
+ if (target.pinned)
917
+ this.#pinned--;
918
+ target.deleted = true;
919
+ target.pinned = false;
920
+ target.reactions = [];
921
+ };
922
+ }
923
+ case "react": {
924
+ mayPost();
925
+ const target = this.#target(body.target);
926
+ live(target);
927
+ const entry = target.reactions.find((r) => r.key === body.key);
928
+ if (body.on && !entry && target.reactions.length >= LIMITS.reactionsPerEvent)
929
+ fail("too_large", "the message has LIMITS.reactionsPerEvent reaction keys");
930
+ return () => {
931
+ record();
932
+ if (body.on) {
933
+ if (!entry)
934
+ target.reactions.push({ key: body.key, by: [author] });
935
+ else if (!entry.by.includes(author))
936
+ entry.by.push(author);
937
+ }
938
+ else if (entry) {
939
+ entry.by = entry.by.filter((p) => p !== author);
940
+ if (entry.by.length === 0)
941
+ target.reactions = target.reactions.filter((r) => r !== entry);
942
+ }
943
+ };
944
+ }
945
+ case "pin": {
946
+ if (s.archived)
947
+ fail("forbidden", "the conversation is archived");
948
+ if (s.pins === "admins" && !isAdminRole(rec.role))
949
+ fail("forbidden", "only an owner or admin may pin");
950
+ const target = this.#target(body.target);
951
+ live(target);
952
+ if (body.on && !target.pinned && this.#pinned >= LIMITS.pins)
953
+ fail("too_large", "the conversation has LIMITS.pins pins");
954
+ return () => {
955
+ record();
956
+ if (body.on !== target.pinned)
957
+ this.#pinned += body.on ? 1 : -1;
958
+ target.pinned = body.on;
959
+ };
960
+ }
961
+ case "task": {
962
+ const task = body.task;
963
+ if (task.k === "request") {
964
+ mayPost();
965
+ const to = s.participants.find((p) => p.p === task.to);
966
+ if (!to || to.state !== "member")
967
+ fail("not_found", "the task's agent is not a participant");
968
+ return () => record({ taskRoot: true, taskTo: task.to });
969
+ }
970
+ const root = this.#target(task.root);
971
+ if (root.t !== undefined && !root.taskRoot)
972
+ fail("invalid_state", "the root is not a task request");
973
+ if ((task.k === "status" || task.k === "artifact") && root.taskTo !== undefined && root.taskTo !== author)
974
+ fail("forbidden", "only the task's agent reports on it");
975
+ if ((task.k === "cancel" || task.k === "followUp") && root.author !== author)
976
+ fail("forbidden", "only the requester cancels or follows up");
977
+ return () => record();
978
+ }
979
+ case "receipt": {
980
+ this.#target(body.target);
981
+ return () => record();
982
+ }
983
+ case "sync":
984
+ return () => record();
985
+ }
986
+ }
987
+ }
988
+ const EntryShape = z.strictObject({ seq: z.number().int().min(1), prev: HashSchema, event: z.unknown() });