@kubun/plugin-p2p 0.10.1 → 0.12.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 (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -0,0 +1,696 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { HLC } from '@kubun/hlc';
3
+ import { getKubunLogger } from '@kubun/logger';
4
+ import { getP2PStore } from '@kubun/store-p2p';
5
+ import { CommitRejectedError, createRecoveryRequest, joinGroupExternal, MissingLedgerEntriesError, openSealedGroupInfo, openSealedLedger, readGroupAnchor, readMessageEpoch, sealGroupInfo, sealLedger } from '@kumiai/mls';
6
+ import { fromB64 } from '@sozai/codec';
7
+ import { toISO } from '../context/types.js';
8
+ import { bootstrapGroupLedger } from './broadcast.js';
9
+ import { readRecoverySecret } from './group-anchor.js';
10
+ import { emitLedgerAffectedEvents } from './ledger-affected-events.js';
11
+ import { entriesRetainedByCommit, foldCommittedLedgerEntries } from './ledger-commit-fold.js';
12
+ import { DecryptError } from './mls-receive-errors.js';
13
+ import { mirrorRosterRoles, roleFromRoster } from './roster-projection.js';
14
+ /**
15
+ * How long a minted recovery request's private key is retained.
16
+ *
17
+ * Bounds a gather that never completes rather than expressing a security
18
+ * property: the key opens replies to ONE request id, and a reply arriving after
19
+ * this is simply unopenable. Matches `@kumiai/mls-rpc`'s own retention, so a
20
+ * kubun peer and an upstream one give up on the same gather at the same time.
21
+ */ const RECOVERY_REQUEST_TTL_MS = 120_000;
22
+ /**
23
+ * Adapts a `GroupHandleRegistry` group into the `@kumiai/rpc`
24
+ * {@link GroupMLS} lifecycle port: apply received Commits to advance the epoch,
25
+ * read the roster, seal this handle's ledger for another member's gather, and
26
+ * expose the epoch-independent recovery seed. group-rpc owns the transport +
27
+ * orchestration (subscribe the non-rotating handshake topic, run these methods,
28
+ * resync the app lane when the epoch advances); this adapter owns the MLS state,
29
+ * storage, and atomicity.
30
+ *
31
+ * The recovery members that throw below do so for reasons that live in KUBUN,
32
+ * not upstream: `@kumiai/mls` ships every primitive they would need. An earlier
33
+ * revision of this file justified the same throws by upstream gaps, and a later
34
+ * upstream release quietly made those justifications false. So a reader deciding
35
+ * whether one of these can now be wired must re-check the KUBUN-side statement
36
+ * in each message — not assume the blocker moved when a dependency did.
37
+ */ export function createGroupMLS(params) {
38
+ const { registry, stores, identity, hlc, groupID } = params;
39
+ const logger = params.logger ?? getKubunLogger('plugin-p2p:group-mls');
40
+ // The private half of each outstanding recovery request, in process memory.
41
+ //
42
+ // Deliberately NOT a store row. The gather this key serves is a live
43
+ // rendezvous the lane holds open; a restart loses the gather too, so a
44
+ // surviving key would have no reply left to open and would only be a raw
45
+ // private key sitting in SQLite at rest, where kubun's at-rest encryption is
46
+ // not yet wired. Losing it on restart costs a retry and nothing else.
47
+ const pending = new Map();
48
+ // The lane has no release hook: a gather that times out drops its request id
49
+ // without telling this port, so eviction on reply-open alone would leak one
50
+ // key per timed-out gather for the life of the process. Swept on mint, which
51
+ // is the only moment the map grows.
52
+ const sweep = ()=>{
53
+ const cutoff = Date.now() - RECOVERY_REQUEST_TTL_MS;
54
+ for (const [id, held] of pending){
55
+ if (held.mintedAt < cutoff) {
56
+ pending.delete(id);
57
+ }
58
+ }
59
+ };
60
+ return {
61
+ /**
62
+ * The DIDs this handle's ratchet tree currently holds a leaf for. Read-only.
63
+ *
64
+ * The lane diffs this around {@link GroupMLS.processCommit} to detect a
65
+ * Remove. Compare DIDs rather than count leaves, or a Commit carrying both an
66
+ * Add and a Remove reads as no change. Sourced from the ratchet TREE, not the
67
+ * store's roster projection — that is the authority the lane must agree with.
68
+ */ async rosterDIDs () {
69
+ return await registry.readHandle(groupID, (handle)=>handle.listMembers().map((member)=>member.id));
70
+ },
71
+ /**
72
+ * Read what a Commit says about itself — its framed epoch, its
73
+ * MLS-authenticated committer, and whether it is an external commit —
74
+ * WITHOUT advancing state. `null` for bytes that are not a Commit.
75
+ *
76
+ * The committer is recovered from the Commit's OWN bytes, never from the
77
+ * frame's transport sender — that is the hub's word.
78
+ *
79
+ * `external` must be passed through even though nothing here reads it: a
80
+ * rejoin moves no DID and no occupied leaf index, so the lane's anchor
81
+ * rotation is the only signal it has, and dropping the flag disables it
82
+ * silently.
83
+ */ async readCommitHeader (commit) {
84
+ return await registry.readHandle(groupID, async (handle)=>{
85
+ const header = await handle.readCommitHeader(commit);
86
+ if (header == null) {
87
+ return null;
88
+ }
89
+ return {
90
+ epoch: Number(header.epoch),
91
+ committerDID: header.committerDID,
92
+ external: header.external
93
+ };
94
+ });
95
+ },
96
+ /**
97
+ * Apply a received MLS handshake Commit: advance the local ratchet and write
98
+ * the membership delta derived by diffing the roster before and after.
99
+ *
100
+ * Runs INSIDE a single store transaction so the ratchet advance (persisted
101
+ * by the registry) and the resulting roster delta writes commit or roll back
102
+ * together. A roster-write failure after the ratchet persisted would
103
+ * otherwise leave a removed member's writes still applying. The registry
104
+ * holds the per-group lock across the whole transaction, so no concurrent
105
+ * same-group op can interleave.
106
+ *
107
+ * A frame this peer is not in a position to apply resolves `advanced:false`,
108
+ * NEVER a throw — a throw would leave the lane's cursor put and re-read the
109
+ * frame forever (a late joiner meeting its own add-commit, the first frame it
110
+ * reads, would wedge on it). All of these are `advanced:false`:
111
+ * - not a Commit, or a Commit framed at another epoch (past OR future);
112
+ * - a Commit this device authored (a member can never apply its own commit);
113
+ * - a Commit the group's control policy refuses.
114
+ *
115
+ * The ONE deliberate throw is a Commit this peer SHOULD apply but cannot,
116
+ * because the ledger entries it names will not resolve from the Commit's own
117
+ * frame — {@link MissingLedgerEntriesError}, which the lane recognizes as
118
+ * poison and steps over. A corrupt/forged frame is ack-and-skipped
119
+ * (`advanced:false`); a transient store fault propagates so the lane retries.
120
+ */ async processCommit (commit, context) {
121
+ // The transport sender is auxiliary (logging), NOT the MLS committer and
122
+ // never an authorization boundary — the Commit authenticates its committer
123
+ // internally.
124
+ logger.debug('processCommit', {
125
+ groupID,
126
+ senderDID: context.senderDID
127
+ });
128
+ // Resolve the commit's sealed ledger bodies BEFORE taking the apply lock.
129
+ // The frame resolver decrypts via the cached handle (`readHandle`), which
130
+ // re-enters the per-group mutex; that mutex is held for the whole apply
131
+ // transaction below, so resolving inside it would self-deadlock. The frame
132
+ // resolver is id-agnostic — it answers with every token in the blob — so a
133
+ // single drain with `[]` yields the whole frame's bodies. The unwrap is
134
+ // read-only and its result is re-verified (content-addressed + signature
135
+ // checked) by `processMessage`, so moving where it runs changes nothing
136
+ // durable or trusted; a blob this peer cannot open drains to `[]`, keeping
137
+ // the missing-entries poison contract intact. When the context carries no
138
+ // resolver, pass none so the store-based default resolves from the store.
139
+ let resolveOverride;
140
+ if (context.resolveLedgerEntries != null) {
141
+ const resolvedEntries = await context.resolveLedgerEntries([]);
142
+ resolveOverride = {
143
+ resolveLedgerEntries: async ()=>resolvedEntries
144
+ };
145
+ }
146
+ // The entries an accepted commit surfaces, collected for this call only.
147
+ // The sink is synchronous by contract, so it cannot await a store write:
148
+ // it only collects, and the fold runs after `processMessage` returns,
149
+ // inside the same transaction as the ratchet advance and the roster delta.
150
+ const surfacedEntries = [];
151
+ // Descriptors the fold produced, emitted only AFTER the transaction
152
+ // commits: an event announcing a row a rollback erased would be a lie.
153
+ let affected = [];
154
+ // The membership deltas this commit enacted, announced under the same
155
+ // past-commit rule as `affected`. This device did NOT author the commit
156
+ // (it declines its own below), so nothing else on this device announces
157
+ // them: the author's own device emits these from the producer path, which
158
+ // no fold ever visits, and the two therefore cannot both fire.
159
+ let joined = [];
160
+ let left = [];
161
+ let roleChanged = [];
162
+ // Set when the commit removed THIS device. Emitted last: the teardown it
163
+ // triggers disposes the listeners the membership events ask to reconcile.
164
+ let selfRemoved = null;
165
+ const advance = await registry.withHandleReplacingInTransaction(groupID, stores, async (handle, tx)=>{
166
+ // The Commit's cleartext wire-header epoch is the sender's pre-commit
167
+ // epoch — the epoch a receiver must be at to process it. A frame at
168
+ // any other epoch (past = already applied, future = a gap this peer
169
+ // has not reached, e.g. a late joiner's own add-commit) is not this
170
+ // member's to apply and is never opened — the blob riding it stays
171
+ // sealed because its entries are never resolved.
172
+ const messageEpoch = readMessageEpoch(commit);
173
+ if (messageEpoch == null || messageEpoch !== handle.epoch) {
174
+ return {
175
+ result: {
176
+ advanced: false
177
+ },
178
+ updated: handle
179
+ };
180
+ }
181
+ // The committer comes from the Commit's own authenticated bytes, not
182
+ // the frame sender. A member can never apply the frame that is its
183
+ // OWN commit — MLS merges a pending commit, it does not process one —
184
+ // so a device meeting its own commit in the log declines it.
185
+ const header = await handle.readCommitHeader(commit);
186
+ // `null` means "these bytes are not a Commit" and nothing else —
187
+ // upstream is explicit that it never means "a Commit I could not
188
+ // read". So this is also the content-type gate for the channel: a
189
+ // by-reference Proposal broadcast on the commit lane by any member
190
+ // stops here, before `processMessage` can store it in the pending
191
+ // set, where it would be absorbed into the next commit an innocent
192
+ // member authors and get that member's commit refused by every peer.
193
+ // Pinned in this position by `commit-ingest-rejects-proposals.test.ts`,
194
+ // because nothing else would fail if it moved.
195
+ if (header == null) {
196
+ logger.warn('commit ingest dropped a non-Commit message', {
197
+ groupID,
198
+ senderDID: context.senderDID
199
+ });
200
+ return {
201
+ result: {
202
+ advanced: false
203
+ },
204
+ updated: handle
205
+ };
206
+ }
207
+ if (header.committerDID === handle.credential.id) {
208
+ return {
209
+ result: {
210
+ advanced: false
211
+ },
212
+ updated: handle
213
+ };
214
+ }
215
+ const priorEpoch = handle.epoch;
216
+ const before = handle.listMembers();
217
+ // The ledger log's length BEFORE the commit is applied. The handle
218
+ // appends what an accepted commit enacted to the end of this log, in
219
+ // enactment order, so this index is the only way to tell this
220
+ // commit's entries from the ones the handle already held — the log
221
+ // records repeats, so a token diff would miss a re-enactment.
222
+ const ledgerLengthBeforeCommit = handle.ledger.length;
223
+ try {
224
+ // No commit policy is passed: the handle carries `@kumiai/mls`'s
225
+ // default one, which folds the roster from the signed control ledger
226
+ // and refuses a commit whose author lacks the permission for it.
227
+ // Passing a policy here would REPLACE that gate rather than add to it.
228
+ await handle.processMessage(commit);
229
+ } catch (error) {
230
+ // The commit enacts ledger entries this peer does not hold, and they
231
+ // will not resolve from the frame that carries them. This is the one
232
+ // deliberate throw: the lane treats it as poison, stepping over the
233
+ // frame rather than re-reading it. Let it propagate untouched — its
234
+ // NAME is what `isMissingLedgerEntries` matches.
235
+ if (error instanceof MissingLedgerEntriesError) {
236
+ throw error;
237
+ }
238
+ // Well-formed but refused: the group's control policy does not accept
239
+ // a commit from this committer. A refusal is a judgment, not a
240
+ // failure — there is nothing to retry, so ack-and-skip.
241
+ if (error instanceof CommitRejectedError) {
242
+ return {
243
+ result: {
244
+ advanced: false
245
+ },
246
+ updated: handle
247
+ };
248
+ }
249
+ // A Commit that fails the authenticated MLS check at the matching
250
+ // epoch is corrupt/forged — unrecoverable, so ack-and-skip rather
251
+ // than redeliver.
252
+ // biome-ignore lint/style/useErrorCause: DecryptError forwards its second argument as the cause
253
+ throw new DecryptError('Failed to process MLS commit', error);
254
+ }
255
+ const after = handle.listMembers();
256
+ const beforeIDs = new Set(before.map((member)=>member.id));
257
+ const afterIDs = new Set(after.map((member)=>member.id));
258
+ const removed = before.filter((member)=>!afterIDs.has(member.id)).map((member)=>member.id);
259
+ const added = after.filter((member)=>!beforeIDs.has(member.id)).map((member)=>member.id);
260
+ // All reads + writes use the transactional p2p store so they share the
261
+ // tx's single connection (a bare store read inside the tx would
262
+ // deadlock single-connection SQLite against the tx's write lock).
263
+ const localHLC = HLC.serialize(hlc.now());
264
+ const p2pStore = await getP2PStore(tx);
265
+ // One read of the pre-commit rows serves both announcements and the
266
+ // role comparison below: a removal has to report the role its target
267
+ // held while they were still a member, and the tombstone is about to
268
+ // put that row out of reach.
269
+ const membersBefore = await p2pStore.listGroupMembers(groupID);
270
+ const rowByDID = new Map(membersBefore.map((member)=>[
271
+ normalizeDID(member.member_did),
272
+ member
273
+ ]));
274
+ // One statement tombstones the whole removed set, so a commit that
275
+ // drops several members cannot land half its removals.
276
+ await p2pStore.markMembersRemoved(groupID, removed, localHLC);
277
+ // The ex-member's advertisement goes with its membership, in the
278
+ // same transaction: the row is a route MLS would refuse, and a
279
+ // projection still offering it makes the UI name a device the group
280
+ // no longer contains.
281
+ await p2pStore.deletePeerAnnouncements({
282
+ groupID,
283
+ peerDIDs: removed
284
+ });
285
+ // A removal naming THIS device says it has left, and nothing else
286
+ // ever said it: `setGroupStatus(id, 'left')` was written only by
287
+ // `markGroupLeft`, on the voluntary path.
288
+ //
289
+ // Marked here because this is the one moment the eviction is
290
+ // AUTHENTICATED — the commit naming this device was processed under
291
+ // the epoch it still held. Anything downstream (frames that stop
292
+ // opening, a quiet hub) is indistinguishable from an outage.
293
+ //
294
+ // The rows stay: they project what this device saw, and erasing
295
+ // local history on someone else's say-so is worse.
296
+ if (removed.some((memberDID)=>normalizeDID(memberDID) === normalizeDID(identity.id))) {
297
+ await p2pStore.setGroupStatus(groupID, 'left');
298
+ // The status is what a LATER read sees; this tells what is already
299
+ // running. `groupLeft` has one consumer (`hub/wiring.ts`) and it is
300
+ // the entire teardown: the group leaves the peer manager's
301
+ // in-memory `joined` set, its tunnel listeners go, its hub peers
302
+ // are disposed. Presence and sync read that set, never the status
303
+ // column, so writing the row alone left the device announcing
304
+ // itself and encrypting into a group MLS refused.
305
+ //
306
+ // Emitted from here and from `leaveGroup`, so both ways out produce
307
+ // the same teardown. Unlike `leaveGroup` this does NOT drop the MLS
308
+ // state: re-admission compares the Welcome's epoch against the
309
+ // persisted one, and a device that discarded it cannot be let back.
310
+ const row = await p2pStore.getGroup(groupID);
311
+ selfRemoved = row == null ? null : {
312
+ id: row.id,
313
+ groupID: row.id,
314
+ name: row.name,
315
+ description: row.description,
316
+ createdBy: row.created_by,
317
+ createdAt: toISO(row.created_at)
318
+ };
319
+ }
320
+ left = removed.map((memberDID)=>rowByDID.get(normalizeDID(memberDID))).filter((row)=>row != null).map((row)=>({
321
+ groupID,
322
+ memberDID: row.member_did,
323
+ role: row.role,
324
+ createdAt: toISO(row.created_at)
325
+ }));
326
+ // Roles come from the handle's own post-commit roster, not from the
327
+ // ratchet (which carries no kubun role) and not from a separately
328
+ // delivered ledger token. The roster folds the genesis anchor plus
329
+ // exactly the entries the authenticated commit chain enacted, in
330
+ // epoch order, so a receiver reads the same authority its commit
331
+ // policy enforces. Recovering a role change missed while offline
332
+ // additionally requires the device to apply the commit that carried
333
+ // it, which the seed pull does not currently do.
334
+ const roster = handle.roster;
335
+ const addedWithRoles = added.map((memberDID)=>({
336
+ memberDID,
337
+ role: roleFromRoster(roster, memberDID)
338
+ }));
339
+ // One multi-row insert for the whole added set.
340
+ await p2pStore.addGroupMembers(addedWithRoles.map(({ memberDID, role })=>({
341
+ group_id: groupID,
342
+ member_did: memberDID,
343
+ role,
344
+ hlc: localHLC
345
+ })));
346
+ // The rows were created by the write above, so their creation instant
347
+ // is now; reading them back would cost a query to learn what is
348
+ // already known.
349
+ const addedAt = new Date().toISOString();
350
+ joined = addedWithRoles.map(({ memberDID, role })=>({
351
+ groupID,
352
+ memberDID,
353
+ role,
354
+ createdAt: addedAt
355
+ }));
356
+ // A commit can also promote or demote a member it neither adds nor
357
+ // removes, so every member that survived it is reconciled too. The
358
+ // rows read above are the pre-commit ones, which is exactly what the
359
+ // roster must be compared against to tell a real change from a
360
+ // re-assertion of the role a member already held.
361
+ roleChanged = await mirrorRosterRoles({
362
+ store: p2pStore,
363
+ groupID,
364
+ roster,
365
+ memberDIDs: after.filter((member)=>beforeIDs.has(member.id)).map((member)=>member.id),
366
+ members: membersBefore
367
+ });
368
+ // A landed commit's surfaced entries fold through the same projection
369
+ // ingest a broadcast entry does, on the transaction's own store — the
370
+ // tx holds the write lock, so a bare store read here would deadlock
371
+ // single-connection SQLite.
372
+ //
373
+ // A role entry is NOT among the surfaced ones: the MLS library
374
+ // interprets it itself, folding it into the roster mirrored onto the
375
+ // rows above rather than handing it on. It is in the handle's ledger
376
+ // regardless — which is where the admin roster folds it from — but
377
+ // its HLC reaches this device by no other path, so it is recovered
378
+ // from the log and passed through the same ingest for its clock.
379
+ affected = await foldCommittedLedgerEntries({
380
+ handle,
381
+ entries: surfacedEntries,
382
+ retained: entriesRetainedByCommit(handle, ledgerLengthBeforeCommit),
383
+ p2pStore,
384
+ groupID,
385
+ hlc,
386
+ logger
387
+ });
388
+ return {
389
+ result: {
390
+ advanced: handle.epoch > priorEpoch
391
+ },
392
+ updated: handle
393
+ };
394
+ }, // Per-call handle wiring, scoped to the working handle this call
395
+ // restores. `resolveLedgerEntries` is pre-resolved above so the commit
396
+ // pre-pass resolves entries from the frame (via a plain non-locking
397
+ // resolver) rather than re-entering the per-group mutex the transaction
398
+ // holds; undefined falls back to the store-based default resolver.
399
+ // `onLedgerEntries` collects what the accepted commit surfaces.
400
+ {
401
+ ...resolveOverride != null ? {
402
+ resolveLedgerEntries: resolveOverride.resolveLedgerEntries
403
+ } : {},
404
+ onLedgerEntries: (entries)=>{
405
+ surfacedEntries.push(...entries);
406
+ }
407
+ }).catch((error)=>{
408
+ // A corrupt/forged Commit threw inside the transaction, so it rolled back
409
+ // (DB untouched). Ack-and-skip it: report no epoch advance so group-rpc
410
+ // acks and the hub stops redelivering — group-rpc re-fetches any Commit
411
+ // whose `processCommit` throws, so letting a corrupt frame escape here
412
+ // would poison-redeliver forever. MissingLedgerEntriesError (poison) and
413
+ // transient store faults are re-thrown so the lane classifies them.
414
+ if (error instanceof DecryptError) {
415
+ logger.warn('dropping corrupt MLS commit (ack-and-skip)', {
416
+ groupID,
417
+ error
418
+ });
419
+ return {
420
+ advanced: false
421
+ };
422
+ }
423
+ throw error;
424
+ });
425
+ // Only now, past commit: the rows the events announce are durable. A
426
+ // rollback (or an ack-and-skip) folded nothing, so there is nothing to
427
+ // announce. Emitting must not turn a landed commit into a failed one, so a
428
+ // faulting subscriber is logged, not propagated.
429
+ const emitter = params.emitter;
430
+ if (emitter != null) {
431
+ for (const ledger of affected){
432
+ try {
433
+ await emitLedgerAffectedEvents(emitter, {
434
+ applied: true,
435
+ affected: {
436
+ kind: 'ledger',
437
+ ledger
438
+ }
439
+ });
440
+ } catch (error) {
441
+ logger.warn('committed ledger entry event emit failed', {
442
+ groupID,
443
+ error
444
+ });
445
+ }
446
+ }
447
+ for (const [eventName, members] of [
448
+ [
449
+ 'groupMemberJoined',
450
+ joined
451
+ ],
452
+ [
453
+ 'groupMemberLeft',
454
+ left
455
+ ],
456
+ [
457
+ 'groupMemberRoleChanged',
458
+ roleChanged
459
+ ]
460
+ ]){
461
+ for (const member of members){
462
+ try {
463
+ await emitter.emit(eventName, member);
464
+ } catch (error) {
465
+ logger.warn('group membership event emit failed', {
466
+ groupID,
467
+ eventName,
468
+ error
469
+ });
470
+ }
471
+ }
472
+ }
473
+ // After `groupMemberLeft`: that reconciles the group's tunnel listeners,
474
+ // this disposes them. Reversed, the reconcile rebuilds them.
475
+ if (selfRemoved != null) {
476
+ try {
477
+ await emitter.emit('groupLeft', selfRemoved);
478
+ } catch (error) {
479
+ logger.warn('groupLeft emit failed', {
480
+ groupID,
481
+ error
482
+ });
483
+ }
484
+ }
485
+ }
486
+ return advance;
487
+ },
488
+ /**
489
+ * Mint the signed rendezvous request this peer publishes to ask the group
490
+ * for its state, retaining the private half of the request's ephemeral HPKE
491
+ * keypair so the replies can be opened.
492
+ *
493
+ * The request token is signed by this member's identity key. The private half
494
+ * is held in `pending` under `requestID`, in process memory only, and evicted
495
+ * by `sweep()` on a TTL — the lane has no release hook, so a timed-out gather
496
+ * drops its request id silently and an evict-on-reply-open port would leak a
497
+ * key per timeout.
498
+ */ async createRecoveryRequest (requestID) {
499
+ sweep();
500
+ // Inside the registry lock like every other handle read: the primitive is
501
+ // read-only, but the handle it reads may not be observed mid-replacement.
502
+ const { request, ephemeralPrivateKey } = await registry.readHandle(groupID, (handle)=>createRecoveryRequest({
503
+ group: handle,
504
+ identity,
505
+ requestID
506
+ }));
507
+ pending.set(requestID, {
508
+ ephemeralPrivateKey,
509
+ mintedAt: Date.now()
510
+ });
511
+ return new TextEncoder().encode(request);
512
+ },
513
+ /**
514
+ * Answer another member's GroupInfo request: seal current GroupInfo to the
515
+ * ephemeral key in the signed request and vouch for it with a membership
516
+ * attestation.
517
+ *
518
+ * The seal is HPKE base mode and authenticates NOBODY, so the reply must carry
519
+ * a membership attestation signed by this member's DID key — that is what lets
520
+ * the requester tell it from an observer's forgery. Confidentiality against
521
+ * the relaying hub is intact regardless: the seal targets a per-request
522
+ * ephemeral public key whose private half the hub never sees.
523
+ */ async sealGroupInfo (request) {
524
+ // Throws for a request this member refuses — a requester holding no leaf
525
+ // in THIS handle's tree gets nothing from a responder that has applied
526
+ // their removal — and group-rpc reads the throw as "stay silent".
527
+ // Roster-intrinsic, so there is no check to write here.
528
+ return await registry.readHandle(groupID, (handle)=>sealGroupInfo({
529
+ group: handle,
530
+ identity,
531
+ request: new TextDecoder().decode(request)
532
+ }));
533
+ },
534
+ /**
535
+ * Open a sealed GroupInfo reply and build the external commit that rejoins
536
+ * this peer, adopted only once the head accepts it.
537
+ *
538
+ * Wired now: it consumes what {@link GroupMLS.createRecoveryRequest}
539
+ * retains — the ephemeral private key held under `requestID` in `pending` —
540
+ * and builds the external commit under the signing `identity`
541
+ * {@link CreateGroupMLSParams} now carries.
542
+ *
543
+ * The rejoin Commit itself has a home: the lane publishes
544
+ * `PendingRecovery.commit` to the commit topic under a compare-and-set at
545
+ * the head, so this peer does not have to fan it out and cannot fork the
546
+ * group by applying it locally. That is not what blocks this member.
547
+ */ async applyRecovery (sealed, requestID) {
548
+ const held = pending.get(requestID);
549
+ if (held == null) {
550
+ return null;
551
+ }
552
+ const rejoined = await registry.readHandle(groupID, async (handle)=>{
553
+ let groupInfo;
554
+ try {
555
+ groupInfo = await openSealedGroupInfo({
556
+ group: handle,
557
+ sealed,
558
+ requestID,
559
+ ephemeralPrivateKey: held.ephemeralPrivateKey
560
+ });
561
+ } catch {
562
+ // Bytes this peer cannot open OR cannot trust. A forgery that merely
563
+ // decrypts still fails the membership attestation, and the two are
564
+ // indistinguishable to the caller on purpose: neither is a reply.
565
+ return null;
566
+ }
567
+ return await joinGroupExternal({
568
+ identity,
569
+ groupInfo,
570
+ credential: handle.credential,
571
+ resync: true
572
+ });
573
+ });
574
+ if (rejoined == null) {
575
+ return null;
576
+ }
577
+ return {
578
+ commit: rejoined.commitMessage,
579
+ // Adopted ONLY once the head accepts the commit. The lane publishes it
580
+ // under a compare-and-set, and a peer that adopted first would sit on a
581
+ // branch of its own the moment it lost that race.
582
+ onAccepted: async ()=>{
583
+ pending.delete(requestID);
584
+ await registry.replaceHandle(groupID, rejoined.group, {
585
+ stores
586
+ });
587
+ }
588
+ };
589
+ },
590
+ /**
591
+ * Whether the entries this handle holds fold to the head its own GroupContext
592
+ * attests to. Purely local, read-only: the completeness gate every lane
593
+ * operation runs before it pulls or commits. False means the handle rejoined
594
+ * with an empty ledger against a live head and must bootstrap before it can be
595
+ * trusted to judge a commit.
596
+ */ async isLedgerComplete () {
597
+ return await registry.readHandle(groupID, (handle)=>handle.isLedgerComplete());
598
+ },
599
+ /**
600
+ * The whole ordered ledger this handle holds, as signed tokens. Purely local,
601
+ * read-only. Order is load-bearing: the head is a chain digest, so a permuted
602
+ * list folds to a different head.
603
+ */ async getLedger () {
604
+ return await registry.readHandle(groupID, (handle)=>handle.getLedger());
605
+ },
606
+ /**
607
+ * Answer another member's ledger gather: seal this handle's whole ordered
608
+ * ledger to the ephemeral key inside the requester's signed request.
609
+ *
610
+ * Authorization is roster-intrinsic: the primitive refuses a requester with no
611
+ * leaf in THIS handle's tree, which matters because the ledger is the group's
612
+ * entire authority state travelling on a public, secretless topic. A throw
613
+ * means "stay silent" to group-rpc.
614
+ *
615
+ * The payload is read from the handle and never handed in, so this cannot be
616
+ * made to seal another group's state.
617
+ */ async sealLedger (request) {
618
+ return await registry.readHandle(groupID, (handle)=>sealLedger({
619
+ group: handle,
620
+ request: new TextDecoder().decode(request)
621
+ }));
622
+ },
623
+ /**
624
+ * Open a sealed gather reply with the key minted for `requestID`.
625
+ *
626
+ * Wired now: it reads the ephemeral private key
627
+ * {@link GroupMLS.createRecoveryRequest} retains under that id in `pending`.
628
+ * It moves with that member, not with {@link GroupMLS.sealLedger} — the
629
+ * responder side needs only the handle and the request, so both are now live
630
+ * on the same custody the requester side holds.
631
+ */ async openSealedLedger (sealed, requestID) {
632
+ const held = pending.get(requestID);
633
+ if (held == null) {
634
+ return null;
635
+ }
636
+ return await registry.readHandle(groupID, async (handle)=>{
637
+ try {
638
+ // The key is NOT consumed here: every responder to a gather answers,
639
+ // and the requester must still be able to open the next reply after
640
+ // discarding one it could not use.
641
+ return await openSealedLedger({
642
+ group: handle,
643
+ sealed,
644
+ requestID,
645
+ ephemeralPrivateKey: held.ephemeralPrivateKey
646
+ });
647
+ } catch {
648
+ return null;
649
+ }
650
+ });
651
+ },
652
+ /**
653
+ * Install a gathered ledger, replacing what this handle holds once the list
654
+ * reproduces the head the handle's own GroupContext authenticates.
655
+ *
656
+ * The install runs FIRST: a list that does not reproduce the authenticated head
657
+ * throws before a durable row is touched, so a withholding responder is
658
+ * rejected whole rather than merged. Only then does kubun's mirror follow.
659
+ *
660
+ * Deliberately NOT in a transaction — one spanning this would hold kubun's
661
+ * single SQLite connection across an unbounded network wait. Safe because
662
+ * nothing is written until the head check passes, and this REPLACES a ledger
663
+ * rather than appending, so the next bootstrap heals a stale mirror.
664
+ */ async bootstrapLedger (tokens) {
665
+ const p2pStore = await getP2PStore(stores);
666
+ await bootstrapGroupLedger({
667
+ p2pStore,
668
+ // Only the projection rebuild is transactional — never the install
669
+ // above it, which waits on the network.
670
+ withProjectionTransaction: (run)=>stores.withTransaction(async (tx)=>await run(await getP2PStore(tx))),
671
+ hlc,
672
+ getGroupAnchor: (id)=>registry.readHandle(id, async (handle)=>readGroupAnchor(handle)),
673
+ getGroupLedger: (id)=>registry.readHandle(id, async (handle)=>handle.ledger),
674
+ logger,
675
+ installGroupLedger: async (id, gathered)=>await registry.withHandle(id, async (handle)=>{
676
+ await handle.bootstrapLedger(gathered);
677
+ // Read under the same hold as the install: this is the roster that
678
+ // install folded, not whatever a later operation may have left.
679
+ return handle.roster;
680
+ })
681
+ }, tokens, groupID);
682
+ },
683
+ /**
684
+ * The epoch-independent secret for the non-rotating handshake/recovery topic,
685
+ * sourced from the genesis anchor (immutable, authenticated by the GroupInfo
686
+ * signature → every member on every epoch reads the same value). Throws when
687
+ * the anchor is absent: a group with no anchor cannot participate in recovery.
688
+ */ async exportRecoverySecret () {
689
+ const anchor = await registry.readHandle(groupID, (handle)=>readGroupAnchor(handle));
690
+ if (anchor == null) {
691
+ throw new Error(`group ${groupID} has no genesis anchor; cannot derive recovery secret`);
692
+ }
693
+ return fromB64(readRecoverySecret(anchor));
694
+ }
695
+ };
696
+ }