@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +214 -2
  2. package/dist/bin/messaging-mcp.js +5043 -4054
  3. package/dist/bin/messaging-relay.js +8263 -5425
  4. package/dist/bin/messaging.js +5982 -4906
  5. package/dist/bin/messagingd.js +19956 -13716
  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 +23 -139
  9. package/dist/lib/modules/core/consent.js +1 -1
  10. package/dist/lib/modules/core/conversation/event.js +29 -4
  11. package/dist/lib/modules/core/conversation/guest-invitation.js +42 -0
  12. package/dist/lib/modules/core/conversation/log.js +287 -65
  13. package/dist/lib/modules/core/conversation/request-log.js +158 -0
  14. package/dist/lib/modules/core/conversation/request-seal.js +41 -0
  15. package/dist/lib/modules/core/conversation/requests.js +87 -0
  16. package/dist/lib/modules/core/conversation/seal.js +90 -37
  17. package/dist/lib/modules/core/conversation-agent-tools.js +69 -0
  18. package/dist/lib/modules/core/federation/directory-proof.js +83 -0
  19. package/dist/lib/modules/core/federation/proof-state.js +167 -0
  20. package/dist/lib/modules/core/federation/roster-proof.js +74 -0
  21. package/dist/lib/modules/core/index.js +8 -3
  22. package/dist/lib/modules/core/jelly-host.js +5 -5
  23. package/dist/lib/modules/core/schema/common.js +31 -5
  24. package/dist/lib/modules/core/schema/consent.js +5 -4
  25. package/dist/lib/modules/core/schema/contact-request.js +22 -0
  26. package/dist/lib/modules/core/schema/conv-frames.js +94 -0
  27. package/dist/lib/modules/core/schema/conversation.js +67 -33
  28. package/dist/lib/modules/core/schema/envelope.js +8 -24
  29. package/dist/lib/modules/core/schema/errors.js +4 -3
  30. package/dist/lib/modules/core/schema/guest-invitation.js +13 -0
  31. package/dist/lib/modules/core/schema/index.js +5 -0
  32. package/dist/lib/modules/core/schema/ipc/consent.js +13 -9
  33. package/dist/lib/modules/core/schema/ipc/conversation-views.js +80 -0
  34. package/dist/lib/modules/core/schema/ipc/conversations.js +128 -0
  35. package/dist/lib/modules/core/schema/ipc/device.js +2 -0
  36. package/dist/lib/modules/core/schema/ipc/jelly.js +3 -38
  37. package/dist/lib/modules/core/schema/ipc/shapes.js +14 -72
  38. package/dist/lib/modules/core/schema/ipc/surfaces.js +12 -195
  39. package/dist/lib/modules/core/schema/ipc/tasks.js +31 -0
  40. package/dist/lib/modules/core/schema/ipc.js +18 -44
  41. package/dist/lib/modules/core/testing.js +1 -1
  42. package/dist/lib/modules/core/version.js +1 -1
  43. package/dist/lib/modules/ipc/client.js +10 -2
  44. package/dist/lib/modules/ipc/server.js +14 -5
  45. package/dist/lib/modules/ipc/testing.js +13 -23
  46. package/dist/spec/messaging-v1.md +546 -409
  47. package/dist/third-party-notices.md +1 -1
  48. package/dist/third-party.json +1 -1
  49. package/dist/types/core/agent/operations.d.ts +5 -52
  50. package/dist/types/core/agent/present.d.ts +6 -21
  51. package/dist/types/core/agent-tools.d.ts +263 -442
  52. package/dist/types/core/conversation/event.d.ts +6 -6
  53. package/dist/types/core/conversation/guest-invitation.d.ts +8 -0
  54. package/dist/types/core/conversation/log.d.ts +28 -13
  55. package/dist/types/core/conversation/request-log.d.ts +24 -0
  56. package/dist/types/core/conversation/request-seal.d.ts +6 -0
  57. package/dist/types/core/conversation/requests.d.ts +14 -0
  58. package/dist/types/core/conversation/seal.d.ts +32 -9
  59. package/dist/types/core/conversation-agent-tools.d.ts +918 -0
  60. package/dist/types/core/federation/directory-proof.d.ts +8 -0
  61. package/dist/types/core/federation/proof-state.d.ts +114 -0
  62. package/dist/types/core/federation/roster-proof.d.ts +7 -0
  63. package/dist/types/core/index.d.ts +10 -5
  64. package/dist/types/core/jelly-host.d.ts +1 -1
  65. package/dist/types/core/schema/common.d.ts +31 -6
  66. package/dist/types/core/schema/consent.d.ts +7 -7
  67. package/dist/types/core/schema/contact-request.d.ts +264 -0
  68. package/dist/types/core/schema/conv-frames.d.ts +1200 -0
  69. package/dist/types/core/schema/conversation.d.ts +1410 -264
  70. package/dist/types/core/schema/envelope.d.ts +83 -130
  71. package/dist/types/core/schema/errors.d.ts +3 -1
  72. package/dist/types/core/schema/guest-invitation.d.ts +51 -0
  73. package/dist/types/core/schema/index.d.ts +5 -0
  74. package/dist/types/core/schema/ipc/consent.d.ts +89 -49
  75. package/dist/types/core/schema/ipc/conversation-views.d.ts +1023 -0
  76. package/dist/types/core/schema/ipc/conversations.d.ts +3987 -0
  77. package/dist/types/core/schema/ipc/device.d.ts +4 -0
  78. package/dist/types/core/schema/ipc/jelly.d.ts +2 -105
  79. package/dist/types/core/schema/ipc/shapes.d.ts +13 -95
  80. package/dist/types/core/schema/ipc/surfaces.d.ts +25 -427
  81. package/dist/types/core/schema/ipc/tasks.d.ts +208 -0
  82. package/dist/types/core/schema/ipc.d.ts +2671 -525
  83. package/dist/types/core/schema/relay.d.ts +8 -4
  84. package/dist/types/core/testing.d.ts +1 -1
  85. package/dist/types/core/version.d.ts +1 -1
  86. package/dist/types/ipc/client.d.ts +4 -1
  87. package/dist/types/ipc/server.d.ts +10 -1
  88. package/dist/types/ipc/testing.d.ts +21 -8
  89. 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 `entries` (each with the opened body of a `msg`, when the caller has it). Throws the first failure. */
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 (const { entry, body } of entries)
105
- log.apply(entry, body === undefined ? {} : { body });
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 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.
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 event = admitConvEvent(outer.event);
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.#messages.has(event.id))
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
- const commit = this.#authorize(entry, options);
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 && participantAgent(s.p) === undefined && s.role !== "guest" && person !== by && live.orgOf(person) !== org) {
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 });