@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
@@ -1 +1,533 @@
1
- import{getKubunLogger as e}from"@kubun/logger";import{getP2PStore as t}from"@kubun/store-p2p";import{createMutex as r}from"../util/mutex.js";import{restoreMLSGroupHandle as s}from"./mls-group-handle.js";import{fromMLSStateRow as i,serializeMLSGroupState as a,toMLSStateInsert as n}from"./mls-state.js";export class GroupHandleRegistry{#e;#t;#r;#s=new Map;constructor(t){this.#e=t.stores,this.#t=t.deviceID,this.#r=t.logger??e("plugin-p2p:group-handle-registry")}withHandle(e,t,r){return this.#i(e,r,async(r,s)=>{let i=await t(r.handle);return await this.#a(s,e,r.handle),i})}withHandleReplacing(e,t,r){return this.#i(e,r,async(r,s)=>{let{result:i,updated:a}=await t(r.handle);return r.handle=a,await this.#a(s,e,a),i})}#i(e,t,r){let s=t?.stores??this.#e,i=this.#n(e);return i.mutex.run(async()=>(null==i.handle&&(i.handle=await this.#l(s,e)),r(i,s)))}invalidate(e){this.#s.delete(e)}replaceHandle(e,t,r){return this.#i(e,r,async(r,s)=>{r.handle=t,await this.#a(s,e,t)})}async seed(e){let{groupID:r,handle:s,stores:i}=e,l=this.#s.get(r);if(l?.handle!=null)throw Error(`GroupHandleRegistry.seed: handle already cached for group ${r}`);let o=a(s),h=await t(i);await h.saveMLSState(n(o,r,this.#t)),i.onCommit(()=>{let e=this.#n(r);null==e.handle&&(e.handle=s)})}#n(e){let t=this.#s.get(e);return null==t&&(t={handle:null,mutex:r()},this.#s.set(e,t)),t}async #l(e,r){let a=await t(e),n=await a.getMLSState(r,this.#t);if(null==n)throw this.#r.debug("restore: no MLS state",{groupID:r,deviceID:this.#t}),Error(`No MLS state for group ${r}`);return this.#r.debug("restore: MLS state",{groupID:r,deviceID:this.#t,epoch:n.epoch}),await s(i(n))}async #a(e,r,s){let i=a(s),l=await t(e);await l.saveMLSState(n(i,r,this.#t)),this.#r.debug("persist: MLS state",{groupID:r,deviceID:this.#t,epoch:i.epoch})}}
1
+ import { getKubunLogger } from '@kubun/logger';
2
+ import { getP2PStore } from '@kubun/store-p2p';
3
+ import { createMutex } from '../util/mutex.js';
4
+ import { restoreMLSGroupHandle } from './mls-group-handle.js';
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;
14
+ /**
15
+ * Single canonical access point for a device's MLS `GroupHandle` instances.
16
+ *
17
+ * Owns the per-group async mutex covering encrypt/decrypt and epoch-bumping
18
+ * operations, and persists the (possibly mutated) handle on every successful
19
+ * `withHandle` callback. On callback throw, persist is skipped; the in-memory
20
+ * handle retains its mutated state so the caller can decide whether to retry.
21
+ *
22
+ * All five mutation paths (manager member ops, context handlers, broadcast
23
+ * service encrypt/decrypt, hub-tunnel sync listener, hub-tunnel sync provider)
24
+ * route MLS state through this registry.
25
+ */ export class GroupHandleRegistry {
26
+ #stores;
27
+ #deviceID;
28
+ #logger;
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();
51
+ constructor(params){
52
+ this.#stores = params.stores;
53
+ this.#deviceID = params.deviceID;
54
+ this.#logger = params.logger ?? getKubunLogger('plugin-p2p:group-handle-registry');
55
+ this.#mutexTimeoutMs = params.mutexTimeoutMs ?? MUTEX_ACQUIRE_TIMEOUT_MS;
56
+ }
57
+ /**
58
+ * Acquire the canonical handle for `groupID`, run `fn` under the per-group
59
+ * mutex, persist the (possibly mutated) handle on success, release.
60
+ * On throw, persist is skipped; in-memory handle retains its mutated state.
61
+ *
62
+ * Use this for in-place mutating ops (encrypt/decrypt) and reads. For
63
+ * epoch-bumping ops that produce a fresh `GroupHandle` (commitInvite,
64
+ * removeMember, processCommit), use `withHandleReplacing` instead.
65
+ *
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.
75
+ */ withHandle(groupID, fn, options) {
76
+ return this.#withLockedHandle(groupID, options, async (entry, stores)=>{
77
+ const result = await fn(entry.handle);
78
+ // `fn` may have mutated the handle in place before the persist; if the
79
+ // surrounding transaction rolls back, the DB row reverts but the cached
80
+ // handle stays advanced, so drop it and re-restore on next access.
81
+ stores.onRollback(()=>this.invalidate(groupID));
82
+ return {
83
+ result,
84
+ persist: true
85
+ };
86
+ });
87
+ }
88
+ /**
89
+ * Acquire the canonical handle for `groupID`, run `fn` under the per-group
90
+ * mutex, return the result, release — WITHOUT persisting MLS state.
91
+ *
92
+ * Read-only access: takes the lock and lazy-restores the cached handle if
93
+ * absent, but never writes MLS state. Use this for pure reads that do not
94
+ * mutate the handle (anchor reads, `GroupInfo` export, credential reads). For
95
+ * in-place-mutating ops that advance the MLS secret tree (encrypt/decrypt),
96
+ * use `withHandle`, which persists the advanced state.
97
+ */ readHandle(groupID, fn, options) {
98
+ return this.#withLockedHandle(groupID, options, async (entry)=>{
99
+ return {
100
+ result: await fn(entry.handle),
101
+ persist: false
102
+ };
103
+ });
104
+ }
105
+ /**
106
+ * Like `withHandle`, but the callback returns `{ result, updated }`.
107
+ * Registry replaces its cached handle with `updated` before persisting on
108
+ * success. Required for `@kumiai/mls` epoch ops (`commitInvite`,
109
+ * `removeMember`, `processCommit`) which construct a fresh `GroupHandle`
110
+ * rather than mutating the input.
111
+ *
112
+ * `options.stores` follows the same semantics as `withHandle.options.stores`.
113
+ */ withHandleReplacing(groupID, fn, options) {
114
+ return this.#withLockedHandle(groupID, options, async (entry, stores)=>{
115
+ const { result, updated } = await fn(entry.handle);
116
+ entry.handle = updated;
117
+ // Eager swap: the cache now holds the advanced handle but the persist is
118
+ // uncommitted. If the surrounding transaction rolls back, the DB row
119
+ // reverts while the cache stays advanced, so drop it and re-restore the
120
+ // reverted row on next access.
121
+ stores.onRollback(()=>this.invalidate(groupID));
122
+ return {
123
+ result,
124
+ persist: true
125
+ };
126
+ });
127
+ }
128
+ /**
129
+ * Like `withHandleReplacing`, but the per-group mutex is held across the
130
+ * ENTIRE transaction — it is acquired once, then a transaction is opened, run,
131
+ * and committed under that single lock, which releases only AFTER commit (or
132
+ * rollback). The cached `entry.handle` is NEVER touched by this method; the
133
+ * cache swap is the `onCommit` invalidation.
134
+ *
135
+ * Use this when the MLS advance must commit or roll back together with other
136
+ * rows written in the same transaction (e.g. receive-side commit processing
137
+ * that advances the ratchet AND writes the roster delta). `fn` receives both
138
+ * the working handle and the transactional `StoreProvider` so it can write
139
+ * those other rows on the same connection.
140
+ *
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) {
156
+ const entry = this.#getOrCreateEntry(groupID);
157
+ return entry.mutex.run(async ()=>{
158
+ return await stores.withTransaction(async (tx)=>{
159
+ const workingHandle = await this.#restore(tx, groupID, options);
160
+ const { result, updated } = await fn(workingHandle, tx);
161
+ await this.#persist(tx, groupID, updated);
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
+ });
173
+ return result;
174
+ });
175
+ }, this.#acquireOptions(groupID));
176
+ }
177
+ /**
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) {
192
+ const stores = options?.stores ?? this.#stores;
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;
246
+ }
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
+ }
365
+ }
366
+ /**
367
+ * Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
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.
375
+ */ invalidate(groupID) {
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;
413
+ }
414
+ /**
415
+ * Atomically swap+persist the cached handle under the per-group mutex, so a
416
+ * concurrent decrypt runs strictly before (stale handle, fails harmlessly) or
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.
427
+ */ replaceHandle(groupID, handle, options) {
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
+ }
442
+ entry.handle = handle;
443
+ // Eager swap: if the surrounding transaction rolls back, the DB row
444
+ // reverts while the cache holds the swapped handle, so drop it and
445
+ // re-restore the reverted row on next access.
446
+ stores.onRollback(()=>this.invalidate(groupID));
447
+ return {
448
+ result: undefined,
449
+ persist: true
450
+ };
451
+ });
452
+ }
453
+ /**
454
+ * Prime the cache with a freshly-built handle and persist the initial row
455
+ * via the supplied transactional `StoreProvider`. Called by `createGroup`
456
+ * and `joinGroup` so the row write rolls back with the rest of the
457
+ * create/join transaction on `withTransaction` abort. The cache install is
458
+ * deferred to `stores.onCommit` so a rollback leaves the cache empty.
459
+ *
460
+ * Must NOT be called concurrently with `withHandle` / `withHandleReplacing`
461
+ * on the same `groupID`: the `seed` row write is uncommitted until the
462
+ * surrounding transaction completes, so a concurrent `withHandle` whose
463
+ * mutex region runs before commit would `#restore` from a missing row.
464
+ * `createGroup` and `joinGroup` only seed for fresh groups not yet visible
465
+ * to other callers, so this constraint is naturally satisfied in practice.
466
+ */ async seed(params) {
467
+ const { groupID, handle, stores, replace } = params;
468
+ const existing = this.#entries.get(groupID);
469
+ if (existing?.handle != null && replace !== true) {
470
+ throw new Error(`GroupHandleRegistry.seed: handle already cached for group ${groupID}`);
471
+ }
472
+ const serialized = serializeMLSGroupState(handle);
473
+ const p2pStore = await getP2PStore(stores);
474
+ await p2pStore.saveMLSState(toMLSStateInsert(serialized, groupID, this.#deviceID));
475
+ stores.onCommit(()=>{
476
+ const entry = this.#getOrCreateEntry(groupID);
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) {
482
+ entry.handle = handle;
483
+ }
484
+ this.#publishEpoch(groupID, entry.handle);
485
+ });
486
+ }
487
+ #getOrCreateEntry(groupID) {
488
+ let entry = this.#entries.get(groupID);
489
+ if (entry == null) {
490
+ entry = {
491
+ handle: null,
492
+ mutex: createMutex()
493
+ };
494
+ this.#entries.set(groupID, entry);
495
+ }
496
+ return entry;
497
+ }
498
+ async #restore(stores, groupID, options) {
499
+ const p2pStore = await getP2PStore(stores);
500
+ const row = await p2pStore.getMLSState(groupID, this.#deviceID);
501
+ if (row == null) {
502
+ this.#logger.debug('restore: no MLS state', {
503
+ groupID,
504
+ deviceID: this.#deviceID
505
+ });
506
+ throw new Error(`No MLS state for group ${groupID}`);
507
+ }
508
+ this.#logger.debug('restore: MLS state', {
509
+ groupID,
510
+ deviceID: this.#deviceID,
511
+ epoch: row.epoch
512
+ });
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
+ });
522
+ }
523
+ async #persist(stores, groupID, handle) {
524
+ const serialized = serializeMLSGroupState(handle);
525
+ const p2pStore = await getP2PStore(stores);
526
+ await p2pStore.saveMLSState(toMLSStateInsert(serialized, groupID, this.#deviceID));
527
+ this.#logger.debug('persist: MLS state', {
528
+ groupID,
529
+ deviceID: this.#deviceID,
530
+ epoch: serialized.epoch
531
+ });
532
+ }
533
+ }
@@ -0,0 +1,30 @@
1
+ import type { ProcedureHandlers } from '@enkaku/server';
2
+ import type { ProcessBroadcastParams } from './broadcast.js';
3
+ import type { ControlProtocol, PeerProtocol, SyncProtocol } from './group-protocols.js';
4
+ /**
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
+ */
9
+ export type GroupHandlers = {
10
+ control: ProcedureHandlers<ControlProtocol>;
11
+ sync: ProcedureHandlers<SyncProtocol>;
12
+ peer: ProcedureHandlers<PeerProtocol>;
13
+ };
14
+ /**
15
+ * Build the control + sync procedure handlers for a single group.
16
+ *
17
+ * Each handler is a thin adapter: it maps the typed group-rpc procedure payload
18
+ * back to the {@link GroupBroadcastMessage} the pure {@link processBroadcast}
19
+ * apply logic already understands, re-adding the `groupID` the peer is bound to
20
+ * (group-rpc strips it from the wire because each topic already encodes the
21
+ * group). The apply logic owns verification, conflict resolution, and storage;
22
+ * the handler owns only the shape translation.
23
+ *
24
+ * Event handlers are fire-and-forget. `processBroadcast` already drops an
25
+ * unverifiable or malformed payload and returns `{ applied: false }` rather than
26
+ * throwing, so the result is intentionally ignored. A programmer-error rethrow
27
+ * (or an unexpected store fault) is caught and logged here so a single bad frame
28
+ * never escapes as an unhandled rejection out of the receive loop.
29
+ */
30
+ export declare function buildGroupHandlers(params: ProcessBroadcastParams, groupID: string): GroupHandlers;