@kubun/plugin-p2p 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -0,0 +1,42 @@
1
+ import type { GroupCrypto } from '@kumiai/rpc';
2
+ import { type Runtime } from '@sozai/runtime';
3
+ import type { GroupHandleRegistry } from './group-handle-registry.js';
4
+ /**
5
+ * Adapts a `GroupHandleRegistry` group into `@kumiai/rpc`'s {@link GroupCrypto}
6
+ * port: epoch, an epoch-bound topic-derivation secret, and byte-level
7
+ * encrypt/decrypt over the live MLS handle.
8
+ *
9
+ * `wrap`/`unwrap`/`exportSecret` route through `registry.readHandle`, taking the
10
+ * per-group mutex and observing the canonical handle, framed as in
11
+ * `mls-codec.ts`. `exportSecret` passes the caller's label through untouched:
12
+ * the labels belong to the package deriving topics from the result, so
13
+ * substituting one moves every topic ID away from what the caller addressed.
14
+ *
15
+ * `sealEntries`/`openEntries` are a SECOND seal, NOT interchangeable with
16
+ * `wrap`/`unwrap`: those consume a ratchet generation and mutate the handle, so
17
+ * they cannot serve an open running inside the apply of the commit carrying the
18
+ * blob. The bytes must match `@kumiai/mls-rpc` exactly — the seal is agreed
19
+ * without exchange, so a divergence is not a decode error but a group whose
20
+ * members silently stop reading each other's commits.
21
+ *
22
+ * The epoch is READ FROM THE REGISTRY, never cached: applying someone else's
23
+ * commit advances the handle without touching this file, and a stale number
24
+ * classifies every later frame as `ahead`, so the cursor skips it forever.
25
+ * `initialEpoch` covers only construction before the registry has observed a
26
+ * handle — the caller MUST read the handle's epoch first, or a returning peer
27
+ * answering `0` steps over every commit it missed. Construction is synchronous
28
+ * and eager, leaving no window to prime it afterwards.
29
+ */
30
+ export type GroupCryptoParams = {
31
+ registry: GroupHandleRegistry;
32
+ groupID: string;
33
+ /** The handle's CURRENT epoch — see the note above on why `0` is not a safe placeholder. */
34
+ initialEpoch: number;
35
+ /**
36
+ * Host runtime supplying the seal's randomness, so the source follows the
37
+ * platform the host is on. Pass the host's — the default constructs a SECOND
38
+ * runtime rather than propagating one.
39
+ */
40
+ runtime?: Runtime;
41
+ };
42
+ export declare function createGroupCrypto(params: GroupCryptoParams): GroupCrypto;
@@ -0,0 +1,159 @@
1
+ import { readMessageEpoch } from '@kumiai/mls';
2
+ import { ENTRY_SEAL_LABEL } from '@kumiai/mls-rpc';
3
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
4
+ import { toB64 } from '@sozai/codec';
5
+ import { createRuntime } from '@sozai/runtime';
6
+ import { mlsExporter } from 'ts-mls';
7
+ import { mlsDecryptFramed, mlsEncryptFramed } from './mls-codec.js';
8
+ // Empty context: the epoch already varies the secret; topic derivation mixes in
9
+ // its own epoch/protocol/scope on top. Byte-identical to the exporter context
10
+ // `@kumiai/mls-rpc` uses for the ledger-entry seal (`new Uint8Array()`), which is
11
+ // what lets a blob sealed here open there.
12
+ const TOPIC_SECRET_CONTEXT = new Uint8Array(0);
13
+ // Default when a caller names no length. 32 bytes feeds an HKDF-based topic
14
+ // derivation; the length is otherwise unconstrained. It is also XChaCha20-
15
+ // Poly1305's key size, and the same 32 the ledger-entry seal exports at — RFC
16
+ // 9420 §8.5 binds the length into the exporter's KDFLabel, so deriving this from
17
+ // the ciphersuite instead would return 48 on SHA-384 and break every seal.
18
+ const DEFAULT_SECRET_LENGTH = 32;
19
+ // XChaCha20-Poly1305's nonce, carried in the clear ahead of the ciphertext.
20
+ const ENTRY_NONCE_BYTES = 24;
21
+ // Sealed blob format version, first byte, in the clear:
22
+ // [ VERSION(1) | NONCE(24) | CIPHERTEXT ]
23
+ // Unauthenticated by necessity — it is read to decide how to open, so it cannot
24
+ // sit under the seal. It buys diagnosis, not compatibility: no v1 reader can open
25
+ // a v2 blob either way, but the failure reads as "unsupported version" rather
26
+ // than an AEAD refusal indistinguishable from a wrong epoch or a tampered frame.
27
+ const ENTRY_VERSION = 1;
28
+ export function createGroupCrypto(params) {
29
+ const { registry, groupID, initialEpoch, runtime = createRuntime() } = params;
30
+ // Exports the ledger-entry seal key for this epoch. Not routed through
31
+ // `exportSecret`, which refuses this label: the two are the same exporter call,
32
+ // and the point of the refusal is that only the seal reaches it.
33
+ const exportEntryKey = ()=>registry.readHandle(groupID, async (handle)=>{
34
+ return await mlsExporter(handle.state.keySchedule.exporterSecret, ENTRY_SEAL_LABEL, TOPIC_SECRET_CONTEXT, DEFAULT_SECRET_LENGTH, handle.context.cipherSuite);
35
+ });
36
+ const exportSecret = (label, length)=>{
37
+ // Reusing the ledger-entry label would not be an independent export: it is
38
+ // the exact exporter call `sealEntries`/`openEntries` make (same context,
39
+ // same length), so it would hand back the ledger-entry seal key under
40
+ // another name and make every holder of a topic secret a reader of the
41
+ // group's control ledger. Refused here rather than left to the doc.
42
+ if (label === ENTRY_SEAL_LABEL) {
43
+ throw new Error(`exportSecret: label '${label}' is reserved for the ledger-entry seal`);
44
+ }
45
+ return registry.readHandle(groupID, async (handle)=>{
46
+ return await mlsExporter(handle.state.keySchedule.exporterSecret, label, TOPIC_SECRET_CONTEXT, length ?? DEFAULT_SECRET_LENGTH, handle.context.cipherSuite);
47
+ });
48
+ };
49
+ // A NUMBER off the registry, never a handle: the registry publishes a scalar
50
+ // precisely so this synchronous contract can be met without anyone reaching
51
+ // MLS state outside the per-group mutex. `initialEpoch` only covers the window
52
+ // before the registry has observed a handle for this group.
53
+ const epoch = ()=>registry.groupEpoch(groupID) ?? initialEpoch;
54
+ const wrap = (bytes)=>registry.readHandle(groupID, (handle)=>mlsEncryptFramed(handle, bytes));
55
+ // In-flight decrypt cache, keyed by the ciphertext bytes.
56
+ //
57
+ // group-rpc subscribes TWO broadcast transports per app-protocol topic (the
58
+ // anycast `BroadcastClient` and the `busServer`) and the hub mux fans every
59
+ // frame to both, so `unwrap` runs twice in one synchronous burst. MLS
60
+ // decryption is single-use: `handle.decrypt` consumes a forward-secret ratchet
61
+ // generation, so the second call fails with "Desired gen in the past" and the
62
+ // frame never reaches its handler. Caching the in-flight promise per ciphertext
63
+ // makes `unwrap` idempotent — the first call advances the ratchet, the sibling
64
+ // (calling before it resolves) replays the plaintext. Entries drop once
65
+ // settled, so the map holds only concurrent in-flight decrypts and never an
66
+ // evicted pending one; distinct ciphertexts miss and decrypt normally, so
67
+ // forward secrecy across messages is preserved.
68
+ //
69
+ // The replayed value carries an IDENTITY, not just bytes. That is sound only
70
+ // because the key is the ciphertext: one ciphertext at one epoch has exactly
71
+ // one MLS-authenticated sender, so a replay cannot attribute a frame to
72
+ // anyone but the leaf that sealed it.
73
+ const unwrapCache = new Map();
74
+ const unwrap = (bytes)=>{
75
+ const key = toB64(bytes);
76
+ const cached = unwrapCache.get(key);
77
+ if (cached != null) {
78
+ return cached;
79
+ }
80
+ const promise = registry.readHandle(groupID, async (handle)=>{
81
+ const { payload, senderDID } = await mlsDecryptFramed(handle, bytes);
82
+ return {
83
+ payload,
84
+ senderDID
85
+ };
86
+ });
87
+ // Drop the entry once it settles (success or failure): the sibling transport
88
+ // has already taken the in-flight promise, and a genuine failure must not be
89
+ // cached so a later valid frame with the same bytes (e.g. after a resync) can
90
+ // decrypt afresh.
91
+ //
92
+ // `.finally()` returns a NEW promise that rejects with the same reason, so
93
+ // this cleanup branch needs a handler of its own. The caller handles the
94
+ // promise it was handed; without the catch here the derived one escapes as an
95
+ // unhandled rejection. Not an edge case: a frame this handle cannot open —
96
+ // another epoch's, another group's — is ordinary on a shared log, so the
97
+ // rejecting path is the common one. Only this branch is absorbed; the promise
98
+ // returned below still rejects to the caller, which is what decides the frame
99
+ // is dead.
100
+ unwrapCache.set(key, promise);
101
+ void promise.finally(()=>{
102
+ unwrapCache.delete(key);
103
+ }).catch(()=>{});
104
+ return promise;
105
+ };
106
+ // Reads the epoch every MLSMessage carries in its own cleartext. Needs no
107
+ // handle and no key, and must never throw: it is asked about every frame a log
108
+ // holds, most of which are not this member's to open. `null` for anything
109
+ // ts-mls will not decode, which is how a caller tells a frame sealed AHEAD
110
+ // (openable once it catches up) from one sealed BELOW (gone forever, since MLS
111
+ // ratchets forward) — the distinction the app lane's durable cursor rests on.
112
+ // Untrusted: it is the publisher's word relayed by an untrusted hub, so it may
113
+ // only decide what to TRY, never that bytes are authentic.
114
+ const frameEpoch = (bytes)=>{
115
+ const epochValue = readMessageEpoch(bytes);
116
+ return epochValue == null ? null : Number(epochValue);
117
+ };
118
+ // Seals the ledger-entry blob a Commit carries, under a key derived from this
119
+ // epoch's exporter secret rather than the message ratchet `wrap` consumes.
120
+ // Derived-key sealing is what makes the open PURE, so it can run from inside
121
+ // the apply of the very commit whose blob it opens — the only place it runs,
122
+ // and one the ratchet-backed pair cannot serve. Byte format is fixed by
123
+ // `@kumiai/mls-rpc`; a one-byte drift is a group that silently partitions.
124
+ const sealEntries = async (bytes)=>{
125
+ const key = await exportEntryKey();
126
+ // Random per seal: two members can frame a commit at the same epoch, and a
127
+ // repeated nonce under one key is a break. 24 bytes puts a collision out of
128
+ // reach without needing a counter.
129
+ const nonce = runtime.getRandomValues(new Uint8Array(ENTRY_NONCE_BYTES));
130
+ const ciphertext = xchacha20poly1305(key, nonce).encrypt(bytes);
131
+ const sealed = new Uint8Array(1 + nonce.length + ciphertext.length);
132
+ sealed[0] = ENTRY_VERSION;
133
+ sealed.set(nonce, 1);
134
+ sealed.set(ciphertext, 1 + nonce.length);
135
+ return sealed;
136
+ };
137
+ const openEntries = async (sealed)=>{
138
+ if (sealed.length <= 1 + ENTRY_NONCE_BYTES) {
139
+ throw new Error('openEntries: not a sealed blob');
140
+ }
141
+ if (sealed[0] !== ENTRY_VERSION) {
142
+ // Distinguishable on purpose: every other failure here is an opaque AEAD
143
+ // refusal, and this lets an operator tell "unsupported version" apart from
144
+ // a wrong epoch or a tampered frame. The lane handles all three the same.
145
+ throw new Error(`openEntries: unsupported blob version ${sealed[0]}`);
146
+ }
147
+ const key = await exportEntryKey();
148
+ return xchacha20poly1305(key, sealed.subarray(1, 1 + ENTRY_NONCE_BYTES)).decrypt(sealed.subarray(1 + ENTRY_NONCE_BYTES));
149
+ };
150
+ return {
151
+ epoch,
152
+ exportSecret,
153
+ wrap,
154
+ unwrap,
155
+ frameEpoch,
156
+ sealEntries,
157
+ openEntries
158
+ };
159
+ }
@@ -1,10 +1,29 @@
1
- import type { GroupHandle } from '@enkaku/group';
2
1
  import type { StoreProvider } from '@kubun/db';
3
2
  import { type Logger } from '@kubun/logger';
3
+ import type { GroupHandle } from '@kumiai/mls';
4
+ import { type LedgerEntryResolver, type LedgerEntrySink } from './mls-group-handle.js';
5
+ /**
6
+ * Per-call handle wiring: the two hooks a caller may bind for the duration of
7
+ * ONE operation rather than for the lifetime of the cached handle. Both are
8
+ * scoped to the working handle a call restores, so nothing a caller binds here
9
+ * leaks into another caller's handle.
10
+ */
11
+ export type HandleCallOptions = {
12
+ /** Replaces the store-based resolver — a received commit's bodies ride its own frame. */
13
+ resolveLedgerEntries?: LedgerEntryResolver;
14
+ /** Collects the entries an accepted commit surfaces, for the caller to fold. */
15
+ onLedgerEntries?: LedgerEntrySink;
16
+ };
4
17
  export type GroupHandleRegistryParams = {
5
18
  stores: StoreProvider;
6
19
  deviceID: string;
7
20
  logger?: Logger;
21
+ /**
22
+ * How long an operation waits for a group's handle before failing. Test-only
23
+ * knob — production runs on {@link MUTEX_ACQUIRE_TIMEOUT_MS}, which is orders
24
+ * of magnitude above any legitimate hold.
25
+ */
26
+ mutexTimeoutMs?: number;
8
27
  };
9
28
  export type SeedParams = {
10
29
  groupID: string;
@@ -17,6 +36,22 @@ export type SeedParams = {
17
36
  * leaves the cache empty.
18
37
  */
19
38
  stores: StoreProvider;
39
+ /**
40
+ * Replace a handle this device already holds for the group, rather than
41
+ * refusing.
42
+ *
43
+ * Only for a REJOIN: a device that left, or that someone else removed, keeps
44
+ * its MLS state and therefore its cached handle, and that handle can decrypt
45
+ * nothing at the group's current epoch. Being admitted back hands it a live
46
+ * one, and the guard below — which exists to stop a live member being rolled
47
+ * back to a handle frozen at its joining epoch — would otherwise refuse the
48
+ * only handle that works.
49
+ *
50
+ * The caller establishes the rejoin from the group row's own `status`, never
51
+ * from the invite: an unauthenticated payload must not be able to displace a
52
+ * live member's handle.
53
+ */
54
+ replace?: boolean;
20
55
  };
21
56
  /**
22
57
  * Single canonical access point for a device's MLS `GroupHandle` instances.
@@ -28,8 +63,7 @@ export type SeedParams = {
28
63
  *
29
64
  * All five mutation paths (manager member ops, context handlers, broadcast
30
65
  * service encrypt/decrypt, hub-tunnel sync listener, hub-tunnel sync provider)
31
- * route MLS state through this registry. Spec:
32
- * `docs/superpowers/specs/2026-05-09-group-handle-registry-design.md`.
66
+ * route MLS state through this registry.
33
67
  */
34
68
  export declare class GroupHandleRegistry {
35
69
  #private;
@@ -43,19 +77,36 @@ export declare class GroupHandleRegistry {
43
77
  * epoch-bumping ops that produce a fresh `GroupHandle` (commitInvite,
44
78
  * removeMember, processCommit), use `withHandleReplacing` instead.
45
79
  *
46
- * `options.stores` overrides the constructor-stored `StoreProvider` for the
47
- * restore + persist DB calls. Callers running inside an engine transaction
48
- * (e.g. context handlers under `mutateGraph`) MUST pass the transactional
49
- * `tx` so reads + writes share the tx's connection. Without this, SQLite's
50
- * write lock held by the outer tx blocks the registry's persist forever.
80
+ * BOUND: under a caller transaction that commits later, the mutex releases
81
+ * before that commit, so a concurrent same-group op can observe the uncommitted
82
+ * handle. `onRollback` repairs the cache; the live exposure is a cross-epoch
83
+ * decrypt that fails the MLS check and skips its persist — a dropped message,
84
+ * never a durable divergence, and only on a multi-connection adapter. Use
85
+ * `withHandleReplacingInTransaction` to avoid the window entirely.
86
+ *
87
+ * The persist runs AFTER the mutex is released — see {@link #withLockedHandle}
88
+ * — but is still awaited here, so a caller that returns has its state durable.
51
89
  */
52
90
  withHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T>, options?: {
53
91
  stores?: StoreProvider;
54
92
  }): Promise<T>;
93
+ /**
94
+ * Acquire the canonical handle for `groupID`, run `fn` under the per-group
95
+ * mutex, return the result, release — WITHOUT persisting MLS state.
96
+ *
97
+ * Read-only access: takes the lock and lazy-restores the cached handle if
98
+ * absent, but never writes MLS state. Use this for pure reads that do not
99
+ * mutate the handle (anchor reads, `GroupInfo` export, credential reads). For
100
+ * in-place-mutating ops that advance the MLS secret tree (encrypt/decrypt),
101
+ * use `withHandle`, which persists the advanced state.
102
+ */
103
+ readHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T> | T, options?: {
104
+ stores?: StoreProvider;
105
+ }): Promise<T>;
55
106
  /**
56
107
  * Like `withHandle`, but the callback returns `{ result, updated }`.
57
108
  * Registry replaces its cached handle with `updated` before persisting on
58
- * success. Required for `@enkaku/group` epoch ops (`commitInvite`,
109
+ * success. Required for `@kumiai/mls` epoch ops (`commitInvite`,
59
110
  * `removeMember`, `processCommit`) which construct a fresh `GroupHandle`
60
111
  * rather than mutating the input.
61
112
  *
@@ -67,15 +118,93 @@ export declare class GroupHandleRegistry {
67
118
  }>, options?: {
68
119
  stores?: StoreProvider;
69
120
  }): Promise<T>;
121
+ /**
122
+ * Like `withHandleReplacing`, but the per-group mutex is held across the
123
+ * ENTIRE transaction — it is acquired once, then a transaction is opened, run,
124
+ * and committed under that single lock, which releases only AFTER commit (or
125
+ * rollback). The cached `entry.handle` is NEVER touched by this method; the
126
+ * cache swap is the `onCommit` invalidation.
127
+ *
128
+ * Use this when the MLS advance must commit or roll back together with other
129
+ * rows written in the same transaction (e.g. receive-side commit processing
130
+ * that advances the ratchet AND writes the roster delta). `fn` receives both
131
+ * the working handle and the transactional `StoreProvider` so it can write
132
+ * those other rows on the same connection.
133
+ *
134
+ * The lock MUST span the commit: the `onCommit` invalidation then fires while it
135
+ * is still held, so there is no post-mutex / pre-commit window for a concurrent
136
+ * op to clobber the advance.
137
+ *
138
+ * The working handle MUST be fresh, not the cached one: `processMessage` advances
139
+ * the ratchet IN PLACE, so running against the cache would advance it while the
140
+ * transaction is still uncommitted, and a rollback would leave the cache ahead of
141
+ * the reverted DB row. Costs one extra MLS-state restore per call.
142
+ *
143
+ * THE ONE PATH that still touches the database under the mutex, because its
144
+ * whole contract is that the lock spans the commit. It is therefore the only
145
+ * remaining candidate to be the holder in a lock-order cycle — see
146
+ * {@link #withLockedHandle} for why that matters — and the acquisition
147
+ * deadline on every other caller is what stops such a cycle being permanent.
148
+ */
149
+ withHandleReplacingInTransaction<T>(groupID: string, stores: StoreProvider, fn: (handle: GroupHandle, tx: StoreProvider) => Promise<{
150
+ result: T;
151
+ updated: GroupHandle;
152
+ }>, options?: HandleCallOptions): Promise<T>;
153
+ /**
154
+ * The last epoch this registry observed for `groupID`, or `null` if it has
155
+ * never held a handle for it. Synchronous, because the one port that needs it
156
+ * — `GroupCrypto.epoch()` — is synchronous by contract while every handle
157
+ * access here is async and mutex-guarded.
158
+ *
159
+ * Publishes a NUMBER, never the handle: a scalar cannot advance a ratchet, skip
160
+ * the per-group mutex, or mutate MLS state. DO NOT widen this to return the
161
+ * handle — the mutex is what makes the ratchet advance and its row write one
162
+ * atomic step.
163
+ *
164
+ * Deliberately NOT monotonic: a rolled-back advance must republish the LOWER
165
+ * epoch, or the reader is pinned above the state the device holds. Genuine
166
+ * backwards installs are refused upstream, by `replaceHandle`'s epoch guard and
167
+ * `saveMLSState`'s no-op on a lower row.
168
+ */
169
+ groupEpoch(groupID: string): number | null;
170
+ /**
171
+ * Called when the epoch published for a group CHANGES — an advance, or the
172
+ * lower republish a rolled-back one produces. Returns the unsubscribe.
173
+ *
174
+ * The first observation of a group is deliberately NOT a change: restoring a
175
+ * handle at boot publishes an epoch where there was none, and treating that as
176
+ * a rotation would have every group fire its epoch-change consumers once per
177
+ * process start.
178
+ *
179
+ * Listeners run INSIDE the per-group mutex and inside whatever transaction the
180
+ * advancing operation holds, so one must never await the registry or the store
181
+ * from here. Defer the work.
182
+ */
183
+ onEpochChanged(listener: (groupID: string, epoch: number) => void): () => void;
70
184
  /**
71
185
  * Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
72
186
  * Called by `leaveGroup` after the MLS state row is deleted, and by tests.
187
+ *
188
+ * The published epoch is deliberately LEFT in place. Invalidation is how this
189
+ * registry says "re-read the durable row", not "this device no longer has an
190
+ * epoch" — and the transactional advance path invalidates immediately after
191
+ * committing an advance, so clearing here would discard the newest epoch the
192
+ * device has. The next restore republishes whatever the row actually holds.
73
193
  */
74
194
  invalidate(groupID: string): void;
75
195
  /**
76
196
  * Atomically swap+persist the cached handle under the per-group mutex, so a
77
197
  * concurrent decrypt runs strictly before (stale handle, fails harmlessly) or
78
198
  * after (new handle), never interleaved.
199
+ *
200
+ * MONOTONIC in the epoch: a handle strictly BEHIND the one held is refused, no
201
+ * cache swap and no persist. Installing one would roll this device's key schedule
202
+ * back and have it encrypt at an epoch its co-members have left, producing traffic
203
+ * nobody can open with nothing raised. Reachable because a journalled commit is
204
+ * adopted at-least-once, carrying a handle frozen at the epoch it was built.
205
+ *
206
+ * The bound is STRICT — a same-epoch re-install must still win, because decrypting
207
+ * an application message advances the secret-tree generation within one epoch.
79
208
  */
80
209
  replaceHandle(groupID: string, handle: GroupHandle, options?: {
81
210
  stores?: StoreProvider;