@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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +55 -0
- package/lib/groups/admin-roster.js +69 -0
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +155 -1
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -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 +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -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 +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +704 -22
- package/lib/types.js +1 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.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 -89
- 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 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- 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,533 @@
|
|
|
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
|
+
* 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;
|