@joe-broadhead/messaging 0.7.1 → 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.
- package/CHANGELOG.md +214 -2
- package/dist/bin/messaging-mcp.js +5043 -4054
- package/dist/bin/messaging-relay.js +8263 -5425
- package/dist/bin/messaging.js +5982 -4906
- package/dist/bin/messagingd.js +19956 -13716
- package/dist/lib/modules/core/agent/operations.js +19 -327
- package/dist/lib/modules/core/agent/present.js +15 -55
- package/dist/lib/modules/core/agent-tools.js +23 -139
- package/dist/lib/modules/core/consent.js +1 -1
- package/dist/lib/modules/core/conversation/event.js +29 -4
- package/dist/lib/modules/core/conversation/guest-invitation.js +42 -0
- package/dist/lib/modules/core/conversation/log.js +287 -65
- package/dist/lib/modules/core/conversation/request-log.js +158 -0
- package/dist/lib/modules/core/conversation/request-seal.js +41 -0
- package/dist/lib/modules/core/conversation/requests.js +87 -0
- package/dist/lib/modules/core/conversation/seal.js +90 -37
- package/dist/lib/modules/core/conversation-agent-tools.js +69 -0
- package/dist/lib/modules/core/federation/directory-proof.js +83 -0
- package/dist/lib/modules/core/federation/proof-state.js +167 -0
- package/dist/lib/modules/core/federation/roster-proof.js +74 -0
- package/dist/lib/modules/core/index.js +8 -3
- package/dist/lib/modules/core/jelly-host.js +5 -5
- package/dist/lib/modules/core/schema/common.js +31 -5
- package/dist/lib/modules/core/schema/consent.js +5 -4
- package/dist/lib/modules/core/schema/contact-request.js +22 -0
- package/dist/lib/modules/core/schema/conv-frames.js +94 -0
- package/dist/lib/modules/core/schema/conversation.js +67 -33
- package/dist/lib/modules/core/schema/envelope.js +8 -24
- package/dist/lib/modules/core/schema/errors.js +4 -3
- package/dist/lib/modules/core/schema/guest-invitation.js +13 -0
- package/dist/lib/modules/core/schema/index.js +5 -0
- package/dist/lib/modules/core/schema/ipc/consent.js +13 -9
- package/dist/lib/modules/core/schema/ipc/conversation-views.js +80 -0
- package/dist/lib/modules/core/schema/ipc/conversations.js +128 -0
- package/dist/lib/modules/core/schema/ipc/device.js +2 -0
- package/dist/lib/modules/core/schema/ipc/jelly.js +3 -38
- package/dist/lib/modules/core/schema/ipc/shapes.js +14 -72
- package/dist/lib/modules/core/schema/ipc/surfaces.js +12 -195
- package/dist/lib/modules/core/schema/ipc/tasks.js +31 -0
- package/dist/lib/modules/core/schema/ipc.js +18 -44
- package/dist/lib/modules/core/testing.js +1 -1
- package/dist/lib/modules/core/version.js +1 -1
- package/dist/lib/modules/ipc/client.js +10 -2
- package/dist/lib/modules/ipc/server.js +14 -5
- package/dist/lib/modules/ipc/testing.js +13 -23
- package/dist/spec/messaging-v1.md +546 -409
- package/dist/third-party-notices.md +1 -1
- package/dist/third-party.json +1 -1
- package/dist/types/core/agent/operations.d.ts +5 -52
- package/dist/types/core/agent/present.d.ts +6 -21
- package/dist/types/core/agent-tools.d.ts +263 -442
- package/dist/types/core/conversation/event.d.ts +6 -6
- package/dist/types/core/conversation/guest-invitation.d.ts +8 -0
- package/dist/types/core/conversation/log.d.ts +28 -13
- package/dist/types/core/conversation/request-log.d.ts +24 -0
- package/dist/types/core/conversation/request-seal.d.ts +6 -0
- package/dist/types/core/conversation/requests.d.ts +14 -0
- package/dist/types/core/conversation/seal.d.ts +32 -9
- package/dist/types/core/conversation-agent-tools.d.ts +918 -0
- package/dist/types/core/federation/directory-proof.d.ts +8 -0
- package/dist/types/core/federation/proof-state.d.ts +114 -0
- package/dist/types/core/federation/roster-proof.d.ts +7 -0
- package/dist/types/core/index.d.ts +10 -5
- package/dist/types/core/jelly-host.d.ts +1 -1
- package/dist/types/core/schema/common.d.ts +31 -6
- package/dist/types/core/schema/consent.d.ts +7 -7
- package/dist/types/core/schema/contact-request.d.ts +264 -0
- package/dist/types/core/schema/conv-frames.d.ts +1200 -0
- package/dist/types/core/schema/conversation.d.ts +1410 -264
- package/dist/types/core/schema/envelope.d.ts +83 -130
- package/dist/types/core/schema/errors.d.ts +3 -1
- package/dist/types/core/schema/guest-invitation.d.ts +51 -0
- package/dist/types/core/schema/index.d.ts +5 -0
- package/dist/types/core/schema/ipc/consent.d.ts +89 -49
- package/dist/types/core/schema/ipc/conversation-views.d.ts +1023 -0
- package/dist/types/core/schema/ipc/conversations.d.ts +3987 -0
- package/dist/types/core/schema/ipc/device.d.ts +4 -0
- package/dist/types/core/schema/ipc/jelly.d.ts +2 -105
- package/dist/types/core/schema/ipc/shapes.d.ts +13 -95
- package/dist/types/core/schema/ipc/surfaces.d.ts +25 -427
- package/dist/types/core/schema/ipc/tasks.d.ts +208 -0
- package/dist/types/core/schema/ipc.d.ts +2671 -525
- package/dist/types/core/schema/relay.d.ts +8 -4
- package/dist/types/core/testing.d.ts +1 -1
- package/dist/types/core/version.d.ts +1 -1
- package/dist/types/ipc/client.d.ts +4 -1
- package/dist/types/ipc/server.d.ts +10 -1
- package/dist/types/ipc/testing.d.ts +21 -8
- package/package.json +1 -1
|
@@ -12,11 +12,14 @@ import { b64u } from "../b64u.js";
|
|
|
12
12
|
import { asMessagingError, parseWith } from "../check.js";
|
|
13
13
|
import { canonicalize } from "../jcs.js";
|
|
14
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";
|
|
15
|
+
import { ConvExpiredEntry as ExpiredSchema, ConvHead as ConvHeadSchema, ConvId as ConvIdSchema, MsgBody as MsgBodySchema, } from "../schema/conversation.js";
|
|
16
16
|
import { MessagingError } from "../schema/errors.js";
|
|
17
17
|
import { RealmId as RealmIdSchema } from "../schema/realm.js";
|
|
18
18
|
import { admitConvEvent, convEntryHash, convEventSigned, convGenesisHash, convLinkHash, deepFreeze } from "./event.js";
|
|
19
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";
|
|
20
23
|
const STRICTNESS = { none: 0, seats: 1, members: 2 };
|
|
21
24
|
const BRIDGE_STRICTNESS = { none: 0, admins: 1, members: 2 };
|
|
22
25
|
const isAdminRole = (role) => role === "owner" || role === "admin";
|
|
@@ -77,6 +80,13 @@ export class ConvLog {
|
|
|
77
80
|
/** h(0), h(1), ...; an expired entry keeps its hash. */
|
|
78
81
|
#hashes;
|
|
79
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();
|
|
80
90
|
/** seq of each op entry, ascending. */
|
|
81
91
|
#ops = [];
|
|
82
92
|
/** seqs of entries whose content expired: an op head must never name one. */
|
|
@@ -98,13 +108,117 @@ export class ConvLog {
|
|
|
98
108
|
throw asMessagingError(error, "conversation log");
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
|
-
/** Replays
|
|
102
|
-
static fromEntries(cid, realm, entries) {
|
|
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
|
+
}
|
|
103
136
|
const log = ConvLog.open(cid, realm);
|
|
104
|
-
for (
|
|
105
|
-
|
|
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
|
+
}
|
|
106
165
|
return log;
|
|
107
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
|
+
}
|
|
108
222
|
head() {
|
|
109
223
|
const seq = this.#hashes.length - 1;
|
|
110
224
|
return { seq, hash: this.#hashes[seq] };
|
|
@@ -121,6 +235,8 @@ export class ConvLog {
|
|
|
121
235
|
entries() {
|
|
122
236
|
return [...this.#entries];
|
|
123
237
|
}
|
|
238
|
+
/** Held links are in the hash chain, but contribute neither state nor messages. */
|
|
239
|
+
held() { return [...this.#held]; }
|
|
124
240
|
/** The state at the head; undefined before `create`. */
|
|
125
241
|
state() {
|
|
126
242
|
return this.#snapshots[this.#snapshots.length - 1]?.snapshot;
|
|
@@ -164,6 +280,18 @@ export class ConvLog {
|
|
|
164
280
|
*/
|
|
165
281
|
apply(raw, options = {}) {
|
|
166
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
|
+
}
|
|
167
295
|
return this.#apply(raw, options);
|
|
168
296
|
}
|
|
169
297
|
catch (error) {
|
|
@@ -171,8 +299,9 @@ export class ConvLog {
|
|
|
171
299
|
}
|
|
172
300
|
}
|
|
173
301
|
/**
|
|
174
|
-
* Appends
|
|
175
|
-
* verifiable and the state does not change.
|
|
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.
|
|
176
305
|
*/
|
|
177
306
|
applyExpired(raw) {
|
|
178
307
|
try {
|
|
@@ -180,8 +309,6 @@ export class ConvLog {
|
|
|
180
309
|
const head = this.head();
|
|
181
310
|
if (entry.seq !== head.seq + 1 || entry.prev !== head.hash)
|
|
182
311
|
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
312
|
const hash = convLinkHash(entry.prev, b64u.decode(entry.digest));
|
|
186
313
|
this.#hashes.push(hash);
|
|
187
314
|
this.#entries.push(entry);
|
|
@@ -192,11 +319,62 @@ export class ConvLog {
|
|
|
192
319
|
throw asMessagingError(error, "expired entry");
|
|
193
320
|
}
|
|
194
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
|
+
}
|
|
195
370
|
#apply(raw, options) {
|
|
196
371
|
const live = options.live ?? {};
|
|
197
372
|
canonicalize(raw);
|
|
198
373
|
const outer = parseWith(EntryShape, raw, "conversation entry");
|
|
199
|
-
const
|
|
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;
|
|
200
378
|
const entry = deepFreeze({ seq: outer.seq, prev: outer.prev, event });
|
|
201
379
|
if (event.realm !== this.realm)
|
|
202
380
|
fail("invalid_request", "event is for another realm");
|
|
@@ -211,15 +389,34 @@ export class ConvLog {
|
|
|
211
389
|
fail("expired", "event ts is outside the clock skew window");
|
|
212
390
|
if (live.isDevice && !live.isDevice(event.by.person, event.by.device))
|
|
213
391
|
fail("unknown_device", "the author device is not a current device of its person");
|
|
214
|
-
if (this.#
|
|
392
|
+
if (this.#ids.has(event.id))
|
|
215
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");
|
|
216
396
|
if (event.seen && event.seen.seq >= entry.seq)
|
|
217
397
|
fail("invalid_request", "seen names a position at or past the event");
|
|
218
|
-
|
|
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 });
|
|
219
408
|
const hash = convEntryHash(entry.prev, event);
|
|
220
409
|
commit();
|
|
410
|
+
if (importedDelete) {
|
|
411
|
+
const target = this.#messages.get(importedDelete);
|
|
412
|
+
if (target)
|
|
413
|
+
target.deleted = true;
|
|
414
|
+
}
|
|
221
415
|
this.#hashes.push(hash);
|
|
222
416
|
this.#entries.push(entry);
|
|
417
|
+
this.#ids.add(event.id);
|
|
418
|
+
if (body !== undefined)
|
|
419
|
+
this.#bodies.set(entry.seq, body);
|
|
223
420
|
return hash;
|
|
224
421
|
}
|
|
225
422
|
#authorize(entry, options) {
|
|
@@ -248,8 +445,6 @@ export class ConvLog {
|
|
|
248
445
|
};
|
|
249
446
|
}
|
|
250
447
|
this.#checkOpHead(event);
|
|
251
|
-
if (event.class === "keys")
|
|
252
|
-
return this.#keys(current, event, seq, author, options.live ?? {});
|
|
253
448
|
return this.#msg(current, event, seq, author, options);
|
|
254
449
|
}
|
|
255
450
|
#checkOpHead(event) {
|
|
@@ -372,9 +567,12 @@ export class ConvLog {
|
|
|
372
567
|
if (participantAgent(s.p) !== undefined && person !== by && (s.device !== undefined || s.respondTo !== undefined)) {
|
|
373
568
|
fail("forbidden", "only an agent's own person seats it");
|
|
374
569
|
}
|
|
375
|
-
if (live.orgOf &&
|
|
570
|
+
if (live.orgOf && s.role !== "guest" && person !== by && (org === undefined || live.orgOf(person) !== org)) {
|
|
376
571
|
fail("forbidden", "a person of another org is added as a guest");
|
|
377
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");
|
|
378
576
|
}
|
|
379
577
|
}
|
|
380
578
|
#op(d, event, seq, live) {
|
|
@@ -396,6 +594,13 @@ export class ConvLog {
|
|
|
396
594
|
return rec;
|
|
397
595
|
};
|
|
398
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
|
+
};
|
|
399
604
|
const removeWithAgents = (p) => {
|
|
400
605
|
const target = d.participants.get(p);
|
|
401
606
|
d.participants.delete(p);
|
|
@@ -434,7 +639,7 @@ export class ConvLog {
|
|
|
434
639
|
const state = agentOf !== undefined ? (agentOf === by ? "member" : "invited") : s.role === "guest" ? "invited" : "member";
|
|
435
640
|
d.participants.set(s.p, participantFrom(s, state, seq));
|
|
436
641
|
}
|
|
437
|
-
if (d.kind === "group")
|
|
642
|
+
if ((d.kind === "group" || d.history === "none") && op.participants.some((s) => d.participants.get(s.p).state === "member"))
|
|
438
643
|
d.rekeyDue.add("added");
|
|
439
644
|
return undefined;
|
|
440
645
|
}
|
|
@@ -443,7 +648,15 @@ export class ConvLog {
|
|
|
443
648
|
fail("not_member", "the author is not a participant");
|
|
444
649
|
if (me.state !== "invited")
|
|
445
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
|
+
}
|
|
446
656
|
me.state = "member";
|
|
657
|
+
me.since = seq;
|
|
658
|
+
if (d.kind === "group" || d.history === "none")
|
|
659
|
+
d.rekeyDue.add("added");
|
|
447
660
|
return undefined;
|
|
448
661
|
}
|
|
449
662
|
case "join": {
|
|
@@ -456,6 +669,8 @@ export class ConvLog {
|
|
|
456
669
|
if (d.participants.size + 1 > LIMITS.convParticipants)
|
|
457
670
|
fail("too_large", "the channel is full");
|
|
458
671
|
d.participants.set(by, participantFrom({ p: by, role: "member" }, "member", seq));
|
|
672
|
+
if (d.history === "none")
|
|
673
|
+
d.rekeyDue.add("added");
|
|
459
674
|
return undefined;
|
|
460
675
|
}
|
|
461
676
|
case "leave": {
|
|
@@ -500,6 +715,10 @@ export class ConvLog {
|
|
|
500
715
|
const target = d.participants.get(op.p);
|
|
501
716
|
if (!target)
|
|
502
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");
|
|
503
722
|
if (rec.role === "admin" && (target.role === "owner" || (op.role !== "member" && op.role !== "guest"))) {
|
|
504
723
|
fail("forbidden", "an admin sets only member or guest, and never an owner's role");
|
|
505
724
|
}
|
|
@@ -516,6 +735,13 @@ export class ConvLog {
|
|
|
516
735
|
fail("invalid_request", "only an agent is seated");
|
|
517
736
|
if (target.person !== by)
|
|
518
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
|
+
}
|
|
519
745
|
target.state = "member";
|
|
520
746
|
if (op.device !== undefined)
|
|
521
747
|
target.device = op.device;
|
|
@@ -560,6 +786,12 @@ export class ConvLog {
|
|
|
560
786
|
fail("not_member", "the author's person is not a participant");
|
|
561
787
|
if (op.epoch !== d.epoch + 1)
|
|
562
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
|
+
}
|
|
563
795
|
if (op.cause === "schedule") {
|
|
564
796
|
if (event.ts - d.epochTs < LIMITS.rekeyScheduleMs)
|
|
565
797
|
fail("invalid_state", "the epoch is not yet due for a scheduled rekey");
|
|
@@ -571,25 +803,50 @@ export class ConvLog {
|
|
|
571
803
|
d.epochSince = seq;
|
|
572
804
|
d.epochTs = event.ts;
|
|
573
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);
|
|
574
845
|
return undefined;
|
|
575
846
|
}
|
|
576
847
|
}
|
|
577
848
|
}
|
|
578
849
|
// ---- 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
850
|
#msg(s, event, seq, author, options) {
|
|
594
851
|
const rec = s.participants.find((p) => p.p === author);
|
|
595
852
|
if (!rec)
|
|
@@ -723,44 +980,9 @@ export class ConvLog {
|
|
|
723
980
|
this.#target(body.target);
|
|
724
981
|
return () => record();
|
|
725
982
|
}
|
|
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
983
|
case "sync":
|
|
761
984
|
return () => record();
|
|
762
985
|
}
|
|
763
986
|
}
|
|
764
987
|
}
|
|
765
988
|
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 });
|