@kubun/plugin-p2p 0.10.0 → 0.11.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 (136) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +12 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +122 -38
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +7 -2
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +69 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +29 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +4 -8
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,243 @@
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 l}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.#l(e);return i.mutex.run(async()=>(null==i.handle&&(i.handle=await this.#n(s,e)),r(i,s)))}invalidate(e){this.#s.delete(e)}async seed(e){let{groupID:r,handle:s,stores:i}=e,n=this.#s.get(r);if(n?.handle!=null)throw Error(`GroupHandleRegistry.seed: handle already cached for group ${r}`);let o=a(s),h=await t(i);await h.saveMLSState(l(o,r,this.#t)),i.onCommit(()=>{let e=this.#l(r);null==e.handle&&(e.handle=s)})}#l(e){let t=this.#s.get(e);return null==t&&(t={handle:null,mutex:r()},this.#s.set(e,t)),t}async #n(e,r){let a=await t(e),l=await a.getMLSState(r,this.#t);if(null==l)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:l.epoch}),await s(i(l))}async #a(e,r,s){let i=a(s),n=await t(e);await n.saveMLSState(l(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
+ * Single canonical access point for a device's MLS `GroupHandle` instances.
8
+ *
9
+ * Owns the per-group async mutex covering encrypt/decrypt and epoch-bumping
10
+ * operations, and persists the (possibly mutated) handle on every successful
11
+ * `withHandle` callback. On callback throw, persist is skipped; the in-memory
12
+ * handle retains its mutated state so the caller can decide whether to retry.
13
+ *
14
+ * All five mutation paths (manager member ops, context handlers, broadcast
15
+ * 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`.
18
+ */ export class GroupHandleRegistry {
19
+ #stores;
20
+ #deviceID;
21
+ #logger;
22
+ #entries = new Map();
23
+ constructor(params){
24
+ this.#stores = params.stores;
25
+ this.#deviceID = params.deviceID;
26
+ this.#logger = params.logger ?? getKubunLogger('plugin-p2p:group-handle-registry');
27
+ }
28
+ /**
29
+ * Acquire the canonical handle for `groupID`, run `fn` under the per-group
30
+ * mutex, persist the (possibly mutated) handle on success, release.
31
+ * On throw, persist is skipped; in-memory handle retains its mutated state.
32
+ *
33
+ * Use this for in-place mutating ops (encrypt/decrypt) and reads. For
34
+ * epoch-bumping ops that produce a fresh `GroupHandle` (commitInvite,
35
+ * removeMember, processCommit), use `withHandleReplacing` instead.
36
+ *
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.
53
+ */ withHandle(groupID, fn, options) {
54
+ return this.#withLockedHandle(groupID, options, async (entry, stores)=>{
55
+ const result = await fn(entry.handle);
56
+ await this.#persist(stores, groupID, entry.handle);
57
+ // `fn` may have mutated the handle in place before the persist; if the
58
+ // surrounding transaction rolls back, the DB row reverts but the cached
59
+ // handle stays advanced, so drop it and re-restore on next access.
60
+ stores.onRollback(()=>this.invalidate(groupID));
61
+ return result;
62
+ });
63
+ }
64
+ /**
65
+ * Acquire the canonical handle for `groupID`, run `fn` under the per-group
66
+ * mutex, return the result, release — WITHOUT persisting MLS state.
67
+ *
68
+ * Read-only access: takes the lock and lazy-restores the cached handle if
69
+ * absent, but never writes MLS state. Use this for pure reads that do not
70
+ * mutate the handle (anchor reads, `GroupInfo` export, credential reads). For
71
+ * in-place-mutating ops that advance the MLS secret tree (encrypt/decrypt),
72
+ * use `withHandle`, which persists the advanced state.
73
+ */ readHandle(groupID, fn, options) {
74
+ return this.#withLockedHandle(groupID, options, async (entry)=>{
75
+ return await fn(entry.handle);
76
+ });
77
+ }
78
+ /**
79
+ * Like `withHandle`, but the callback returns `{ result, updated }`.
80
+ * Registry replaces its cached handle with `updated` before persisting on
81
+ * success. Required for `@kumiai/mls` epoch ops (`commitInvite`,
82
+ * `removeMember`, `processCommit`) which construct a fresh `GroupHandle`
83
+ * rather than mutating the input.
84
+ *
85
+ * `options.stores` follows the same semantics as `withHandle.options.stores`.
86
+ */ withHandleReplacing(groupID, fn, options) {
87
+ return this.#withLockedHandle(groupID, options, async (entry, stores)=>{
88
+ const { result, updated } = await fn(entry.handle);
89
+ entry.handle = updated;
90
+ await this.#persist(stores, groupID, updated);
91
+ // Eager swap: the cache now holds the advanced handle but the persist is
92
+ // uncommitted. If the surrounding transaction rolls back, the DB row
93
+ // reverts while the cache stays advanced, so drop it and re-restore the
94
+ // reverted row on next access.
95
+ stores.onRollback(()=>this.invalidate(groupID));
96
+ return result;
97
+ });
98
+ }
99
+ /**
100
+ * Like `withHandleReplacing`, but the per-group mutex is held across the
101
+ * ENTIRE transaction — it is acquired once, then a transaction is opened, run,
102
+ * and committed under that single lock, which releases only AFTER commit (or
103
+ * rollback). The cached `entry.handle` is NEVER touched by this method; the
104
+ * cache swap is the `onCommit` invalidation.
105
+ *
106
+ * Use this when the MLS advance must commit or roll back together with other
107
+ * rows written in the same transaction (e.g. receive-side commit processing
108
+ * that advances the ratchet AND writes the roster delta). `fn` receives both
109
+ * the working handle and the transactional `StoreProvider` so it can write
110
+ * those other rows on the same connection.
111
+ *
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) {
130
+ const entry = this.#getOrCreateEntry(groupID);
131
+ return entry.mutex.run(async ()=>{
132
+ return await stores.withTransaction(async (tx)=>{
133
+ const workingHandle = await this.#restore(tx, groupID);
134
+ const { result, updated } = await fn(workingHandle, tx);
135
+ await this.#persist(tx, groupID, updated);
136
+ tx.onCommit(()=>this.invalidate(groupID));
137
+ return result;
138
+ });
139
+ });
140
+ }
141
+ /**
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) {
147
+ 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);
152
+ }
153
+ return fn(entry, stores);
154
+ });
155
+ }
156
+ /**
157
+ * Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
158
+ * Called by `leaveGroup` after the MLS state row is deleted, and by tests.
159
+ */ invalidate(groupID) {
160
+ this.#entries.delete(groupID);
161
+ }
162
+ /**
163
+ * Atomically swap+persist the cached handle under the per-group mutex, so a
164
+ * concurrent decrypt runs strictly before (stale handle, fails harmlessly) or
165
+ * after (new handle), never interleaved.
166
+ */ replaceHandle(groupID, handle, options) {
167
+ return this.#withLockedHandle(groupID, options, async (entry, stores)=>{
168
+ entry.handle = handle;
169
+ await this.#persist(stores, groupID, handle);
170
+ // Eager swap: if the surrounding transaction rolls back, the DB row
171
+ // reverts while the cache holds the swapped handle, so drop it and
172
+ // re-restore the reverted row on next access.
173
+ stores.onRollback(()=>this.invalidate(groupID));
174
+ });
175
+ }
176
+ /**
177
+ * Prime the cache with a freshly-built handle and persist the initial row
178
+ * via the supplied transactional `StoreProvider`. Called by `createGroup`
179
+ * and `joinGroup` so the row write rolls back with the rest of the
180
+ * create/join transaction on `withTransaction` abort. The cache install is
181
+ * deferred to `stores.onCommit` so a rollback leaves the cache empty.
182
+ *
183
+ * Must NOT be called concurrently with `withHandle` / `withHandleReplacing`
184
+ * on the same `groupID`: the `seed` row write is uncommitted until the
185
+ * surrounding transaction completes, so a concurrent `withHandle` whose
186
+ * mutex region runs before commit would `#restore` from a missing row.
187
+ * `createGroup` and `joinGroup` only seed for fresh groups not yet visible
188
+ * to other callers, so this constraint is naturally satisfied in practice.
189
+ */ async seed(params) {
190
+ const { groupID, handle, stores } = params;
191
+ const existing = this.#entries.get(groupID);
192
+ if (existing?.handle != null) {
193
+ throw new Error(`GroupHandleRegistry.seed: handle already cached for group ${groupID}`);
194
+ }
195
+ const serialized = serializeMLSGroupState(handle);
196
+ const p2pStore = await getP2PStore(stores);
197
+ await p2pStore.saveMLSState(toMLSStateInsert(serialized, groupID, this.#deviceID));
198
+ stores.onCommit(()=>{
199
+ const entry = this.#getOrCreateEntry(groupID);
200
+ if (entry.handle == null) {
201
+ entry.handle = handle;
202
+ }
203
+ });
204
+ }
205
+ #getOrCreateEntry(groupID) {
206
+ let entry = this.#entries.get(groupID);
207
+ if (entry == null) {
208
+ entry = {
209
+ handle: null,
210
+ mutex: createMutex()
211
+ };
212
+ this.#entries.set(groupID, entry);
213
+ }
214
+ return entry;
215
+ }
216
+ async #restore(stores, groupID) {
217
+ const p2pStore = await getP2PStore(stores);
218
+ const row = await p2pStore.getMLSState(groupID, this.#deviceID);
219
+ if (row == null) {
220
+ this.#logger.debug('restore: no MLS state', {
221
+ groupID,
222
+ deviceID: this.#deviceID
223
+ });
224
+ throw new Error(`No MLS state for group ${groupID}`);
225
+ }
226
+ this.#logger.debug('restore: MLS state', {
227
+ groupID,
228
+ deviceID: this.#deviceID,
229
+ epoch: row.epoch
230
+ });
231
+ return await restoreMLSGroupHandle(fromMLSStateRow(row));
232
+ }
233
+ async #persist(stores, groupID, handle) {
234
+ const serialized = serializeMLSGroupState(handle);
235
+ const p2pStore = await getP2PStore(stores);
236
+ await p2pStore.saveMLSState(toMLSStateInsert(serialized, groupID, this.#deviceID));
237
+ this.#logger.debug('persist: MLS state', {
238
+ groupID,
239
+ deviceID: this.#deviceID,
240
+ epoch: serialized.epoch
241
+ });
242
+ }
243
+ }
@@ -0,0 +1,29 @@
1
+ import type { ProcedureHandlers } from '@enkaku/server';
2
+ import type { ProcessBroadcastParams } from './broadcast.js';
3
+ import type { ControlProtocol, SyncProtocol } from './group-protocols.js';
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.
8
+ */
9
+ export type GroupHandlers = {
10
+ control: ProcedureHandlers<ControlProtocol>;
11
+ sync: ProcedureHandlers<SyncProtocol>;
12
+ };
13
+ /**
14
+ * Build the control + sync procedure handlers for a single group.
15
+ *
16
+ * Each handler is a thin adapter: it maps the typed group-rpc procedure payload
17
+ * back to the {@link GroupBroadcastMessage} the pure {@link processBroadcast}
18
+ * apply logic already understands, re-adding the `groupID` the peer is bound to
19
+ * (group-rpc strips it from the wire because each topic already encodes the
20
+ * group). The apply logic owns verification, conflict resolution, and storage;
21
+ * the handler owns only the shape translation.
22
+ *
23
+ * Event handlers are fire-and-forget. `processBroadcast` already drops an
24
+ * unverifiable or malformed payload and returns `{ applied: false }` rather than
25
+ * throwing, so the result is intentionally ignored. A programmer-error rethrow
26
+ * (or an unexpected store fault) is caught and logged here so a single bad frame
27
+ * never escapes as an unhandled rejection out of the receive loop.
28
+ */
29
+ export declare function buildGroupHandlers(params: ProcessBroadcastParams, groupID: string): GroupHandlers;
@@ -0,0 +1,92 @@
1
+ import { isSignedToken } from '@kokuin/token';
2
+ import { processBroadcast } from './broadcast.js';
3
+ import { emitLedgerAffectedEvents } from './ledger-affected-events.js';
4
+ /**
5
+ * Build the control + sync procedure handlers for a single group.
6
+ *
7
+ * Each handler is a thin adapter: it maps the typed group-rpc procedure payload
8
+ * back to the {@link GroupBroadcastMessage} the pure {@link processBroadcast}
9
+ * apply logic already understands, re-adding the `groupID` the peer is bound to
10
+ * (group-rpc strips it from the wire because each topic already encodes the
11
+ * group). The apply logic owns verification, conflict resolution, and storage;
12
+ * the handler owns only the shape translation.
13
+ *
14
+ * Event handlers are fire-and-forget. `processBroadcast` already drops an
15
+ * unverifiable or malformed payload and returns `{ applied: false }` rather than
16
+ * throwing, so the result is intentionally ignored. A programmer-error rethrow
17
+ * (or an unexpected store fault) is caught and logged here so a single bad frame
18
+ * never escapes as an unhandled rejection out of the receive loop.
19
+ */ export function buildGroupHandlers(params, groupID) {
20
+ const safeApply = async (apply)=>{
21
+ try {
22
+ await apply();
23
+ } catch (error) {
24
+ params.logger?.warn('group handler apply failed', {
25
+ groupID,
26
+ error
27
+ });
28
+ }
29
+ };
30
+ return {
31
+ control: {
32
+ // Event handlers return the (never-rejecting) apply promise. The procedure
33
+ // contract types them `=> void`, so the server ignores the return; returning
34
+ // the promise is purely so a caller (e.g. a test) that holds the handler
35
+ // directly can await the apply completing.
36
+ 'control/ledgerEntry': ({ data })=>safeApply(async ()=>{
37
+ // A remote-applied ledger entry returns its circle/group projection row
38
+ // as `affected` rather than emitting; surface the matching local events
39
+ // so this receiver's GraphQL subscribers see the change live.
40
+ const result = await processBroadcast(params, {
41
+ type: 'ledger:entry',
42
+ groupID,
43
+ token: data.token
44
+ }, groupID);
45
+ if (params.emitter != null) {
46
+ await emitLedgerAffectedEvents(params.emitter, result);
47
+ }
48
+ }),
49
+ 'control/delegationShare': ({ data })=>safeApply(()=>processBroadcast(params, {
50
+ type: 'delegation:share',
51
+ token: data.token,
52
+ groupID,
53
+ hlc: data.hlc
54
+ }, groupID)),
55
+ 'control/delegationRevoke': ({ data })=>safeApply(()=>processBroadcast(params, {
56
+ type: 'delegation:revoke',
57
+ token: data.token,
58
+ groupID,
59
+ hlc: data.hlc
60
+ }, groupID)),
61
+ 'control/groupLeaveRequest': ({ data })=>safeApply(()=>processBroadcast(params, {
62
+ type: 'group:leaveRequest',
63
+ groupID,
64
+ memberDID: data.memberDID,
65
+ hlc: data.hlc
66
+ }, groupID)),
67
+ 'control/ledgerCatchup': async ()=>{
68
+ // Untrusted responder: hand back every stored signed entry token. The
69
+ // requester re-verifies each one independently, so authority lives in the
70
+ // token, not in this reply.
71
+ const rows = await params.p2pStore.getLedgerEntries(groupID);
72
+ return {
73
+ entries: rows.map((row)=>row.signed_token)
74
+ };
75
+ }
76
+ },
77
+ sync: {
78
+ 'sync/mutationApply': ({ data, message })=>{
79
+ // The authenticated transport sender is the signed frame's issuer. It is
80
+ // only a belt-and-suspenders loopback guard — the hub already fans a
81
+ // publish to subscribers minus the sender, so a peer never receives its
82
+ // own broadcast. An unsigned frame yields '' (harmless: no self-match).
83
+ const senderPeerDID = isSignedToken(message) ? message.payload.iss : '';
84
+ return safeApply(()=>processBroadcast(params, {
85
+ type: 'mutation:apply',
86
+ entries: data.entries,
87
+ senderPeerDID
88
+ }, groupID));
89
+ }
90
+ }
91
+ };
92
+ }
@@ -0,0 +1,56 @@
1
+ import { type Logger } from '@kubun/logger';
2
+ import type { GroupHealthCondition, GroupHealthSnapshot, GroupHealthState } from '../types.js';
3
+ import type { P2PEventEmitter } from './events.js';
4
+ export type GroupHealthMonitorParams = {
5
+ emitter: P2PEventEmitter;
6
+ /** Tuning for the `epoch-stale` condition. Defaults to a threshold of 3. */
7
+ epochStale?: {
8
+ threshold?: number;
9
+ };
10
+ /** Clock for `lastTransitionAt` stamps. Defaults to `Date.now`. */
11
+ now?: () => number;
12
+ /** Logger for a throwing `groupHealthChanged` subscriber. Defaults to the
13
+ * `group-health-monitor` namespace. */
14
+ logger?: Logger;
15
+ };
16
+ /**
17
+ * INERT: the mutating producers (`signal`/`reset`/`mark*`) have no live caller,
18
+ * so `getHealth` always reports `healthy` and `groupHealthChanged` never fires.
19
+ * This is the front-end of the epoch-stale-recovery feature, deliberately
20
+ * dormant until tier-2 Welcome re-admission exists — see
21
+ * docs/agents/plans/backlog/welcome-based-deep-recovery.md. Kept (not deleted)
22
+ * because that feature is roadmapped and this scaffolding is design-aligned.
23
+ *
24
+ * Per-group health state machine. Counts/latches failure signals per
25
+ * `(groupID, condition)` and emits `groupHealthChanged` on transitions.
26
+ * Crypto- and persistence-free: callers classify failures before signalling.
27
+ *
28
+ * State is in-memory per-process — a restart re-derives it from the next signal.
29
+ * Transitions are synchronous (no `await`), so calls cannot interleave; signals
30
+ * arriving while a rejoin is in flight (`recovering`) are ignored — the recovery
31
+ * owns the group until it resolves.
32
+ */
33
+ export declare class GroupHealthMonitor {
34
+ #private;
35
+ constructor(params: GroupHealthMonitorParams);
36
+ /**
37
+ * Record a failure observation for a condition. Below the condition's
38
+ * threshold this only increments the counter; on reaching it the group
39
+ * transitions to `degraded` (emitted once, then latched). Ignored while the
40
+ * group is `recovering` (the rejoin owns it) or already `degraded`/failed.
41
+ */
42
+ signal(groupID: string, condition: GroupHealthCondition): void;
43
+ /**
44
+ * Clear a condition to healthy and re-arm (counter reset), driven by the
45
+ * success path. Silent: only recovery (via the mark* methods) is surfaced.
46
+ */
47
+ reset(groupID: string, condition: GroupHealthCondition): void;
48
+ markRecovering(groupID: string): void;
49
+ /** Emit the transient `recovered` event, then settle the phase to `healthy`. */
50
+ markRecovered(groupID: string): void;
51
+ /** Leave the group `recovery-failed` until a later success resets it. */
52
+ markRecoveryFailed(groupID: string): void;
53
+ /** Aggregate state: the first non-healthy condition phase, else `healthy`. */
54
+ getState(groupID: string): GroupHealthState;
55
+ getHealth(groupID: string): GroupHealthSnapshot;
56
+ }
@@ -0,0 +1,139 @@
1
+ import { getKubunLogger } from '@kubun/logger';
2
+ const DEFAULT_EPOCH_STALE_THRESHOLD = 3;
3
+ /**
4
+ * INERT: the mutating producers (`signal`/`reset`/`mark*`) have no live caller,
5
+ * so `getHealth` always reports `healthy` and `groupHealthChanged` never fires.
6
+ * This is the front-end of the epoch-stale-recovery feature, deliberately
7
+ * dormant until tier-2 Welcome re-admission exists — see
8
+ * docs/agents/plans/backlog/welcome-based-deep-recovery.md. Kept (not deleted)
9
+ * because that feature is roadmapped and this scaffolding is design-aligned.
10
+ *
11
+ * Per-group health state machine. Counts/latches failure signals per
12
+ * `(groupID, condition)` and emits `groupHealthChanged` on transitions.
13
+ * Crypto- and persistence-free: callers classify failures before signalling.
14
+ *
15
+ * State is in-memory per-process — a restart re-derives it from the next signal.
16
+ * Transitions are synchronous (no `await`), so calls cannot interleave; signals
17
+ * arriving while a rejoin is in flight (`recovering`) are ignored — the recovery
18
+ * owns the group until it resolves.
19
+ */ export class GroupHealthMonitor {
20
+ #emitter;
21
+ #epochStaleThreshold;
22
+ #now;
23
+ #logger;
24
+ // groupID -> condition -> per-condition state
25
+ #groups = new Map();
26
+ // groupID -> epoch ms of the last transition affecting its aggregate state
27
+ #lastTransitionAt = new Map();
28
+ constructor(params){
29
+ this.#emitter = params.emitter;
30
+ this.#epochStaleThreshold = params.epochStale?.threshold ?? DEFAULT_EPOCH_STALE_THRESHOLD;
31
+ this.#now = params.now ?? Date.now;
32
+ this.#logger = params.logger ?? getKubunLogger('group-health-monitor');
33
+ }
34
+ /**
35
+ * Record a failure observation for a condition. Below the condition's
36
+ * threshold this only increments the counter; on reaching it the group
37
+ * transitions to `degraded` (emitted once, then latched). Ignored while the
38
+ * group is `recovering` (the rejoin owns it) or already `degraded`/failed.
39
+ */ signal(groupID, condition) {
40
+ const state = this.#ensure(groupID, condition);
41
+ if (state.phase !== 'healthy') {
42
+ // non-healthy: further failures are expected, don't re-emit.
43
+ return;
44
+ }
45
+ state.count += 1;
46
+ if (state.count >= this.#thresholdFor(condition)) {
47
+ state.phase = 'degraded';
48
+ this.#stamp(groupID);
49
+ this.#emit(groupID, condition, 'degraded');
50
+ }
51
+ }
52
+ /**
53
+ * Clear a condition to healthy and re-arm (counter reset), driven by the
54
+ * success path. Silent: only recovery (via the mark* methods) is surfaced.
55
+ */ reset(groupID, condition) {
56
+ const state = this.#ensure(groupID, condition);
57
+ state.count = 0;
58
+ if (state.phase !== 'healthy') {
59
+ state.phase = 'healthy';
60
+ this.#stamp(groupID);
61
+ }
62
+ }
63
+ markRecovering(groupID) {
64
+ const state = this.#ensure(groupID, 'epoch-stale');
65
+ state.phase = 'recovering';
66
+ this.#stamp(groupID);
67
+ this.#emit(groupID, 'epoch-stale', 'recovering');
68
+ }
69
+ /** Emit the transient `recovered` event, then settle the phase to `healthy`. */ markRecovered(groupID) {
70
+ const state = this.#ensure(groupID, 'epoch-stale');
71
+ state.count = 0;
72
+ state.phase = 'healthy';
73
+ this.#stamp(groupID);
74
+ this.#emit(groupID, 'epoch-stale', 'recovered');
75
+ }
76
+ /** Leave the group `recovery-failed` until a later success resets it. */ markRecoveryFailed(groupID) {
77
+ const state = this.#ensure(groupID, 'epoch-stale');
78
+ state.phase = 'recovery-failed';
79
+ this.#stamp(groupID);
80
+ this.#emit(groupID, 'epoch-stale', 'recovery-failed');
81
+ }
82
+ /** Aggregate state: the first non-healthy condition phase, else `healthy`. */ getState(groupID) {
83
+ const conditions = this.#groups.get(groupID);
84
+ if (conditions == null) {
85
+ return 'healthy';
86
+ }
87
+ for (const state of conditions.values()){
88
+ if (state.phase !== 'healthy') {
89
+ return state.phase;
90
+ }
91
+ }
92
+ return 'healthy';
93
+ }
94
+ getHealth(groupID) {
95
+ return {
96
+ state: this.getState(groupID),
97
+ lastTransitionAt: this.#lastTransitionAt.get(groupID) ?? null
98
+ };
99
+ }
100
+ #stamp(groupID) {
101
+ this.#lastTransitionAt.set(groupID, this.#now());
102
+ }
103
+ #thresholdFor(condition) {
104
+ // Only `epoch-stale` exists today; switch when more conditions land.
105
+ return condition === 'epoch-stale' ? this.#epochStaleThreshold : Number.POSITIVE_INFINITY;
106
+ }
107
+ #ensure(groupID, condition) {
108
+ let conditions = this.#groups.get(groupID);
109
+ if (conditions == null) {
110
+ conditions = new Map();
111
+ this.#groups.set(groupID, conditions);
112
+ }
113
+ let state = conditions.get(condition);
114
+ if (state == null) {
115
+ state = {
116
+ phase: 'healthy',
117
+ count: 0
118
+ };
119
+ conditions.set(condition, state);
120
+ }
121
+ return state;
122
+ }
123
+ #emit(groupID, condition, state) {
124
+ // Enkaku's `emit` rethrows a throwing subscriber; catch so it can't leak an
125
+ // unhandled rejection.
126
+ this.#emitter.emit('groupHealthChanged', {
127
+ groupID,
128
+ condition,
129
+ state
130
+ }).catch((error)=>{
131
+ this.#logger.error('groupHealthChanged listener failed', {
132
+ groupID,
133
+ condition,
134
+ state,
135
+ error
136
+ });
137
+ });
138
+ }
139
+ }
@@ -0,0 +1,35 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import { HLC } from '@kubun/hlc';
3
+ import { type Logger } from '@kubun/logger';
4
+ import type { GroupMLS } from '@kumiai/rpc';
5
+ import type { GroupHandleRegistry } from './group-handle-registry.js';
6
+ export type CreateGroupMLSParams = {
7
+ /** Single canonical access point for the device's MLS `GroupHandle`. */
8
+ registry: GroupHandleRegistry;
9
+ /**
10
+ * Store coordinator used to open the receive-side commit transaction so the
11
+ * MLS ratchet advance and the roster delta writes commit (or roll back)
12
+ * together. Without one transaction, a roster-write failure after the ratchet
13
+ * persists would silently keep a removed member's writes applying.
14
+ */
15
+ stores: StoreProvider;
16
+ /**
17
+ * Device-wide monotonic clock for stamping the `removed_at_hlc` of members
18
+ * dropped (and the `hlc` of members added) by a received MLS Commit. Pass the
19
+ * engine's single instance so every write from this device advances one shared
20
+ * clock, preventing two same-millisecond writes from minting identical
21
+ * timestamps (the second would silently lose under last-writer-wins).
22
+ */
23
+ hlc: HLC;
24
+ groupID: string;
25
+ logger?: Logger;
26
+ };
27
+ /**
28
+ * Adapts a `GroupHandleRegistry` group into the `@kumiai/rpc`
29
+ * {@link GroupMLS} lifecycle port: apply received Commits to advance the epoch,
30
+ * export GroupInfo for a recovery responder, and expose the epoch-independent
31
+ * recovery seed. group-rpc owns the transport + orchestration (subscribe the
32
+ * non-rotating handshake topic, run these methods, resync the app lane when the
33
+ * epoch advances); this adapter owns the MLS state, storage, and atomicity.
34
+ */
35
+ export declare function createGroupMLS(params: CreateGroupMLSParams): GroupMLS;