@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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +12 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +122 -38
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +7 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +69 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +56 -0
- package/lib/groups/group-health-monitor.js +139 -0
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +29 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +4 -8
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +28 -41
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +29 -13
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +8 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +471 -2
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +73 -0
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +12 -0
- package/lib/sync/errors.js +19 -0
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.d.ts +3 -22
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +19 -0
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +8 -1
- package/lib/sync/peer-registry.js +107 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +5 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +41 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +21 -0
- package/package.json +48 -41
- package/lib/groups/broadcast-service.d.ts +0 -189
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -32
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -67
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -33
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -127
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
|
@@ -1 +1,243 @@
|
|
|
1
|
-
import{getKubunLogger
|
|
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;
|