@kubun/plugin-p2p 0.11.0 → 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 (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 +56 -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
@@ -3,6 +3,14 @@ import { getP2PStore } from '@kubun/store-p2p';
3
3
  import { createMutex } from '../util/mutex.js';
4
4
  import { restoreMLSGroupHandle } from './mls-group-handle.js';
5
5
  import { fromMLSStateRow, serializeMLSGroupState, toMLSStateInsert } from './mls-state.js';
6
+ /**
7
+ * The backstop deadline on acquiring a group's handle.
8
+ *
9
+ * Every hold is in-memory work bounded by one MLS operation — microseconds to
10
+ * milliseconds — so 15 s is not a budget, it is the point past which the only
11
+ * remaining explanation is a lock-order bug. Exceeding it fails the waiting
12
+ * operation, which releases whatever the waiter held and lets the holder finish.
13
+ */ const MUTEX_ACQUIRE_TIMEOUT_MS = 15_000;
6
14
  /**
7
15
  * Single canonical access point for a device's MLS `GroupHandle` instances.
8
16
  *
@@ -13,17 +21,38 @@ import { fromMLSStateRow, serializeMLSGroupState, toMLSStateInsert } from './mls
13
21
  *
14
22
  * All five mutation paths (manager member ops, context handlers, broadcast
15
23
  * service encrypt/decrypt, hub-tunnel sync listener, hub-tunnel sync provider)
16
- * route MLS state through this registry. Spec:
17
- * `docs/superpowers/specs/2026-05-09-group-handle-registry-design.md`.
24
+ * route MLS state through this registry.
18
25
  */ export class GroupHandleRegistry {
19
26
  #stores;
20
27
  #deviceID;
21
28
  #logger;
22
29
  #entries = new Map();
30
+ // Last epoch this registry OBSERVED on a group's handle, published for
31
+ // synchronous readers. Deliberately a SEPARATE map from `#entries`: the
32
+ // transactional advance path swaps the durable row and then `invalidate`s the
33
+ // cached handle, so an epoch stored on the entry would be thrown away by the
34
+ // very operation that advanced it — which is exactly how a reader ends up
35
+ // answering a pre-commit epoch forever.
36
+ #epochs = new Map();
37
+ // Notified when a published epoch changes — see `onEpochChanged`.
38
+ #epochListeners = new Set();
39
+ #mutexTimeoutMs;
40
+ // Snapshots numbered in mutex order, and the highest number already written /
41
+ // already published. All three exist because the persist and the epoch publish
42
+ // happen after the lock is released — see `#persistOrdered`.
43
+ #stateSequence = new Map();
44
+ #persistedSequence = new Map();
45
+ #publishedSequence = new Map();
46
+ // How many times each group's cache has been dropped, and the restore each
47
+ // group currently has in flight. Both exist because restores run outside the
48
+ // mutex — see `#withLockedHandle`.
49
+ #generations = new Map();
50
+ #restoring = new Map();
23
51
  constructor(params){
24
52
  this.#stores = params.stores;
25
53
  this.#deviceID = params.deviceID;
26
54
  this.#logger = params.logger ?? getKubunLogger('plugin-p2p:group-handle-registry');
55
+ this.#mutexTimeoutMs = params.mutexTimeoutMs ?? MUTEX_ACQUIRE_TIMEOUT_MS;
27
56
  }
28
57
  /**
29
58
  * Acquire the canonical handle for `groupID`, run `fn` under the per-group
@@ -34,31 +63,26 @@ import { fromMLSStateRow, serializeMLSGroupState, toMLSStateInsert } from './mls
34
63
  * epoch-bumping ops that produce a fresh `GroupHandle` (commitInvite,
35
64
  * removeMember, processCommit), use `withHandleReplacing` instead.
36
65
  *
37
- * Bound: when called under a caller transaction that commits later, this
38
- * mutates/eager-uses the shared cached handle and releases the mutex before
39
- * that commit, so a concurrent same-group op can observe the uncommitted
40
- * handle during the window. `onRollback` repairs the cache after a rollback;
41
- * the live exposure is a concurrent cross-epoch decrypt during a rolling-back
42
- * membership mutation, which fails the MLS check and skips its persist (a
43
- * dropped message, never a durable divergence), and only on a multi-connection
44
- * adapter. The transactional epoch-bumping path uses
45
- * `withHandleReplacingInTransaction` (fresh handle + mutex held across commit)
46
- * to avoid the window entirely.
47
- *
48
- * `options.stores` overrides the constructor-stored `StoreProvider` for the
49
- * restore + persist DB calls. Callers running inside an engine transaction
50
- * (e.g. context handlers under `mutateGraph`) MUST pass the transactional
51
- * `tx` so reads + writes share the tx's connection. Without this, SQLite's
52
- * write lock held by the outer tx blocks the registry's persist forever.
66
+ * BOUND: under a caller transaction that commits later, the mutex releases
67
+ * before that commit, so a concurrent same-group op can observe the uncommitted
68
+ * handle. `onRollback` repairs the cache; the live exposure is a cross-epoch
69
+ * decrypt that fails the MLS check and skips its persist a dropped message,
70
+ * never a durable divergence, and only on a multi-connection adapter. Use
71
+ * `withHandleReplacingInTransaction` to avoid the window entirely.
72
+ *
73
+ * The persist runs AFTER the mutex is released — see {@link #withLockedHandle}
74
+ * but is still awaited here, so a caller that returns has its state durable.
53
75
  */ withHandle(groupID, fn, options) {
54
76
  return this.#withLockedHandle(groupID, options, async (entry, stores)=>{
55
77
  const result = await fn(entry.handle);
56
- await this.#persist(stores, groupID, entry.handle);
57
78
  // `fn` may have mutated the handle in place before the persist; if the
58
79
  // surrounding transaction rolls back, the DB row reverts but the cached
59
80
  // handle stays advanced, so drop it and re-restore on next access.
60
81
  stores.onRollback(()=>this.invalidate(groupID));
61
- return result;
82
+ return {
83
+ result,
84
+ persist: true
85
+ };
62
86
  });
63
87
  }
64
88
  /**
@@ -72,7 +96,10 @@ import { fromMLSStateRow, serializeMLSGroupState, toMLSStateInsert } from './mls
72
96
  * use `withHandle`, which persists the advanced state.
73
97
  */ readHandle(groupID, fn, options) {
74
98
  return this.#withLockedHandle(groupID, options, async (entry)=>{
75
- return await fn(entry.handle);
99
+ return {
100
+ result: await fn(entry.handle),
101
+ persist: false
102
+ };
76
103
  });
77
104
  }
78
105
  /**
@@ -87,13 +114,15 @@ import { fromMLSStateRow, serializeMLSGroupState, toMLSStateInsert } from './mls
87
114
  return this.#withLockedHandle(groupID, options, async (entry, stores)=>{
88
115
  const { result, updated } = await fn(entry.handle);
89
116
  entry.handle = updated;
90
- await this.#persist(stores, groupID, updated);
91
117
  // Eager swap: the cache now holds the advanced handle but the persist is
92
118
  // uncommitted. If the surrounding transaction rolls back, the DB row
93
119
  // reverts while the cache stays advanced, so drop it and re-restore the
94
120
  // reverted row on next access.
95
121
  stores.onRollback(()=>this.invalidate(groupID));
96
- return result;
122
+ return {
123
+ result,
124
+ persist: true
125
+ };
97
126
  });
98
127
  }
99
128
  /**
@@ -109,68 +138,316 @@ import { fromMLSStateRow, serializeMLSGroupState, toMLSStateInsert } from './mls
109
138
  * the working handle and the transactional `StoreProvider` so it can write
110
139
  * those other rows on the same connection.
111
140
  *
112
- * WHY the lock spans commit: holding the mutex until after commit means the
113
- * `onCommit` invalidation fires while the lock is still held. By the time a
114
- * concurrent same-group op acquires the lock, the cache is already invalidated
115
- * and the committed advanced row is durable, so the concurrent op re-restores
116
- * the advanced handle. There is no post-mutex / pre-commit window for a
117
- * concurrent op to interleave and clobber the advance.
118
- *
119
- * WHY a fresh working handle rather than the cached one: epoch ops here
120
- * advance the MLS ratchet IN PLACE (`handle.processMessage` mutates the handle
121
- * object and returns bytes, not a fresh handle). If the callback ran against
122
- * the cached `entry.handle`, that in-place mutation would advance the cache
123
- * immediately but the transaction is still uncommitted. On ROLLBACK the DB
124
- * ratchet row reverts while the cached handle stays advanced, so cache and DB
125
- * diverge. Restoring a throwaway working handle keeps the cache untouched: on
126
- * rollback the cache still matches the reverted DB; on commit the cache is
127
- * invalidated and the next access lazily re-restores the committed advanced
128
- * state. This costs one extra MLS-state restore per call.
129
- */ async withHandleReplacingInTransaction(groupID, stores, fn) {
141
+ * The lock MUST span the commit: the `onCommit` invalidation then fires while it
142
+ * is still held, so there is no post-mutex / pre-commit window for a concurrent
143
+ * op to clobber the advance.
144
+ *
145
+ * The working handle MUST be fresh, not the cached one: `processMessage` advances
146
+ * the ratchet IN PLACE, so running against the cache would advance it while the
147
+ * transaction is still uncommitted, and a rollback would leave the cache ahead of
148
+ * the reverted DB row. Costs one extra MLS-state restore per call.
149
+ *
150
+ * THE ONE PATH that still touches the database under the mutex, because its
151
+ * whole contract is that the lock spans the commit. It is therefore the only
152
+ * remaining candidate to be the holder in a lock-order cycle see
153
+ * {@link #withLockedHandle} for why that matters and the acquisition
154
+ * deadline on every other caller is what stops such a cycle being permanent.
155
+ */ async withHandleReplacingInTransaction(groupID, stores, fn, options) {
130
156
  const entry = this.#getOrCreateEntry(groupID);
131
157
  return entry.mutex.run(async ()=>{
132
158
  return await stores.withTransaction(async (tx)=>{
133
- const workingHandle = await this.#restore(tx, groupID);
159
+ const workingHandle = await this.#restore(tx, groupID, options);
134
160
  const { result, updated } = await fn(workingHandle, tx);
135
161
  await this.#persist(tx, groupID, updated);
136
- tx.onCommit(()=>this.invalidate(groupID));
162
+ tx.onCommit(()=>{
163
+ // Publish the advance BEFORE dropping the cached handle. This is the
164
+ // receive path: applying someone else's commit is the one way a
165
+ // device's epoch moves without it ever sealing anything, so if the
166
+ // advance is not published here nothing else republishes it and every
167
+ // later commit is classified against a pre-commit number. Inside
168
+ // `onCommit` because the row is only then durable — a rollback must
169
+ // leave the published epoch where it was.
170
+ this.#publishEpoch(groupID, updated);
171
+ this.invalidate(groupID);
172
+ });
137
173
  return result;
138
174
  });
139
- });
175
+ }, this.#acquireOptions(groupID));
140
176
  }
141
177
  /**
142
- * Shared prelude: resolve stores, get-or-create entry, acquire per-group
143
- * mutex, lazy-restore on first acquire, run `fn` (which is responsible for
144
- * the post-callback persist + cache update). Centralizes lock/restore
145
- * semantics so `withHandle` and `withHandleReplacing` cannot drift.
146
- */ #withLockedHandle(groupID, options, fn) {
178
+ * Shared prelude: resolve stores, get-or-create entry, restore the handle if
179
+ * the cache is empty, run `fn` under the per-group mutex, then persist.
180
+ * Centralizes lock/restore semantics so `withHandle` and
181
+ * `withHandleReplacing` cannot drift.
182
+ *
183
+ * **The database is never touched while the mutex is held** — restore before
184
+ * the lock, persist after. Kysely's SQLite dialect has one connection, so a
185
+ * query issued while a transaction is open queues with no timeout, and every
186
+ * `request*` control mutation holds that connection while waiting for this
187
+ * mutex. A holder waiting on the connection in return wedged the group for the
188
+ * life of the process. See `group-mutex-db-deadlock.test.ts`.
189
+ *
190
+ * The persist is still awaited by the caller, so durability is unchanged.
191
+ */ async #withLockedHandle(groupID, options, fn) {
147
192
  const stores = options?.stores ?? this.#stores;
148
- const entry = this.#getOrCreateEntry(groupID);
149
- return entry.mutex.run(async ()=>{
150
- if (entry.handle == null) {
151
- entry.handle = await this.#restore(stores, groupID);
193
+ // Restoring outside the lock costs a re-read when an applied commit
194
+ // invalidates while this call queues; the generation counter detects that.
195
+ //
196
+ // Bounded by TIME, not attempts: `walkCommits` invalidates once per applied
197
+ // commit, so a count bound turns an ordinary catch-up burst into a throw for
198
+ // anything reading alongside it. Each pass waits on a real acquisition and a
199
+ // real row read, so there is nothing to spin on.
200
+ const deadline = Date.now() + this.#mutexTimeoutMs;
201
+ for(let attempt = 0;; attempt++){
202
+ const entry = this.#getOrCreateEntry(groupID);
203
+ const generation = this.#generation(groupID);
204
+ const restored = entry.handle == null ? await this.#restoreShared(stores, groupID, generation) : undefined;
205
+ const outcome = await entry.mutex.run(async ()=>{
206
+ if (this.#generation(groupID) !== generation || this.#entries.get(groupID) !== entry) {
207
+ // The cache was dropped while this call queued for the lock.
208
+ // Anything read before that is stale, and this entry is no longer
209
+ // the one the registry hands out.
210
+ return {
211
+ retry: true
212
+ };
213
+ }
214
+ if (entry.handle == null) {
215
+ if (restored == null) {
216
+ return {
217
+ retry: true
218
+ };
219
+ }
220
+ entry.handle = restored;
221
+ }
222
+ const { result, persist } = await fn(entry, stores);
223
+ // Read AFTER `fn`, still under the mutex: `withHandleReplacing`
224
+ // rebinds `entry.handle`, and `withHandle` may have advanced it in
225
+ // place. Skipped on throw — a callback that failed left no state worth
226
+ // publishing, and the next successful operation republishes.
227
+ const snapshot = entry.handle;
228
+ // Numbered under the lock: the only place snapshot order is defined.
229
+ // Numbering at the persist would number them in whatever order the
230
+ // released callers arrived, letting an older claim to be newer.
231
+ const sequence = (this.#stateSequence.get(groupID) ?? 0) + 1;
232
+ this.#stateSequence.set(groupID, sequence);
233
+ return {
234
+ retry: false,
235
+ result,
236
+ snapshot,
237
+ persist,
238
+ sequence
239
+ };
240
+ }, this.#acquireOptions(groupID));
241
+ if (outcome.retry) {
242
+ if (Date.now() >= deadline) {
243
+ throw new Error(`Group ${groupID} handle could not be held: its cache was invalidated ${attempt + 1} times in ${this.#mutexTimeoutMs}ms, each time before this call could take the lock`);
244
+ }
245
+ continue;
152
246
  }
153
- return fn(entry, stores);
154
- });
247
+ // Epoch-change listeners read the PERSISTED row (the app lane re-exports
248
+ // its anchor from it), so an epoch published ahead of its row puts the
249
+ // announce on a topic nobody listens to.
250
+ //
251
+ // A non-persisting op therefore must NOT publish: `readHandle` snapshots
252
+ // the entry a writer just advanced in place and has nothing to await, so
253
+ // it would publish ahead of that writer's row and claim the sequence
254
+ // number that then suppresses the writer's own publish.
255
+ if (outcome.persist) {
256
+ await this.#persistOrdered(stores, groupID, outcome.snapshot, outcome.sequence);
257
+ this.#publishOrdered(groupID, outcome.snapshot, outcome.sequence);
258
+ } else if (this.#epochs.get(groupID) === undefined) {
259
+ // Except a first observation, which notifies nobody but must be recorded
260
+ // — otherwise the next advance reads as the first one and is swallowed.
261
+ this.#publishOrdered(groupID, outcome.snapshot, outcome.sequence);
262
+ }
263
+ return outcome.result;
264
+ }
265
+ }
266
+ /**
267
+ * Turns a residual lock-order bug into one failed operation instead of a dead
268
+ * group: the waiter giving up closes its own transaction, so the holder
269
+ * proceeds and the group recovers without an app restart.
270
+ */ #acquireOptions(groupID) {
271
+ return {
272
+ acquireTimeoutMs: this.#mutexTimeoutMs,
273
+ acquireTimeoutError: ()=>{
274
+ this.#logger.error('group handle lock not acquired', {
275
+ groupID,
276
+ deviceID: this.#deviceID,
277
+ timeoutMs: this.#mutexTimeoutMs
278
+ });
279
+ return new Error(`Group ${groupID} handle is locked: no operation released it within ${this.#mutexTimeoutMs}ms`);
280
+ }
281
+ };
282
+ }
283
+ /**
284
+ * Publish outside the mutex without an older snapshot republishing over a
285
+ * newer one. `#publishEpoch` is deliberately non-monotonic — a rolled-back
286
+ * advance republishes the LOWER epoch — so only the sequence can say which
287
+ * snapshot is later.
288
+ */ #publishOrdered(groupID, handle, sequence) {
289
+ if ((this.#publishedSequence.get(groupID) ?? 0) > sequence) {
290
+ return;
291
+ }
292
+ this.#publishedSequence.set(groupID, sequence);
293
+ this.#publishEpoch(groupID, handle);
294
+ }
295
+ /**
296
+ * Persist outside the mutex without a stale snapshot landing on a fresher one.
297
+ * A lower sequence is strictly older state; skipping it is safe because it is
298
+ * a prefix of what the fresher row already says. A single-connection adapter
299
+ * keeps issue order, a multi-connection one need not.
300
+ */ async #persistOrdered(stores, groupID, handle, sequence) {
301
+ if ((this.#persistedSequence.get(groupID) ?? 0) > sequence) {
302
+ return;
303
+ }
304
+ await this.#persist(stores, groupID, handle);
305
+ if ((this.#persistedSequence.get(groupID) ?? 0) < sequence) {
306
+ this.#persistedSequence.set(groupID, sequence);
307
+ }
308
+ }
309
+ /**
310
+ * The last epoch this registry observed for `groupID`, or `null` if it has
311
+ * never held a handle for it. Synchronous, because the one port that needs it
312
+ * — `GroupCrypto.epoch()` — is synchronous by contract while every handle
313
+ * access here is async and mutex-guarded.
314
+ *
315
+ * Publishes a NUMBER, never the handle: a scalar cannot advance a ratchet, skip
316
+ * the per-group mutex, or mutate MLS state. DO NOT widen this to return the
317
+ * handle — the mutex is what makes the ratchet advance and its row write one
318
+ * atomic step.
319
+ *
320
+ * Deliberately NOT monotonic: a rolled-back advance must republish the LOWER
321
+ * epoch, or the reader is pinned above the state the device holds. Genuine
322
+ * backwards installs are refused upstream, by `replaceHandle`'s epoch guard and
323
+ * `saveMLSState`'s no-op on a lower row.
324
+ */ groupEpoch(groupID) {
325
+ return this.#epochs.get(groupID) ?? null;
326
+ }
327
+ /**
328
+ * Called when the epoch published for a group CHANGES — an advance, or the
329
+ * lower republish a rolled-back one produces. Returns the unsubscribe.
330
+ *
331
+ * The first observation of a group is deliberately NOT a change: restoring a
332
+ * handle at boot publishes an epoch where there was none, and treating that as
333
+ * a rotation would have every group fire its epoch-change consumers once per
334
+ * process start.
335
+ *
336
+ * Listeners run INSIDE the per-group mutex and inside whatever transaction the
337
+ * advancing operation holds, so one must never await the registry or the store
338
+ * from here. Defer the work.
339
+ */ onEpochChanged(listener) {
340
+ this.#epochListeners.add(listener);
341
+ return ()=>{
342
+ this.#epochListeners.delete(listener);
343
+ };
344
+ }
345
+ #publishEpoch(groupID, handle) {
346
+ const epoch = Number(handle.epoch);
347
+ const previous = this.#epochs.get(groupID);
348
+ this.#epochs.set(groupID, epoch);
349
+ if (previous === undefined || previous === epoch) {
350
+ return;
351
+ }
352
+ for (const listener of this.#epochListeners){
353
+ try {
354
+ listener(groupID, epoch);
355
+ } catch (error) {
356
+ // A consumer that throws must not fail the operation that advanced the
357
+ // epoch: the handle is already swapped and its row already written.
358
+ this.#logger.warn('epoch-change listener threw', {
359
+ groupID,
360
+ epoch,
361
+ error
362
+ });
363
+ }
364
+ }
155
365
  }
156
366
  /**
157
367
  * Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
158
368
  * Called by `leaveGroup` after the MLS state row is deleted, and by tests.
369
+ *
370
+ * The published epoch is deliberately LEFT in place. Invalidation is how this
371
+ * registry says "re-read the durable row", not "this device no longer has an
372
+ * epoch" — and the transactional advance path invalidates immediately after
373
+ * committing an advance, so clearing here would discard the newest epoch the
374
+ * device has. The next restore republishes whatever the row actually holds.
159
375
  */ invalidate(groupID) {
160
376
  this.#entries.delete(groupID);
377
+ // Bumped so a restore already in flight — they run outside the mutex now —
378
+ // knows the row it read has been superseded and must not be installed. See
379
+ // {@link #withLockedHandle}.
380
+ this.#generations.set(groupID, this.#generation(groupID) + 1);
381
+ }
382
+ /** How many times this group's cache has been dropped. */ #generation(groupID) {
383
+ return this.#generations.get(groupID) ?? 0;
384
+ }
385
+ /**
386
+ * Restore, sharing one read between callers that arrive together — but only
387
+ * through the SAME provider. A caller inside a transaction must not join a
388
+ * device-wide read: that read queues behind its own open transaction, so it
389
+ * would be waiting on itself.
390
+ */ #restoreShared(stores, groupID, generation) {
391
+ const inFlight = this.#restoring.get(groupID);
392
+ if (inFlight != null) {
393
+ // Generation matters as much as the provider: a read started before a
394
+ // commit returns the pre-commit row, and a caller arriving after that
395
+ // commit would adopt it with its own generation check satisfied.
396
+ return inFlight.stores === stores && inFlight.generation === generation ? inFlight.promise : this.#restore(stores, groupID);
397
+ }
398
+ const promise = this.#restore(stores, groupID);
399
+ this.#restoring.set(groupID, {
400
+ stores,
401
+ generation,
402
+ promise
403
+ });
404
+ void promise.finally(()=>{
405
+ if (this.#restoring.get(groupID)?.promise === promise) {
406
+ this.#restoring.delete(groupID);
407
+ }
408
+ })// A group with no MLS state rejects here, which is ordinary; the caller
409
+ // handles the rejection it was handed, and this cleanup branch must not
410
+ // surface a second, unhandled one.
411
+ .catch(()=>{});
412
+ return promise;
161
413
  }
162
414
  /**
163
415
  * Atomically swap+persist the cached handle under the per-group mutex, so a
164
416
  * concurrent decrypt runs strictly before (stale handle, fails harmlessly) or
165
417
  * after (new handle), never interleaved.
418
+ *
419
+ * MONOTONIC in the epoch: a handle strictly BEHIND the one held is refused, no
420
+ * cache swap and no persist. Installing one would roll this device's key schedule
421
+ * back and have it encrypt at an epoch its co-members have left, producing traffic
422
+ * nobody can open with nothing raised. Reachable because a journalled commit is
423
+ * adopted at-least-once, carrying a handle frozen at the epoch it was built.
424
+ *
425
+ * The bound is STRICT — a same-epoch re-install must still win, because decrypting
426
+ * an application message advances the secret-tree generation within one epoch.
166
427
  */ replaceHandle(groupID, handle, options) {
167
428
  return this.#withLockedHandle(groupID, options, async (entry, stores)=>{
429
+ const held = entry.handle;
430
+ if (held != null && handle.epoch < held.epoch) {
431
+ this.#logger.warn('replaceHandle: refused a handle behind the one held', {
432
+ groupID,
433
+ deviceID: this.#deviceID,
434
+ heldEpoch: Number(held.epoch),
435
+ offeredEpoch: Number(handle.epoch)
436
+ });
437
+ return {
438
+ result: undefined,
439
+ persist: false
440
+ };
441
+ }
168
442
  entry.handle = handle;
169
- await this.#persist(stores, groupID, handle);
170
443
  // Eager swap: if the surrounding transaction rolls back, the DB row
171
444
  // reverts while the cache holds the swapped handle, so drop it and
172
445
  // re-restore the reverted row on next access.
173
446
  stores.onRollback(()=>this.invalidate(groupID));
447
+ return {
448
+ result: undefined,
449
+ persist: true
450
+ };
174
451
  });
175
452
  }
176
453
  /**
@@ -187,9 +464,9 @@ import { fromMLSStateRow, serializeMLSGroupState, toMLSStateInsert } from './mls
187
464
  * `createGroup` and `joinGroup` only seed for fresh groups not yet visible
188
465
  * to other callers, so this constraint is naturally satisfied in practice.
189
466
  */ async seed(params) {
190
- const { groupID, handle, stores } = params;
467
+ const { groupID, handle, stores, replace } = params;
191
468
  const existing = this.#entries.get(groupID);
192
- if (existing?.handle != null) {
469
+ if (existing?.handle != null && replace !== true) {
193
470
  throw new Error(`GroupHandleRegistry.seed: handle already cached for group ${groupID}`);
194
471
  }
195
472
  const serialized = serializeMLSGroupState(handle);
@@ -197,9 +474,14 @@ import { fromMLSStateRow, serializeMLSGroupState, toMLSStateInsert } from './mls
197
474
  await p2pStore.saveMLSState(toMLSStateInsert(serialized, groupID, this.#deviceID));
198
475
  stores.onCommit(()=>{
199
476
  const entry = this.#getOrCreateEntry(groupID);
200
- if (entry.handle == null) {
477
+ // A rejoin installs its handle over the stale one; anything else keeps
478
+ // whatever is already there. Assigning unconditionally would let a second
479
+ // Welcome delivery roll a live member back, which is what the guard above
480
+ // refuses on the other path.
481
+ if (entry.handle == null || replace === true) {
201
482
  entry.handle = handle;
202
483
  }
484
+ this.#publishEpoch(groupID, entry.handle);
203
485
  });
204
486
  }
205
487
  #getOrCreateEntry(groupID) {
@@ -213,7 +495,7 @@ import { fromMLSStateRow, serializeMLSGroupState, toMLSStateInsert } from './mls
213
495
  }
214
496
  return entry;
215
497
  }
216
- async #restore(stores, groupID) {
498
+ async #restore(stores, groupID, options) {
217
499
  const p2pStore = await getP2PStore(stores);
218
500
  const row = await p2pStore.getMLSState(groupID, this.#deviceID);
219
501
  if (row == null) {
@@ -228,7 +510,15 @@ import { fromMLSStateRow, serializeMLSGroupState, toMLSStateInsert } from './mls
228
510
  deviceID: this.#deviceID,
229
511
  epoch: row.epoch
230
512
  });
231
- return await restoreMLSGroupHandle(fromMLSStateRow(row));
513
+ return await restoreMLSGroupHandle({
514
+ serialized: fromMLSStateRow(row),
515
+ ...options?.resolveLedgerEntries != null ? {
516
+ resolveLedgerEntries: options.resolveLedgerEntries
517
+ } : {},
518
+ ...options?.onLedgerEntries != null ? {
519
+ onLedgerEntries: options.onLedgerEntries
520
+ } : {}
521
+ });
232
522
  }
233
523
  async #persist(stores, groupID, handle) {
234
524
  const serialized = serializeMLSGroupState(handle);
@@ -1,14 +1,15 @@
1
1
  import type { ProcedureHandlers } from '@enkaku/server';
2
2
  import type { ProcessBroadcastParams } from './broadcast.js';
3
- import type { ControlProtocol, SyncProtocol } from './group-protocols.js';
3
+ import type { ControlProtocol, PeerProtocol, SyncProtocol } from './group-protocols.js';
4
4
  /**
5
- * The two app-lane handler maps a per-group peer wires into `createGroupPeer`.
6
- * Keyed to {@link groupProtocols} so the peer dispatches each decoded procedure
7
- * payload to the matching handler.
5
+ * The app-lane handler maps a per-group peer wires into `createGroupPeer`. Keyed
6
+ * to {@link groupProtocols} so the peer dispatches each decoded procedure payload
7
+ * to the matching handler.
8
8
  */
9
9
  export type GroupHandlers = {
10
10
  control: ProcedureHandlers<ControlProtocol>;
11
11
  sync: ProcedureHandlers<SyncProtocol>;
12
+ peer: ProcedureHandlers<PeerProtocol>;
12
13
  };
13
14
  /**
14
15
  * Build the control + sync procedure handlers for a single group.