@kubun/plugin-p2p 0.11.0 → 0.12.1

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 (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +59 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -1,58 +1,75 @@
1
+ import { readMessageEpoch } from '@kumiai/mls';
2
+ import { ENTRY_SEAL_LABEL } from '@kumiai/mls-rpc';
3
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
1
4
  import { toB64 } from '@sozai/codec';
5
+ import { createRuntime } from '@sozai/runtime';
2
6
  import { mlsExporter } from 'ts-mls';
3
7
  import { mlsDecryptFramed, mlsEncryptFramed } from './mls-codec.js';
4
- // Domain-separation label for the epoch-bound topic-derivation secret. Stable so
5
- // the exported secret is constant within an epoch and rotates when a commit
6
- // advances the epoch (MLS re-derives exporterSecret per epoch).
7
- const TOPIC_SECRET_LABEL = 'kubun/group-rpc/topic';
8
8
  // Empty context: the epoch already varies the secret; topic derivation mixes in
9
- // its own epoch/protocol/scope on top.
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.
10
12
  const TOPIC_SECRET_CONTEXT = new Uint8Array(0);
11
- // 32-byte secret feeds an HKDF-based topic derivation; the length is otherwise
12
- // unconstrained.
13
- const TOPIC_SECRET_LENGTH = 32;
14
- /**
15
- * Adapts a `GroupHandleRegistry` group into the `@kumiai/rpc`
16
- * {@link GroupCrypto} port: epoch number, an epoch-bound topic-derivation
17
- * secret, and byte-level encrypt/decrypt over the live MLS handle.
18
- *
19
- * `wrap`/`unwrap`/`exportSecret` route through `registry.readHandle` so they take
20
- * the per-group mutex and observe the canonical handle, using the same framing as
21
- * `MLSEncryptor` (see `mls-codec.ts`).
22
- *
23
- * `GroupCrypto.epoch()` is synchronous by contract, but the registry only
24
- * exposes the handle behind an async, mutex-guarded `readHandle`. group-rpc
25
- * always calls `epoch()` immediately after awaiting `exportSecret()` (see
26
- * GroupPeer's epoch rebuild), so `exportSecret` records the epoch it read under
27
- * the same lock and `epoch()` returns that cached value — no second, racy lock
28
- * acquisition.
29
- */ export function createGroupCrypto(registry, groupID) {
30
- let lastEpoch = 0;
31
- const exportSecret = ()=>registry.readHandle(groupID, async (handle)=>{
32
- lastEpoch = Number(handle.epoch);
33
- return await mlsExporter(handle.state.keySchedule.exporterSecret, TOPIC_SECRET_LABEL, TOPIC_SECRET_CONTEXT, TOPIC_SECRET_LENGTH, handle.context.cipherSuite);
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);
34
35
  });
35
- const epoch = ()=>lastEpoch;
36
- const wrap = (bytes)=>registry.readHandle(groupID, async (handle)=>{
37
- const { framed } = await mlsEncryptFramed(handle, bytes);
38
- return framed;
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);
39
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));
40
55
  // In-flight decrypt cache, keyed by the ciphertext bytes.
41
56
  //
42
- // group-rpc subscribes TWO broadcast transports to each app-protocol topic
43
- // the anycast `BroadcastClient` and the `busServer` and the hub mux fans
44
- // every inbound frame to BOTH, so each frame's `unwrap` is invoked twice in the
45
- // same synchronous burst. MLS application-message decryption is single-use:
46
- // `handle.decrypt` consumes a forward-secret ratchet generation, so a second
47
- // decrypt of the same ciphertext fails with "Desired gen in the past" and the
48
- // frame never reaches the event handler. Caching the in-flight decrypt promise
49
- // per ciphertext makes `unwrap` idempotent: the first call advances the ratchet
50
- // once and the sibling transport (which calls before the decrypt resolves)
51
- // replays the same plaintext without touching the ratchet. The entry is removed
52
- // once it settles, so the cache only ever holds the handful of concurrently
53
- // in-flight decrypts — no unbounded growth and never an evicted pending entry.
54
- // Distinct ciphertexts always miss and decrypt normally, so forward secrecy
55
- // across messages is preserved.
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.
56
73
  const unwrapCache = new Map();
57
74
  const unwrap = (bytes)=>{
58
75
  const key = toB64(bytes);
@@ -61,25 +78,82 @@ const TOPIC_SECRET_LENGTH = 32;
61
78
  return cached;
62
79
  }
63
80
  const promise = registry.readHandle(groupID, async (handle)=>{
64
- const payload = await mlsDecryptFramed(handle, bytes);
81
+ const { payload, senderDID } = await mlsDecryptFramed(handle, bytes);
65
82
  return {
66
- payload
83
+ payload,
84
+ senderDID
67
85
  };
68
86
  });
69
87
  // Drop the entry once it settles (success or failure): the sibling transport
70
88
  // has already taken the in-flight promise, and a genuine failure must not be
71
89
  // cached so a later valid frame with the same bytes (e.g. after a resync) can
72
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.
73
100
  unwrapCache.set(key, promise);
74
101
  void promise.finally(()=>{
75
102
  unwrapCache.delete(key);
76
- });
103
+ }).catch(()=>{});
77
104
  return promise;
78
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
+ };
79
150
  return {
80
151
  epoch,
81
152
  exportSecret,
82
153
  wrap,
83
- unwrap
154
+ unwrap,
155
+ frameEpoch,
156
+ sealEntries,
157
+ openEntries
84
158
  };
85
159
  }
@@ -1,10 +1,29 @@
1
1
  import type { StoreProvider } from '@kubun/db';
2
2
  import { type Logger } from '@kubun/logger';
3
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,22 +77,15 @@ 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
- * Bound: when called under a caller transaction that commits later, this
47
- * mutates/eager-uses the shared cached handle and releases the mutex before
48
- * that commit, so a concurrent same-group op can observe the uncommitted
49
- * handle during the window. `onRollback` repairs the cache after a rollback;
50
- * the live exposure is a concurrent cross-epoch decrypt during a rolling-back
51
- * membership mutation, which fails the MLS check and skips its persist (a
52
- * dropped message, never a durable divergence), and only on a multi-connection
53
- * adapter. The transactional epoch-bumping path uses
54
- * `withHandleReplacingInTransaction` (fresh handle + mutex held across commit)
55
- * to avoid the window entirely.
56
- *
57
- * `options.stores` overrides the constructor-stored `StoreProvider` for the
58
- * restore + persist DB calls. Callers running inside an engine transaction
59
- * (e.g. context handlers under `mutateGraph`) MUST pass the transactional
60
- * `tx` so reads + writes share the tx's connection. Without this, SQLite's
61
- * 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.
62
89
  */
63
90
  withHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T>, options?: {
64
91
  stores?: StoreProvider;
@@ -104,37 +131,80 @@ export declare class GroupHandleRegistry {
104
131
  * the working handle and the transactional `StoreProvider` so it can write
105
132
  * those other rows on the same connection.
106
133
  *
107
- * WHY the lock spans commit: holding the mutex until after commit means the
108
- * `onCommit` invalidation fires while the lock is still held. By the time a
109
- * concurrent same-group op acquires the lock, the cache is already invalidated
110
- * and the committed advanced row is durable, so the concurrent op re-restores
111
- * the advanced handle. There is no post-mutex / pre-commit window for a
112
- * concurrent op to interleave and clobber the advance.
113
- *
114
- * WHY a fresh working handle rather than the cached one: epoch ops here
115
- * advance the MLS ratchet IN PLACE (`handle.processMessage` mutates the handle
116
- * object and returns bytes, not a fresh handle). If the callback ran against
117
- * the cached `entry.handle`, that in-place mutation would advance the cache
118
- * immediately but the transaction is still uncommitted. On ROLLBACK the DB
119
- * ratchet row reverts while the cached handle stays advanced, so cache and DB
120
- * diverge. Restoring a throwaway working handle keeps the cache untouched: on
121
- * rollback the cache still matches the reverted DB; on commit the cache is
122
- * invalidated and the next access lazily re-restores the committed advanced
123
- * state. This costs one extra MLS-state restore per call.
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.
124
148
  */
125
149
  withHandleReplacingInTransaction<T>(groupID: string, stores: StoreProvider, fn: (handle: GroupHandle, tx: StoreProvider) => Promise<{
126
150
  result: T;
127
151
  updated: GroupHandle;
128
- }>): Promise<T>;
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;
129
184
  /**
130
185
  * Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
131
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.
132
193
  */
133
194
  invalidate(groupID: string): void;
134
195
  /**
135
196
  * Atomically swap+persist the cached handle under the per-group mutex, so a
136
197
  * concurrent decrypt runs strictly before (stale handle, fails harmlessly) or
137
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.
138
208
  */
139
209
  replaceHandle(groupID: string, handle: GroupHandle, options?: {
140
210
  stores?: StoreProvider;