@kubun/plugin-p2p 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +59 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -1,45 +1,219 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
2
  import { HLC } from '@kubun/hlc';
3
3
  import { getP2PStore } from '@kubun/store-p2p';
4
- import { commitInvite, createGroup, createInvite, processWelcome, removeMember } from '@kumiai/mls';
4
+ import { commitInvite, commitLedgerEntries, controlCapabilities, createGroup, createInvite, foldEnvelope, processWelcome, readGroupAnchor, removeMember } from '@kumiai/mls';
5
5
  import { toB64 } from '@sozai/codec';
6
+ import { toISO } from '../context/types.js';
6
7
  import { bindHubToGroup, upsertHub } from '../hub/manager.js';
7
- import { reprojectAdminRoster } from './admin-roster.js';
8
8
  import { reprojectGroupSettings } from './circle-projection.js';
9
9
  import { GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
10
- import { buildCurrentGroupAnchorExtension, groupAnchorCapabilities, readGroupAnchor } from './group-anchor.js';
11
- import { ledgerEntryDigest, signLedgerEntry } from './ledger-entry.js';
10
+ import { serializeCommitJournalBlob } from './commit-adoption.js';
11
+ import { settleControlRequest } from './control-request.js';
12
+ import { buildKubunGroupAnchorExtension } from './group-anchor.js';
13
+ import { ledgerEntryDigest, signLedgerEntry, verifyLedgerEntry } from './ledger.js';
14
+ import { emitLedgerAffectedEvents } from './ledger-affected-events.js';
15
+ import { entriesSurfacedByOwnCommit, foldCommittedLedgerEntries } from './ledger-commit-fold.js';
16
+ import { serializeMLSGroupState } from './mls-state.js';
17
+ import { mirrorRosterRoles, roleFromRoster } from './roster-projection.js';
18
+ /**
19
+ * Refuse to author a Remove the group will refuse. A receiver rejects a Remove
20
+ * whose target is still admin in the roster the commit's OWN entries fold to, so
21
+ * the demotion must ride the same commit. The MLS library's pre-flight does not
22
+ * cover this — it checks each entry's authority, not the removal's legality.
23
+ *
24
+ * Without the check the write path fails OPEN: the author tombstones the member
25
+ * locally and reports success while every receiver drops the commit.
26
+ *
27
+ * Test the FOLDED roster, not the one the handle holds now — the demotion riding
28
+ * this commit is what makes the removal legal. A fold that rejects outright is
29
+ * left alone: the commit attempt fails a few lines later with the library's own
30
+ * precise reason.
31
+ */ async function assertRemovalDemotes(handle, memberDID, tokens) {
32
+ const inputs = [];
33
+ for (const token of tokens){
34
+ const verified = await verifyLedgerEntry(token);
35
+ if (verified == null) {
36
+ continue;
37
+ }
38
+ inputs.push({
39
+ verified,
40
+ entryID: ledgerEntryDigest(token)
41
+ });
42
+ }
43
+ const folded = foldEnvelope(handle.roster, inputs, handle.groupID);
44
+ if (!folded.ok) {
45
+ return;
46
+ }
47
+ if (folded.roster.roles.get(normalizeDID(memberDID)) === 'admin') {
48
+ throw new Error(`Cannot remove ${memberDID}: still an admin in the roster this commit folds to. A demotion entry must ride the same commit.`);
49
+ }
50
+ }
12
51
  export class GroupManager {
13
52
  #deviceID;
14
- #getRandomID;
53
+ #runtime;
15
54
  #hlc;
16
55
  #registry;
56
+ #stores;
57
+ #emitter;
58
+ #logger;
17
59
  constructor(params){
18
60
  this.#deviceID = params.identity.id;
19
- this.#getRandomID = params.getRandomID;
61
+ this.#runtime = params.runtime;
20
62
  this.#hlc = params.hlc;
21
63
  this.#registry = params.registry;
64
+ this.#stores = params.stores;
65
+ this.#emitter = params.emitter;
66
+ this.#logger = params.logger;
67
+ }
68
+ /**
69
+ * Mirror the roster and fold the entries a commit this device authored enacted,
70
+ * through the same projections a received commit's fold writes.
71
+ *
72
+ * Runs even for a commit that surfaced NO entries: a role change is folded by
73
+ * the MLS library into the roster rather than surfaced as an entry.
74
+ *
75
+ * Lock order is lane → registry, so this opens its own transaction after the
76
+ * handle swap released the mutex — no transaction may span a lane operation.
77
+ * Never throws: the hub has already accepted the commit.
78
+ */ async #foldOwnCommittedEntries(groupID, handle, tokens) {
79
+ const stores = this.#stores;
80
+ if (stores == null) {
81
+ return;
82
+ }
83
+ const entries = entriesSurfacedByOwnCommit(handle, tokens);
84
+ try {
85
+ const { affected, roleChanged } = await stores.withTransaction(async (tx)=>{
86
+ const p2pStore = await getP2PStore(tx);
87
+ // The author reads roles from the same authenticated source its
88
+ // co-members read them from on receive: the post-commit handle's roster,
89
+ // folded from the genesis anchor and the entries the commit chain
90
+ // enacted. All reads and writes go through the transactional store so
91
+ // they share the transaction's single connection.
92
+ //
93
+ // This is the ONLY place the author's own device learns a role its own
94
+ // commit changed — the role it reports is the post-commit one, since the
95
+ // mirror is what makes the change true locally.
96
+ const members = await p2pStore.listGroupMembers(groupID);
97
+ const roleChanged = await mirrorRosterRoles({
98
+ store: p2pStore,
99
+ groupID,
100
+ roster: handle.roster,
101
+ memberDIDs: members.map((member)=>member.member_did),
102
+ members
103
+ });
104
+ if (entries.length === 0) {
105
+ return {
106
+ affected: [],
107
+ roleChanged
108
+ };
109
+ }
110
+ return {
111
+ affected: await foldCommittedLedgerEntries({
112
+ handle,
113
+ entries,
114
+ p2pStore,
115
+ groupID,
116
+ hlc: this.#hlc,
117
+ ...this.#logger != null ? {
118
+ logger: this.#logger
119
+ } : {}
120
+ }),
121
+ roleChanged
122
+ };
123
+ });
124
+ // Past commit: the rows the events announce are durable.
125
+ const emitter = this.#emitter;
126
+ if (emitter != null) {
127
+ for (const ledger of affected){
128
+ await emitLedgerAffectedEvents(emitter, {
129
+ applied: true,
130
+ affected: {
131
+ kind: 'ledger',
132
+ ledger
133
+ }
134
+ });
135
+ }
136
+ for (const member of roleChanged){
137
+ await emitter.emit('groupMemberRoleChanged', member);
138
+ }
139
+ }
140
+ } catch (error) {
141
+ this.#logger?.warn('folding own committed ledger entries failed', {
142
+ groupID,
143
+ error
144
+ });
145
+ }
22
146
  }
23
147
  async createGroup(params) {
24
- const groupID = this.#getRandomID();
148
+ const groupID = this.#runtime.getRandomID();
25
149
  // The creator DID is baked into the MLS GroupContext as the immutable
26
150
  // genesis anchor, alongside a fresh 32-byte recovery seed. Both survive
27
151
  // every epoch and are authenticated by the GroupInfo signature, so every
28
152
  // member derives the same recovery rendezvous. Every member leaf advertises
29
153
  // the anchor extension capability so the group can be invited to.
30
- const recoverySecret = toB64(crypto.getRandomValues(new Uint8Array(32)));
154
+ // Randomness comes from the host runtime, never a global: this seed is baked
155
+ // into the GroupContext for the life of the group and is what every member's
156
+ // recovery rendezvous derives from, so it is the one value that most needs
157
+ // the platform's own CSPRNG rather than whatever `crypto` a bundler resolved.
158
+ const recoverySecret = toB64(this.#runtime.getRandomValues(new Uint8Array(32)));
31
159
  const { group } = await createGroup(params.identity, groupID, {
32
160
  extensions: [
33
- buildCurrentGroupAnchorExtension(normalizeDID(params.identity.id), recoverySecret)
161
+ buildKubunGroupAnchorExtension(normalizeDID(params.identity.id), recoverySecret)
34
162
  ],
35
- capabilities: groupAnchorCapabilities()
163
+ capabilities: controlCapabilities()
36
164
  });
37
165
  // Atomic register + bind: each `hubs` entry is upserted via `upsertHub`
38
- // and bound to the group inside one transaction. A
39
- // HubServerDIDConflictError on any entry rolls back the group row, the
40
- // MLS state, the membership row, and any earlier hub bindings that were
41
- // already added in this transaction.
166
+ // and bound to the group inside one transaction, so a failure on any entry
167
+ // rolls back the group row, the MLS state, the membership row, and any
168
+ // earlier hub bindings that were already added in this transaction.
169
+ // The group's name and description are a ledger entry like any other, so
170
+ // they are ENACTED by a commit rather than written straight to the store.
171
+ // A seeded, un-enacted entry is not an option: `isLedgerComplete` folds a
172
+ // head from the ids the handle holds and compares it to the authenticated
173
+ // head, which only moves when a commit enacts entries — so an entry the
174
+ // head does not cover makes the peer read itself as stranded for life.
175
+ //
176
+ // The creator commits to itself: there are no co-members yet, so the commit
177
+ // message goes nowhere and there is nothing to fan out. What it buys is the
178
+ // head moving to cover the entry, which is what makes the handle's ledger
179
+ // complete from the first epoch and what lets a joiner gather these settings
180
+ // over the authenticated ledger rather than a side channel.
42
181
  const hubs = params.hubs ?? [];
182
+ const settingsHLC = HLC.serialize(this.#hlc.now());
183
+ // `description` is OMITTED when absent, never `''`: the settings reducer
184
+ // treats `''` as a real value that CLEARS the field.
185
+ const settingsValue = {
186
+ name: params.name,
187
+ ...params.description != null ? {
188
+ description: params.description
189
+ } : {}
190
+ };
191
+ // The creator is admin by construction, so the group's commit-hub designation
192
+ // is authored right here, in the genesis settings the founding commit enacts:
193
+ // the first hub bound below is the one `insertGroupHub` auto-designates
194
+ // locally, and carrying it as folded group state is what lets every other
195
+ // device fold the SAME designation rather than each fall back to its own
196
+ // first-bound hub. A hubless group folds no designation and keeps using its
197
+ // loopback lane.
198
+ const firstHub = hubs[0];
199
+ if (firstHub != null) {
200
+ settingsValue.commitHub = firstHub.serverDID != null ? {
201
+ url: firstHub.url,
202
+ serverDID: firstHub.serverDID
203
+ } : {
204
+ url: firstHub.url
205
+ };
206
+ }
207
+ const settingsToken = await signLedgerEntry(params.identity, {
208
+ type: GROUP_SETTINGS_ENTRY_TYPE,
209
+ groupID,
210
+ subject: groupID,
211
+ value: settingsValue,
212
+ ord: settingsHLC
213
+ });
214
+ const { newGroup: seededGroup } = await commitLedgerEntries(group, [
215
+ settingsToken
216
+ ]);
43
217
  await params.stores.withTransaction(async (tx)=>{
44
218
  const store = await getP2PStore(tx);
45
219
  await store.createGroup({
@@ -51,67 +225,49 @@ export class GroupManager {
51
225
  });
52
226
  await this.#registry.seed({
53
227
  groupID,
54
- handle: group,
228
+ handle: seededGroup,
55
229
  stores: tx
56
230
  });
57
231
  // The membership row must exist before projection (setGroupMemberRole is
58
- // update-only); the role value here is irrelevant — projecting the genesis
59
- // anchor below overwrites it. The ledger fold is the only writer of role.
232
+ // update-only); the role value here is irrelevant — the roster mirror
233
+ // below overwrites it.
60
234
  await store.addGroupMember({
61
235
  group_id: groupID,
62
236
  member_did: params.identity.id,
63
237
  role: 'member',
64
238
  hlc: HLC.serialize(this.#hlc.now())
65
239
  });
66
- // Derive the creator's admin role from the genesis anchor: folding the
67
- // (empty) ledger to its seed {creator} projects the creator to 'admin'.
68
240
  const anchor = readGroupAnchor(group);
69
- if (anchor != null) {
70
- await reprojectAdminRoster({
71
- store,
72
- groupID,
73
- anchor
74
- });
75
- }
76
- // Seed the group name/description into the ledger: the direct
77
- // `store.createGroup` row write does not propagate, so the ledger is the
78
- // only channel that carries settings to joiners and survives a reproject.
79
- // No broadcast at creation (no co-members yet) — the entry rides catch-up
80
- // when peers join. Reprojecting from the seed yields the same name/desc the
81
- // group row already holds and sets the settings watermark.
82
- const settingsHLC = HLC.serialize(this.#hlc.now());
83
- const settingsValue = {
84
- name: params.name,
85
- description: params.description ?? ''
86
- };
87
- const settingsToken = await signLedgerEntry(params.identity, {
88
- type: GROUP_SETTINGS_ENTRY_TYPE,
89
- subject: groupID,
90
- value: settingsValue,
91
- hlc: settingsHLC
92
- });
93
- await store.appendLedgerEntry({
94
- group_id: groupID,
95
- entry_id: ledgerEntryDigest(settingsToken),
96
- type: GROUP_SETTINGS_ENTRY_TYPE,
97
- subject: groupID,
98
- value: settingsValue,
99
- hlc: settingsHLC,
100
- issuer_did: params.identity.id,
101
- signed_token: settingsToken
241
+ // The handle's roster is authoritative from the first epoch: it folds the
242
+ // same genesis anchor, so the creator reads as admin here without the
243
+ // group holding a single ledger entry. Mirroring it is what makes every
244
+ // later epoch's role a continuation of the authenticated chain rather than
245
+ // of a separately delivered token.
246
+ await mirrorRosterRoles({
247
+ store,
248
+ groupID,
249
+ roster: group.roster,
250
+ memberDIDs: [
251
+ params.identity.id
252
+ ]
102
253
  });
103
254
  if (anchor != null) {
255
+ // The seeded handle's own log — it holds exactly the settings entry the
256
+ // creation commit enacted, which is the whole ledger a group has at
257
+ // creation.
104
258
  await reprojectGroupSettings({
105
259
  store,
106
260
  groupID,
107
- anchor
261
+ anchor,
262
+ ledger: seededGroup.ledger
108
263
  });
109
264
  }
110
265
  for (const hub of hubs){
111
266
  const upserted = await upsertHub({
112
267
  stores: tx,
113
268
  url: hub.url,
114
- serverDID: hub.serverDID ?? null
269
+ serverDID: hub.serverDID ?? null,
270
+ runtime: this.#runtime
115
271
  });
116
272
  await bindHubToGroup({
117
273
  stores: tx,
@@ -124,18 +280,48 @@ export class GroupManager {
124
280
  groupID
125
281
  };
126
282
  }
127
- async inviteToGroup(params) {
283
+ /**
284
+ * Add a member by committing directly against the local handle. TEST-ONLY.
285
+ *
286
+ * Production invites ride the commit lane: the Add is published to the commit
287
+ * hub's log under the head CAS, and the local epoch advances only once the hub
288
+ * accepts. This does neither — it advances and persists the epoch outright —
289
+ * so calling it on a hub-backed group forks that group, and the fork presents
290
+ * as a hang rather than an error. `git log -S "groupManager.inviteToGroup"`
291
+ * shows the last production caller removed by `2e901689`, when the invite
292
+ * moved onto the lane; `addGroupMember` was deleted rather than renamed for
293
+ * the same reason. Kept for the ~40 suites that drive a hubless group
294
+ * directly, and unreachable from outside this package: {@link GroupManager} is
295
+ * not exported from the plugin's entry point.
296
+ */ async inviteToGroup(params) {
297
+ // The roster of the handle the add-commit produced. The invitee's role rides
298
+ // that commit, so this is the same roster every receiver folds as it applies
299
+ // the Add — the authenticated epoch chain's order, not a permission the
300
+ // caller of this method chose to hand us.
301
+ let postCommitRoster;
128
302
  const opResult = await this.#registry.withHandleReplacing(params.groupID, async (handle)=>{
303
+ // The entries this invite ADDS to the group's ledger — its own role entry
304
+ // for the invitee, and nothing else. The invite carries the whole history
305
+ // (a joiner has nothing to fold it onto), but only what is new rides the
306
+ // commit, and only what is new has to reach the existing members.
307
+ const held = new Set(handle.ledgerTokens);
129
308
  const { invite } = await createInvite({
130
309
  group: handle,
131
310
  identity: params.identity,
132
311
  recipientDID: params.recipientDID,
133
312
  permission: params.permission
134
313
  });
135
- const { commitMessage, welcomeMessage, newGroup } = await commitInvite(handle, params.recipientKeyPackage);
314
+ const addedTokens = invite.ledgerEntries.filter((token)=>!held.has(token));
315
+ // The invite's ledger entries ride this commit: their content ids go in
316
+ // the commit's control envelope and the commit advances the ledger head
317
+ // by exactly those ids, so every receiver folds the invitee's role entry
318
+ // into its roster as it applies the Add.
319
+ const { commitMessage, welcomeMessage, newGroup } = await commitInvite(handle, params.recipientKeyPackage, invite);
320
+ postCommitRoster = newGroup.roster;
136
321
  return {
137
322
  result: {
138
323
  invite,
324
+ addedTokens,
139
325
  commitMessage,
140
326
  welcomeMessage,
141
327
  ratchetTree: newGroup.state.ratchetTree
@@ -146,34 +332,86 @@ export class GroupManager {
146
332
  stores: params.stores
147
333
  });
148
334
  const store = await getP2PStore(params.stores);
335
+ // The role comes from the post-commit roster, never from the `permission`
336
+ // argument: the column must carry only what the commit chain enacted, so
337
+ // that a caller cannot mint a role the group's other members will not fold.
338
+ // The roster is always set here — the lane operation assigns it before
339
+ // returning — and `member` is the roster's own answer for an unlisted DID.
149
340
  await store.addGroupMember({
150
341
  group_id: params.groupID,
151
342
  member_did: params.recipientDID,
152
- role: params.permission === 'admin' ? 'admin' : 'member',
343
+ role: postCommitRoster == null ? 'member' : roleFromRoster(postCommitRoster, params.recipientDID),
153
344
  hlc: HLC.serialize(this.#hlc.now())
154
345
  });
155
346
  return opResult;
156
347
  }
157
348
  async joinGroup(params) {
158
- // Process the welcome message using the invitee's key package bundle
159
- const { group } = await processWelcome({
349
+ // A Welcome is delivered at-least-once a sender that crashed between
350
+ // journalling its commit and delivering re-delivers rather than strand an
351
+ // invitee never told it was added — so this path must absorb the repeat.
352
+ //
353
+ // Decided on the EPOCH, not the local group row's status. The status answers
354
+ // "do I believe I am in this group", and a device is entitled to be wrong:
355
+ // one asleep across its own removal still reads `joined`, so its genuine
356
+ // re-admission was discarded as a redelivered Welcome — returning the same
357
+ // `undefined` a success returns, and leaving it with a handle whose roster
358
+ // still called it an admin.
359
+ //
360
+ // The hazard the dedupe exists for is adopting a handle FROZEN AT ITS
361
+ // JOINING EPOCH, which is exactly "not ahead of what I hold". So adopt only
362
+ // a strictly later handle: a repeat is equal-or-lower, a re-admission is
363
+ // later, no MLS state is a first join.
364
+ //
365
+ // Keyed on the id the Welcome DECRYPTS to; the invite's id is an
366
+ // unauthenticated hint. Cannot be hoisted above the join — that id is
367
+ // encrypted to the joiner's key — hence `processWelcome` plus this check
368
+ // rather than `processWelcomeOnce`, which dedupes on group id alone.
369
+ const p2pStore = await getP2PStore(params.stores);
370
+ const processed = await processWelcome({
160
371
  identity: params.identity,
161
372
  invite: params.invite,
162
373
  welcome: params.welcomeMessage,
163
374
  keyPackageBundle: params.keyPackageBundle,
164
375
  ratchetTree: params.ratchetTree
165
376
  });
377
+ const { group } = processed;
378
+ // Every write below keys on `params.groupID`, the hint. Disagreement would
379
+ // evaluate the dedupe against one group and seed the handle, roster and hub
380
+ // bindings onto another, so refuse rather than reconcile.
381
+ if (group.groupID !== params.groupID) {
382
+ throw new Error(`joinGroup: the invite names group ${params.groupID} but its Welcome decrypts to ${group.groupID}`);
383
+ }
384
+ const held = await p2pStore.getMLSState(group.groupID, this.#deviceID);
385
+ if (held != null && Number(group.epoch) <= held.epoch) {
386
+ // A repeat: the first delivery already seeded the handle, the Group row,
387
+ // the roster and the hub bindings.
388
+ this.#logger?.debug('welcome for an epoch already held: absorbed as a repeat', {
389
+ groupID: group.groupID,
390
+ welcomeEpoch: Number(group.epoch),
391
+ heldEpoch: held.epoch
392
+ });
393
+ return undefined;
394
+ }
395
+ // Held state that this Welcome is ahead of: the cached handle can decrypt
396
+ // nothing at the group's current epoch and must be displaced rather than
397
+ // defended.
398
+ const rejoining = held != null;
166
399
  // Atomic register + bind: hub entries imported from the invite are
167
- // upserted and bound to the group inside one transaction. A
168
- // HubServerDIDConflictError on any entry rolls back MLS state, the
169
- // (provisional) group row, the membership row, and any earlier hub
170
- // bindings.
400
+ // upserted and bound to the group inside one transaction, so a failure on
401
+ // any entry rolls back MLS state, the (provisional) group row, the
402
+ // membership row, and any earlier hub bindings.
171
403
  const hubs = params.hubs ?? [];
172
404
  await params.stores.withTransaction(async (tx)=>{
405
+ // `replace` only over MLS state this Welcome is ahead of, established from
406
+ // the persisted epoch rather than the invite: an unauthenticated payload
407
+ // must not displace a live member's handle.
173
408
  await this.#registry.seed({
174
409
  groupID: params.groupID,
175
410
  handle: group,
176
- stores: tx
411
+ stores: tx,
412
+ ...rejoining ? {
413
+ replace: true
414
+ } : {}
177
415
  });
178
416
  // Provision the local Group row if it does not already exist. Required
179
417
  // so downstream group-aware components (e.g. HubRelayManager) can read
@@ -188,24 +426,33 @@ export class GroupManager {
188
426
  created_by: params.identity.id,
189
427
  hlc: HLC.serialize(this.#hlc.now())
190
428
  });
429
+ } else {
430
+ // A row already here means this device has been in this group before and
431
+ // left it: joining again is this row rejoining, not a second row for the
432
+ // same group. The point lookup above is deliberately unfiltered so this
433
+ // branch can be reached at all.
434
+ await store.setGroupStatus(params.groupID, 'joined');
191
435
  }
192
436
  // Seed the full MLS roster (the store roster is the projection of the
193
437
  // ratchet tree) so the admin gate can confirm membership of the
194
- // creator/existing admins on this peer. Roles default to 'member';
195
- // admin authority is derived from the ledger fold and the role column is
196
- // corrected when ledger entries arrive.
438
+ // creator/existing admins on this peer. Roles come from the handle's own
439
+ // roster, which the Welcome's ledger populates: a joiner therefore learns
440
+ // who is an admin from the same authenticated fold every other member
441
+ // holds, at the epoch it joined, without waiting for a token to arrive.
197
442
  const localHLC = HLC.serialize(this.#hlc.now());
443
+ const roster = group.roster;
198
444
  await store.addGroupMembers(group.listMembers().map((member)=>({
199
445
  group_id: params.groupID,
200
446
  member_did: member.id,
201
- role: 'member',
447
+ role: roleFromRoster(roster, member.id),
202
448
  hlc: localHLC
203
449
  })));
204
450
  for (const hub of hubs){
205
451
  const upserted = await upsertHub({
206
452
  stores: tx,
207
453
  url: hub.url,
208
- serverDID: hub.serverDID ?? null
454
+ serverDID: hub.serverDID ?? null,
455
+ runtime: this.#runtime
209
456
  });
210
457
  await bindHubToGroup({
211
458
  stores: tx,
@@ -216,53 +463,323 @@ export class GroupManager {
216
463
  });
217
464
  return undefined;
218
465
  }
219
- async removeMember(params) {
220
- const { commitMessage } = await this.#registry.withHandleReplacing(params.groupID, async (handle)=>{
221
- const { commitMessage, newGroup } = await removeMember(handle, params.leafIndex);
222
- return {
223
- result: {
224
- commitMessage
225
- },
226
- updated: newGroup
227
- };
228
- }, {
229
- stores: params.stores
466
+ /**
467
+ * The admin write path for the control ledger: build a commit carrying no
468
+ * membership change, only the entries it enacts and the head move covering them.
469
+ * A role entry that never rides a commit is invisible to every other peer's
470
+ * roster and to the ledger head.
471
+ *
472
+ * BUILD IS SPLIT FROM DRIVE, and every `build*Commit` here shares the contract:
473
+ * the returned closure is re-run per lane attempt, reads the group's CURRENT
474
+ * handle and derives a fresh commit against it without advancing the live one,
475
+ * so a rebase after a lost compare-and-set frames at the up-to-date epoch.
476
+ * Nothing is adopted, written or handed out before `onAccepted` — adopting
477
+ * earlier seals the bodies under an epoch no receiver holds — so a losing
478
+ * attempt is discarded whole.
479
+ */ buildEnactLedgerCommit(params) {
480
+ // Reads/writes go through the registry's own connection, not a request
481
+ // transaction: the lane runs decoupled from the resolver that started it —
482
+ // possibly after that transaction has committed and across rebases — so it
483
+ // must observe durable state, not an in-flight write set.
484
+ return ()=>this.#registry.readHandle(params.groupID, async (handle)=>{
485
+ const { commitMessage, newGroup } = await commitLedgerEntries(handle, params.tokens);
486
+ return {
487
+ commit: commitMessage,
488
+ bodies: params.tokens,
489
+ kind: 'ledger',
490
+ journal: serializeCommitJournalBlob({
491
+ serializedHandle: serializeMLSGroupState(newGroup),
492
+ ...params.requestID != null ? {
493
+ requestID: params.requestID
494
+ } : {}
495
+ }),
496
+ onAccepted: async ()=>{
497
+ await this.#registry.replaceHandle(params.groupID, newGroup);
498
+ // The handle is adopted, so this device's own committed entries fold
499
+ // through the SAME ingest a co-member's receive side runs on them.
500
+ await this.#foldOwnCommittedEntries(params.groupID, newGroup, params.tokens);
501
+ await this.#settleCommittedRequest(params.requestID);
502
+ }
503
+ };
504
+ });
505
+ }
506
+ /**
507
+ * Settle the control request a landed commit enacted, if it had one, with the
508
+ * artifact it produced when there is one.
509
+ *
510
+ * Runs from `onAccepted` and nowhere else: the drive also returns for an
511
+ * attempt that never landed, and only this closure knows THIS attempt is the
512
+ * one the group accepted. It runs after the handle swap, so it holds no
513
+ * registry mutex.
514
+ */ async #settleCommittedRequest(requestID, result) {
515
+ const stores = this.#stores;
516
+ if (requestID == null || stores == null) {
517
+ return;
518
+ }
519
+ await settleControlRequest({
520
+ stores,
521
+ requestID,
522
+ outcome: 'committed',
523
+ ...result != null ? {
524
+ result
525
+ } : {},
526
+ ...this.#emitter != null ? {
527
+ emitter: this.#emitter
528
+ } : {},
529
+ ...this.#logger != null ? {
530
+ logger: this.#logger
531
+ } : {}
230
532
  });
231
- const store = await getP2PStore(params.stores);
232
- await store.removeGroupMember(params.groupID, params.memberDID, HLC.serialize(this.#hlc.now()));
233
- return {
234
- commitMessage
235
- };
236
533
  }
237
- async removeGroupMember(params) {
238
- const { commitMessage } = await this.#registry.withHandleReplacing(params.groupID, async (handle)=>{
239
- const leafIndex = handle.findMemberLeafIndex(params.memberDID);
240
- if (leafIndex == null) {
241
- throw new Error(`Member ${params.memberDID} not found in MLS group`);
242
- }
243
- const { commitMessage, newGroup } = await removeMember(handle, leafIndex);
244
- return {
245
- result: {
246
- commitMessage
247
- },
248
- updated: newGroup
249
- };
250
- }, {
251
- stores: params.stores
534
+ /**
535
+ * The admin membership-add path for the commit lane: build an Add commit
536
+ * carrying the invitee's key package plus the role entry the invite mints, and
537
+ * the head move covering it. Same build-vs-drive contract as
538
+ * {@link buildEnactLedgerCommit}.
539
+ *
540
+ * `kind` is `'invite'` because a host cannot re-issue this commit after a crash:
541
+ * the intent lives in the Add proposal and the KeyPackage, neither of which
542
+ * survives the process.
543
+ */ buildInviteCommit(params) {
544
+ // Reads/writes go through the registry's own connection, not a request
545
+ // transaction: the lane runs decoupled from the resolver that started it —
546
+ // possibly after that transaction has committed and across rebases — so it
547
+ // must observe durable state, not an in-flight write set.
548
+ return ()=>this.#registry.readHandle(params.groupID, async (handle)=>{
549
+ // The entries this invite ADDS to the group's ledger — the invitee's own
550
+ // role entry, and nothing else. The invite carries the whole history (a
551
+ // joiner has nothing to fold it onto), but only what is new rides the
552
+ // commit, and only what is new has to reach the existing members.
553
+ const held = new Set(handle.ledgerTokens);
554
+ const { invite } = await createInvite({
555
+ group: handle,
556
+ identity: params.identity,
557
+ recipientDID: params.recipientDID,
558
+ permission: params.permission
559
+ });
560
+ const addedTokens = invite.ledgerEntries.filter((token)=>!held.has(token));
561
+ const { commitMessage, welcomeMessage, newGroup } = await commitInvite(handle, params.recipientKeyPackage, invite);
562
+ // Encoded per attempt, before the publish, so the bytes the journal
563
+ // carries are this attempt's own. The Welcome is inside them, which is
564
+ // why the blob no longer carries one separately.
565
+ const invitePayload = await params.encodeInvitePayload({
566
+ invite,
567
+ welcomeMessage,
568
+ ratchetTree: newGroup.state.ratchetTree
569
+ });
570
+ return {
571
+ commit: commitMessage,
572
+ bodies: addedTokens,
573
+ kind: 'invite',
574
+ journal: serializeCommitJournalBlob({
575
+ serializedHandle: serializeMLSGroupState(newGroup),
576
+ // The journal is the only durable copy of this payload: a host that
577
+ // crashed between the hub accepting this commit and the caller being
578
+ // answered has no way to reconstruct it, and the invitee was added
579
+ // to the group either way.
580
+ invitePayload,
581
+ ...params.requestID != null ? {
582
+ requestID: params.requestID
583
+ } : {}
584
+ }),
585
+ onAccepted: async ()=>{
586
+ await this.#registry.replaceHandle(params.groupID, newGroup);
587
+ // The handle is adopted, so this device's own committed entries fold
588
+ // through the SAME ingest a co-member's receive side runs on them.
589
+ await this.#foldOwnCommittedEntries(params.groupID, newGroup, addedTokens);
590
+ // The invitee's role entry is not one of kubun's own types, so the
591
+ // fold above skipped it and this is its only writer. The entry's body
592
+ // reaches co-members on the commit's own frame; this row is the
593
+ // sender's copy, and the fallback a restored handle resolves from.
594
+ await this.#recordInvitedMember(params.groupID, newGroup, params.recipientDID);
595
+ if (params.onInvited != null) {
596
+ await params.onInvited({
597
+ invitePayload,
598
+ addedTokens
599
+ });
600
+ }
601
+ // The payload settles WITH the request, in one update: the same
602
+ // artifact, written to the same row, that a replayed adopt of this
603
+ // commit's journal blob would write. Both paths therefore answer the
604
+ // caller by the same route.
605
+ await this.#settleCommittedRequest(params.requestID, invitePayload);
606
+ }
607
+ };
608
+ });
609
+ }
610
+ /**
611
+ * Write the membership row for a member an accepted Add commit brought in, and
612
+ * announce the join.
613
+ *
614
+ * The roster comes from the handle this call is given — the ACCEPTED attempt's —
615
+ * never from a variable a losing attempt may have written. The role comes from
616
+ * that roster and never from a `permission` argument, so the column can only
617
+ * carry what the commit chain enacted.
618
+ *
619
+ * The author never folds its own commit, so the announcement must come from
620
+ * here. Every OTHER device announces the same join from its fold, so the two can
621
+ * never both fire on one device.
622
+ */ async #recordInvitedMember(groupID, handle, recipientDID) {
623
+ const stores = this.#stores;
624
+ if (stores == null) {
625
+ return;
626
+ }
627
+ const store = await getP2PStore(stores);
628
+ const role = roleFromRoster(handle.roster, recipientDID);
629
+ await store.addGroupMember({
630
+ group_id: groupID,
631
+ member_did: recipientDID,
632
+ role,
633
+ hlc: HLC.serialize(this.#hlc.now())
634
+ });
635
+ const emitter = this.#emitter;
636
+ if (emitter == null) {
637
+ return;
638
+ }
639
+ // Read the row back rather than announce what was just written: the store
640
+ // owns `created_at`, and a row that already existed keeps its own.
641
+ const row = await store.getGroupMember(groupID, recipientDID);
642
+ if (row != null) {
643
+ await emitter.emit('groupMemberJoined', {
644
+ groupID,
645
+ memberDID: row.member_did,
646
+ role: row.role,
647
+ createdAt: toISO(row.created_at)
648
+ });
649
+ }
650
+ }
651
+ /**
652
+ * The admin membership-removal path for the commit lane: build a Remove commit
653
+ * evicting a member, carrying any demotion entry the removal must enact and the
654
+ * head move covering it. Same build-vs-drive contract as
655
+ * {@link buildEnactLedgerCommit}; the leaf index is resolved per attempt so a
656
+ * rebase cannot evict the leaf a superseded roster held.
657
+ *
658
+ * An admin's demotion must ride this very commit — that is what `ledgerEntries`
659
+ * is for, enforced by {@link assertRemovalDemotes} so a caller that forgets it
660
+ * fails here rather than shipping a commit the group silently drops.
661
+ *
662
+ * `kind` is `'remove'` because a host cannot re-issue this commit after a crash:
663
+ * the intent lives in the MLS Remove proposal, which does not survive the
664
+ * process.
665
+ */ buildRemoveCommit(params) {
666
+ const tokens = params.ledgerEntries ?? [];
667
+ return ()=>this.#registry.readHandle(params.groupID, async (handle)=>{
668
+ const leafIndex = handle.findMemberLeafIndex(params.memberDID);
669
+ if (leafIndex == null) {
670
+ throw new Error(`Member ${params.memberDID} not found in MLS group`);
671
+ }
672
+ await assertRemovalDemotes(handle, params.memberDID, tokens);
673
+ const { commitMessage, newGroup } = await removeMember(handle, leafIndex, tokens);
674
+ return {
675
+ commit: commitMessage,
676
+ bodies: tokens,
677
+ kind: 'remove',
678
+ journal: serializeCommitJournalBlob({
679
+ serializedHandle: serializeMLSGroupState(newGroup),
680
+ ...params.requestID != null ? {
681
+ requestID: params.requestID
682
+ } : {}
683
+ }),
684
+ onAccepted: async ()=>{
685
+ // Read the departing row FIRST: the roster mirror inside the fold
686
+ // rewrites the role from the post-commit roster, which no longer
687
+ // names this member, and the tombstone then puts the row out of
688
+ // reach. The announcement has to report the role they held while
689
+ // they were still a member.
690
+ const departing = await this.#readGroupMember(params.groupID, params.memberDID);
691
+ await this.#registry.replaceHandle(params.groupID, newGroup);
692
+ await this.#foldOwnCommittedEntries(params.groupID, newGroup, tokens);
693
+ await this.#recordRemovedMember(params.groupID, params.memberDID, params.hlc, departing);
694
+ await this.#settleCommittedRequest(params.requestID);
695
+ }
696
+ };
697
+ });
698
+ }
699
+ /**
700
+ * Tombstone the member an accepted Remove commit evicted, and announce it.
701
+ *
702
+ * `departing` is passed in because both the roster mirror and the tombstone have
703
+ * erased the role the announcement must report.
704
+ *
705
+ * The tombstone carries the caller's HLC, shared with the demotion entry riding
706
+ * this same commit, so a fold drops exactly the ex-admin's entries signed at or
707
+ * after the eviction.
708
+ *
709
+ * The author never folds its own commit, so the announcement must come from
710
+ * here; every OTHER device announces the same removal from its fold.
711
+ */ async #recordRemovedMember(groupID, memberDID, hlc, departing) {
712
+ const stores = this.#stores;
713
+ if (stores == null) {
714
+ return;
715
+ }
716
+ const store = await getP2PStore(stores);
717
+ await store.removeGroupMember(groupID, memberDID, hlc);
718
+ // The committer never folds its own commit, so the discovery rows every
719
+ // other device drops in its fold have to be dropped here too.
720
+ await store.deletePeerAnnouncements({
721
+ groupID,
722
+ peerDIDs: [
723
+ memberDID
724
+ ]
725
+ });
726
+ const emitter = this.#emitter;
727
+ if (emitter == null || departing == null) {
728
+ return;
729
+ }
730
+ await emitter.emit('groupMemberLeft', {
731
+ groupID,
732
+ memberDID: departing.member_did,
733
+ role: departing.role,
734
+ createdAt: toISO(departing.created_at)
252
735
  });
736
+ }
737
+ /** The membership row as it stands, or `undefined` when nothing is wired. */ async #readGroupMember(groupID, memberDID) {
738
+ const stores = this.#stores;
739
+ if (stores == null) {
740
+ return undefined;
741
+ }
742
+ const store = await getP2PStore(stores);
743
+ return await store.getGroupMember(groupID, memberDID);
744
+ }
745
+ /**
746
+ * Record locally that this device has left, without touching MLS.
747
+ *
748
+ * Split from the teardown because the two want opposite timing. What the
749
+ * device says about ITSELF has to be true the moment `leaveGroup` returns — a
750
+ * screen that navigates away must not still be told it belongs to the group —
751
+ * while the departure announcement must reach co-members BEFORE the key it is
752
+ * encrypted under is deleted, and publishing cannot be awaited inside the
753
+ * caller's write transaction.
754
+ *
755
+ * The group row is kept and marked instead of deleted: its circles, circle
756
+ * members, peer announcements and hub bindings hang off it, and a device
757
+ * invited back would otherwise have to rebuild state it still holds.
758
+ */ async markGroupLeft(params) {
253
759
  const store = await getP2PStore(params.stores);
254
- await store.removeGroupMember(params.groupID, params.memberDID, params.hlc);
255
- return {
256
- commitMessage
257
- };
760
+ await store.removeGroupMember(params.groupID, params.identity.id, params.hlc);
761
+ await store.setGroupStatus(params.groupID, 'left');
762
+ }
763
+ /**
764
+ * Drop this device's MLS state for a group, and nothing else.
765
+ *
766
+ * The other half of {@link GroupManager.markGroupLeft}: it must run AFTER the
767
+ * departure announcement, because that announcement is encrypted under the key
768
+ * this deletes. Separate from the row writes so the caller can commit those
769
+ * with its own transaction and leave only this for afterwards — repeating the
770
+ * writes out here would have them block on a connection the caller still holds.
771
+ */ async tearDownGroupMLS(stores, groupID) {
772
+ const store = await getP2PStore(stores);
773
+ await store.deleteMLSState(groupID, this.#deviceID);
774
+ stores.onCommit(()=>this.#registry.invalidate(groupID));
258
775
  }
259
776
  async leaveGroup(params) {
260
777
  // MLS does not allow a member to commit their own removal.
261
778
  // leaveGroup performs local cleanup: deletes MLS state and group membership.
262
- // The actual MLS group removal happens when another member calls removeGroupMember.
779
+ // The actual MLS group removal happens when an admin drives a Remove commit.
263
780
  const store = await getP2PStore(params.stores);
264
781
  await store.deleteMLSState(params.groupID, this.#deviceID);
265
- await store.removeGroupMember(params.groupID, params.identity.id, params.hlc);
782
+ await this.markGroupLeft(params);
266
783
  // Defer cache eviction to onCommit so a future caller wrapping these
267
784
  // deletes in withTransaction does not invalidate the cache when the row
268
785
  // delete rolls back. KubunDB's non-tx onCommit fires synchronously, so