@kubun/plugin-p2p 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +3 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +105 -91
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +5 -1
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +61 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +11 -0
  28. package/lib/groups/group-health-monitor.js +139 -1
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +20 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +0 -6
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.js +198 -1
  73. package/lib/hub/wiring.d.ts +17 -48
  74. package/lib/hub/wiring.js +132 -1
  75. package/lib/index.d.ts +17 -16
  76. package/lib/index.js +392 -1
  77. package/lib/protocol.d.ts +1 -1
  78. package/lib/protocol.js +251 -1
  79. package/lib/schema.d.ts +2 -1
  80. package/lib/schema.js +439 -2
  81. package/lib/sync/authorize.d.ts +1 -1
  82. package/lib/sync/authorize.js +73 -1
  83. package/lib/sync/broadcast-queue.js +183 -1
  84. package/lib/sync/broadcast-sender.js +179 -1
  85. package/lib/sync/catalog-scope.js +68 -1
  86. package/lib/sync/errors.d.ts +3 -2
  87. package/lib/sync/errors.js +19 -1
  88. package/lib/sync/forwarder.js +277 -1
  89. package/lib/sync/handlers.js +160 -1
  90. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
  91. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  92. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  93. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  94. package/lib/sync/merkle-apply.d.ts +5 -0
  95. package/lib/sync/merkle-apply.js +194 -1
  96. package/lib/sync/merkle-channel.d.ts +2 -2
  97. package/lib/sync/merkle-channel.js +182 -1
  98. package/lib/sync/merkle-tree.js +120 -1
  99. package/lib/sync/peer-registry.d.ts +1 -1
  100. package/lib/sync/peer-registry.js +107 -1
  101. package/lib/sync/receive-access-gate.js +24 -1
  102. package/lib/sync/scope-resolver.js +41 -1
  103. package/lib/sync/sync-client.d.ts +3 -3
  104. package/lib/sync/sync-client.js +192 -1
  105. package/lib/sync/sync-manager.d.ts +2 -2
  106. package/lib/sync/sync-manager.js +272 -1
  107. package/lib/types.d.ts +11 -0
  108. package/lib/types.js +2 -1
  109. package/lib/util/mutex.js +10 -1
  110. package/lib/utils.js +21 -1
  111. package/package.json +47 -40
  112. package/lib/groups/broadcast-service.d.ts +0 -263
  113. package/lib/groups/broadcast-service.js +0 -1
  114. package/lib/groups/rejoin-codec.d.ts +0 -14
  115. package/lib/groups/rejoin-codec.js +0 -1
  116. package/lib/groups/wire-frame.d.ts +0 -34
  117. package/lib/groups/wire-frame.js +0 -1
  118. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  119. package/lib/hub/circle-catchup-requester.js +0 -1
  120. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  121. package/lib/hub/circle-catchup-responder.js +0 -1
  122. package/lib/hub/connection-pool.d.ts +0 -43
  123. package/lib/hub/connection-pool.js +0 -1
  124. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  125. package/lib/hub/epoch-stale-detector.js +0 -1
  126. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  127. package/lib/hub/forward-remote-broadcast.js +0 -1
  128. package/lib/hub/group-channel.d.ts +0 -89
  129. package/lib/hub/group-channel.js +0 -1
  130. package/lib/hub/hub-connection.d.ts +0 -96
  131. package/lib/hub/hub-connection.js +0 -1
  132. package/lib/hub/receive-handler.d.ts +0 -51
  133. package/lib/hub/receive-handler.js +0 -1
  134. package/lib/hub/rejoin-manager.d.ts +0 -78
  135. package/lib/hub/rejoin-manager.js +0 -1
  136. package/lib/hub/rejoin-responder.d.ts +0 -32
  137. package/lib/hub/rejoin-responder.js +0 -1
  138. package/lib/hub/relay-manager.d.ts +0 -142
  139. package/lib/hub/relay-manager.js +0 -1
  140. package/lib/hub/send-handler.d.ts +0 -40
  141. package/lib/hub/send-handler.js +0 -1
  142. package/lib/hub/tunnel-inbox.d.ts +0 -20
  143. package/lib/hub/tunnel-inbox.js +0 -1
  144. package/lib/hub/wait-for-gate.d.ts +0 -14
  145. package/lib/hub/wait-for-gate.js +0 -1
@@ -1,263 +0,0 @@
1
- import { EventEmitter } from '@enkaku/event';
2
- import { type GroupHandle, type MemberCredential } from '@enkaku/group';
3
- import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
4
- import { HLC } from '@kubun/hlc';
5
- import type { Logger } from '@kubun/logger';
6
- import type { GraphStoreAPI } from '@kubun/store-graph';
7
- import type { P2PStoreAPI } from '@kubun/store-p2p';
8
- import type { ForwardingConfig } from '../sync/forwarder.js';
9
- import type { StoreUnreadableMode } from '../types.js';
10
- import { type AffectedRow, type ApplyCatchupReplyResult, type GroupBroadcastMessage } from './broadcast.js';
11
- import type { P2PEventEmitter } from './events.js';
12
- import type { GroupHandleRegistry } from './group-handle-registry.js';
13
- /**
14
- * Thrown when a received broadcast fails to decrypt (MLS restore, decrypt,
15
- * or deserialize). Callers should typically ack-and-skip these, since the
16
- * message is unrecoverable and redelivery will not help.
17
- */
18
- export declare class DecryptError extends Error {
19
- #private;
20
- constructor(message: string, cause: unknown);
21
- get cause(): unknown;
22
- }
23
- /**
24
- * Decode failure on a rejoin control frame. Distinct from {@link DecryptError}
25
- * so the receive path classifies it `control`, not `decrypt-failed` — a corrupt
26
- * frame is not evidence of epoch-staleness and must not drive the health
27
- * detector. Ack-and-skip: redelivery can't fix a malformed frame.
28
- */
29
- export declare class MalformedControlError extends Error {
30
- #private;
31
- constructor(message: string, cause: unknown);
32
- get cause(): unknown;
33
- }
34
- /**
35
- * Thrown when a successfully decrypted broadcast fails to apply to the local
36
- * DB. Callers should typically NOT ack these, so the message can be
37
- * redelivered after the transient failure clears.
38
- */
39
- export declare class ApplyError extends Error {
40
- #private;
41
- constructor(message: string, cause: unknown);
42
- get cause(): unknown;
43
- }
44
- /**
45
- * Thrown when a received MLS Commit targets a future epoch the local handle
46
- * has not reached yet (a gap in the in-order Commit stream). Callers should
47
- * NOT ack — the hub redelivers in `sequence_id` order, so the gap-filling
48
- * Commit arrives first and this one applies on a later delivery.
49
- */
50
- export declare class CommitOutOfOrderError extends Error {
51
- #private;
52
- constructor(messageEpoch: bigint, handleEpoch: bigint);
53
- get messageEpoch(): bigint;
54
- get handleEpoch(): bigint;
55
- }
56
- export type BroadcastEvent = {
57
- groupID: string;
58
- message: GroupBroadcastMessage;
59
- applied: boolean;
60
- /**
61
- * Row affected by the broadcast. After-state for create / update,
62
- * before-state for delete / remove. Undefined when `applied === false`.
63
- */
64
- affected?: AffectedRow;
65
- /**
66
- * Count of `mutation:apply` entries the local broadcast log already had.
67
- * Only set on the `mutation:apply` branch; absent on control-plane
68
- * variants.
69
- */
70
- duplicates?: number;
71
- };
72
- export type BroadcastServiceEvents = {
73
- broadcast: BroadcastEvent;
74
- };
75
- export type SendBroadcastParams = {
76
- groupID: string;
77
- message: GroupBroadcastMessage;
78
- /**
79
- * Wire-send hook invoked with the MLS-encrypted payload INSIDE the
80
- * registry's per-group lock. A throw here propagates out of `prepareSend`
81
- * and the registry skips persist — so a failed send leaves the device's
82
- * MLS state at its pre-encrypt epoch, preventing the wedge where local
83
- * state outpaces recipients.
84
- */
85
- send: (encrypted: Uint8Array) => Promise<void>;
86
- };
87
- export type SendCommitParams = {
88
- groupID: string;
89
- /**
90
- * Canonical MLS-encoded handshake Commit bytes (an encrypted
91
- * `PrivateMessage`) produced by the committer's `GroupManager` call.
92
- */
93
- commitBytes: Uint8Array;
94
- /** Wire-send hook; receives the `mls`-framed bytes. */
95
- send: (framed: Uint8Array) => Promise<void>;
96
- };
97
- export type ReceiveBroadcastParams = {
98
- groupID: string;
99
- encrypted: Uint8Array;
100
- };
101
- export type ReceiveAppResult = {
102
- kind: 'app';
103
- message: GroupBroadcastMessage;
104
- applied: boolean;
105
- affected?: AffectedRow;
106
- duplicates?: number;
107
- };
108
- /**
109
- * Result of processing a received MLS handshake Commit (`mls` wire frame).
110
- * `removed`/`added` are the membership deltas derived by diffing the group
111
- * roster before and after applying the Commit. `removed` DIDs are tombstoned
112
- * via `markMemberRemoved` so the apply-time membership check can reject their
113
- * post-removal writes.
114
- */
115
- export type ReceiveCommitResult = {
116
- kind: 'mls';
117
- epoch: bigint;
118
- removed: Array<string>;
119
- added: Array<string>;
120
- };
121
- /**
122
- * A received `rejoin-request`: a stale device asking current members for fresh
123
- * GroupInfo. Cleartext; does not touch the MLS handle.
124
- */
125
- export type ReceiveRejoinRequestResult = {
126
- kind: 'rejoin-request';
127
- requestID: string;
128
- };
129
- /**
130
- * A current member's `rejoin-groupinfo` reply carrying fresh GroupInfo. Consumed
131
- * by the requester's rejoin flow; also drives responder suppression on peers.
132
- */
133
- export type ReceiveRejoinGroupInfoResult = {
134
- kind: 'rejoin-groupinfo';
135
- requestID: string;
136
- groupInfo: Uint8Array;
137
- };
138
- export type ReceiveBroadcastResult = ReceiveAppResult | ReceiveCommitResult | ReceiveRejoinRequestResult | ReceiveRejoinGroupInfoResult;
139
- export type SendRejoinRequestParams = {
140
- requestID: string;
141
- /** Wire-send hook; receives the `rejoinRequest`-framed cleartext bytes. */
142
- send: (framed: Uint8Array) => Promise<void>;
143
- };
144
- export type SendRejoinGroupInfoParams = {
145
- requestID: string;
146
- groupInfo: Uint8Array;
147
- /** Wire-send hook; receives the `rejoinGroupInfo`-framed cleartext bytes. */
148
- send: (framed: Uint8Array) => Promise<void>;
149
- };
150
- /**
151
- * BroadcastService handles MLS encrypt/decrypt for group broadcasts
152
- * and applies received messages to the local DB.
153
- *
154
- * Encrypt and decrypt go through `GroupHandleRegistry.withHandle` so MLS
155
- * state mutations serialize through the per-group mutex with write-through
156
- * persist. The DB-apply step (`processBroadcast`) for receives runs OUTSIDE
157
- * the lock so a slow apply does not block subsequent encrypt/decrypt on the
158
- * same group.
159
- */
160
- export type BroadcastServiceParams = {
161
- p2pStore: P2PStoreAPI;
162
- graphStore: GraphStoreAPI;
163
- /**
164
- * Engine graph internals — required so `mutation:apply` broadcasts route
165
- * through {@link GraphInternals.applyVerifiedMutation} with `origin: 'peer'`.
166
- */
167
- graph: GraphInternals;
168
- /** Self DID for `mutation:apply` loopback detection. */
169
- selfDID: string;
170
- /** Single canonical access point for MLS `GroupHandle` instances. */
171
- registry: GroupHandleRegistry;
172
- /**
173
- * Device-wide monotonic clock for stamping the `removed_at_hlc` of members
174
- * dropped by a received MLS Commit (the receive side has no HLC otherwise).
175
- * Required: production passes the engine's single instance so every write
176
- * from this device advances one shared clock, preventing two same-millisecond
177
- * writes from minting identical timestamps (the second would silently lose
178
- * under last-writer-wins).
179
- */
180
- hlc: HLC;
181
- /**
182
- * Receiver storage mode for incoming `mutation:apply` entries. Defaults
183
- * to `'persist'`. When `'drop'`, `defaultAccessLevel` must be supplied
184
- * so the gate factory can resolve server-side default access rules.
185
- */
186
- storeUnreadable?: StoreUnreadableMode;
187
- /** Server default access level — required when `storeUnreadable === 'drop'`. */
188
- defaultAccessLevel?: DefaultAccessLevel;
189
- /**
190
- * Forwarding config. Threaded into `processBroadcast` per call.
191
- * `false` / undefined → forwarding disabled (default).
192
- */
193
- forwarding?: ForwardingConfig;
194
- /**
195
- * Hub forwarding hook — required when `forwarding` is set. Called once per
196
- * surviving candidate group on the receive path.
197
- */
198
- scheduleBroadcast?: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
199
- /**
200
- * Emitter forwarded to `processBroadcast` so `delegation:share` receives
201
- * surface a `delegationTokenAdded` event to local subscribers.
202
- */
203
- emitter?: P2PEventEmitter;
204
- logger?: Logger;
205
- };
206
- export declare class BroadcastService extends EventEmitter<BroadcastServiceEvents> {
207
- #private;
208
- constructor(params: BroadcastServiceParams);
209
- /**
210
- * Encrypt + send a broadcast for a group, then persist the advanced MLS state.
211
- *
212
- * Failure semantics: send happens inside the registry's per-group lock. A
213
- * throw from `send` propagates out and the registry skips persist — so the
214
- * device's MLS state stays at its pre-encrypt epoch and the next attempt
215
- * re-derives a fresh handle from the prior generation. Wedge protection
216
- * intact.
217
- */
218
- prepareSend(params: SendBroadcastParams): Promise<void>;
219
- /**
220
- * Send a pre-produced MLS handshake Commit to the group, tagged `mls` on the
221
- * wire. The Commit is already a self-protected (encrypted `PrivateMessage`)
222
- * framed message produced by the committer's `GroupManager` call — it is NOT
223
- * re-encrypted through the application path, so this does not touch the
224
- * handle. The committer's MLS state has already advanced + persisted; the
225
- * hub's durable + ordered mailbox carries the Commit to existing members and
226
- * replays it on reconnect.
227
- */
228
- prepareSendCommit(params: SendCommitParams): Promise<void>;
229
- /**
230
- * Export a fresh `MLSMessage(GroupInfo)` from the current handle. Read-only:
231
- * takes the registry lock but does not advance or persist state.
232
- */
233
- exportGroupInfo(groupID: string): Promise<Uint8Array>;
234
- readCredential(groupID: string): Promise<MemberCredential>;
235
- /**
236
- * Swap+persist the cached handle (under the per-group mutex), then reconcile
237
- * the store roster. The swap is security-critical; a reconcile failure is
238
- * logged and swallowed so it can't reject the rejoin after the handle is
239
- * installed (which would skip `markRecovered` and wedge the monitor) — the
240
- * next rejoin re-runs the idempotent reconcile.
241
- */
242
- replaceHandle(groupID: string, handle: GroupHandle): Promise<void>;
243
- /** Send a `rejoin-request` (cleartext) to the group channel. */
244
- prepareSendRejoinRequest(params: SendRejoinRequestParams): Promise<void>;
245
- /** Send a `rejoin-groupinfo` reply (cleartext) to the group channel. */
246
- prepareSendRejoinGroupInfo(params: SendRejoinGroupInfoParams): Promise<void>;
247
- /**
248
- * Process a received broadcast: MLS decrypt INSIDE the registry lock,
249
- * then apply to the DB OUTSIDE the lock so a slow apply doesn't block
250
- * subsequent encrypt/decrypt on the same group.
251
- */
252
- processReceived(params: ReceiveBroadcastParams): Promise<ReceiveBroadcastResult>;
253
- /**
254
- * Apply a `circle-catchup:reply` batch (circles + circle members, including
255
- * tombstoned) row-by-row through the existing handlers. Lock-free: like
256
- * `processReceived`'s post-decrypt apply, this runs OUTSIDE the registry's
257
- * per-group lock — it touches no MLS handle, and each row apply is idempotent
258
- * and order-independent, so a late or duplicate reply converges harmlessly.
259
- */
260
- applyCatchupReply(groupID: string, reply: Extract<GroupBroadcastMessage, {
261
- type: 'circle-catchup:reply';
262
- }>): Promise<ApplyCatchupReplyResult>;
263
- }
@@ -1 +0,0 @@
1
- import{EventEmitter as r}from"@enkaku/event";import{exportGroupInfo as e,readMessageEpoch as t}from"@enkaku/group";import{HLC as a}from"@kubun/hlc";import{applyCatchupReply as o,processBroadcast as s}from"./broadcast.js";import{deserializeBroadcast as i,serializeBroadcast as c}from"./broadcast-codec.js";import{mlsDecryptFramed as n,mlsEncryptFramed as l}from"./mls-codec.js";import{decodeRejoinGroupInfo as d,decodeRejoinRequest as m,encodeRejoinGroupInfo as p,encodeRejoinRequest as h}from"./rejoin-codec.js";import{frameWire as u,unframeWire as w,WireKind as f}from"./wire-frame.js";export class DecryptError extends Error{#r;constructor(r,e){super(r),this.name="DecryptError",this.#r=e}get cause(){return this.#r}}export class MalformedControlError extends Error{#r;constructor(r,e){super(r),this.name="MalformedControlError",this.#r=e}get cause(){return this.#r}}export class ApplyError extends Error{#r;constructor(r,e){super(r),this.name="ApplyError",this.#r=e}get cause(){return this.#r}}export class CommitOutOfOrderError extends Error{#e;#t;constructor(r,e){super(`MLS commit epoch ${r} ahead of handle epoch ${e}`),this.name="CommitOutOfOrderError",this.#e=r,this.#t=e}get messageEpoch(){return this.#e}get handleEpoch(){return this.#t}}export class BroadcastService extends r{#a;#o;#s;constructor(r){super(),this.#s=r.hlc,this.#a={p2pStore:r.p2pStore,graphStore:r.graphStore,graph:r.graph,selfDID:r.selfDID,storeUnreadable:r.storeUnreadable,defaultAccessLevel:r.defaultAccessLevel,forwarding:r.forwarding,scheduleBroadcast:r.scheduleBroadcast,emitter:r.emitter,logger:r.logger},this.#o=r.registry}async prepareSend(r){let{groupID:e,message:t,send:a}=r,o=c(t);await this.#o.withHandle(e,async r=>{let{framed:e}=await l(r,o);await a(u(f.app,e))})}async prepareSendCommit(r){let{commitBytes:e,send:t}=r;await t(u(f.mls,e))}async exportGroupInfo(r){return await this.#o.withHandle(r,async r=>{let{groupInfo:t}=await e({group:r});return t})}async readCredential(r){return await this.#o.withHandle(r,async r=>r.credential)}async replaceHandle(r,e){await this.#o.replaceHandle(r,e);try{await this.#i(r,e)}catch(e){this.#a.logger?.warn("rejoin: store roster reconcile failed",{groupID:r,error:e})}}async #i(r,e){let{p2pStore:t}=this.#a,o=e.listMembers().map(r=>r.id),s=new Set(o),i=await t.listGroupMembers(r),c=new Set(i.map(r=>r.member_did)),n=a.serialize(this.#s.now());for(let e of i)s.has(e.member_did)||await t.markMemberRemoved(r,e.member_did,n);for(let e of o)c.has(e)||await t.addGroupMember({group_id:r,member_did:e,role:"member",hlc:n})}async prepareSendRejoinRequest(r){let{requestID:e,send:t}=r;await t(u(f.rejoinRequest,h({requestID:e})))}async prepareSendRejoinGroupInfo(r){let{requestID:e,groupInfo:t,send:a}=r;await a(u(f.rejoinGroupInfo,p({requestID:e,groupInfo:t})))}async processReceived(r){let e,t,a,o,{groupID:c,encrypted:l}=r;try{({kind:e,payload:t}=w(l))}catch(r){throw new DecryptError("Malformed group wire frame",r)}if(e===f.mls)return await this.#c(c,t);if(e===f.rejoinRequest)try{let{requestID:r}=m(t);return{kind:"rejoin-request",requestID:r}}catch(r){throw new MalformedControlError("Malformed rejoin-request frame",r)}if(e===f.rejoinGroupInfo)try{let{requestID:r,groupInfo:e}=d(t);return{kind:"rejoin-groupinfo",requestID:r,groupInfo:e}}catch(r){throw new MalformedControlError("Malformed rejoin-groupinfo frame",r)}try{a=await this.#o.withHandle(c,async r=>{let e=await n(r,t);return i(e)})}catch(r){throw new DecryptError("Failed to decrypt broadcast",r)}try{o=await s(this.#a,a,c)}catch(r){throw new ApplyError("Failed to apply broadcast",r)}let{applied:p,affected:h,duplicates:u}=o;return await this.emit("broadcast",{groupID:c,message:a,applied:p,affected:h,duplicates:u}),{kind:"app",message:a,applied:p,affected:h,duplicates:u}}async applyCatchupReply(r,e){return await o(this.#a,e,r)}async #c(r,e){return await this.#o.withHandle(r,async o=>{let s=t(e),i=o.epoch;if(null!=s&&s<i)return{kind:"mls",epoch:i,removed:[],added:[]};if(null!=s&&s>i)throw new CommitOutOfOrderError(s,i);let c=o.listMembers();try{await o.processMessage(e)}catch(r){throw new DecryptError("Failed to process MLS commit",r)}let n=o.listMembers(),l=new Set(c.map(r=>r.id)),d=new Set(n.map(r=>r.id)),m=c.filter(r=>!d.has(r.id)).map(r=>r.id),p=n.filter(r=>!l.has(r.id)).map(r=>r.id),h=a.serialize(this.#s.now()),{p2pStore:u}=this.#a;for(let e of m)await u.markMemberRemoved(r,e,h);for(let e of p)await u.addGroupMember({group_id:r,member_did:e,role:"member",hlc:h});return{kind:"mls",epoch:o.epoch,removed:m,added:p}})}}
@@ -1,14 +0,0 @@
1
- /** A stale device's request for a fresh GroupInfo. Cleartext on the wire. */
2
- export type RejoinRequest = {
3
- requestID: string;
4
- };
5
- /** A current member's reply carrying a framed `MLSMessage(GroupInfo)`. */
6
- export type RejoinGroupInfo = {
7
- requestID: string;
8
- /** Framed `MLSMessage(GroupInfo)` bytes. */
9
- groupInfo: Uint8Array;
10
- };
11
- export declare function encodeRejoinRequest(request: RejoinRequest): Uint8Array;
12
- export declare function decodeRejoinRequest(bytes: Uint8Array): RejoinRequest;
13
- export declare function encodeRejoinGroupInfo(message: RejoinGroupInfo): Uint8Array;
14
- export declare function decodeRejoinGroupInfo(bytes: Uint8Array): RejoinGroupInfo;
@@ -1 +0,0 @@
1
- import{replacer as e,reviver as r}from"./mls-json.js";let o=new TextEncoder,n=new TextDecoder;export function encodeRejoinRequest(r){return o.encode(JSON.stringify(r,e))}export function decodeRejoinRequest(e){let o=JSON.parse(n.decode(e),r);if("string"!=typeof o?.requestID)throw Error("invalid rejoin-request: missing requestID");return{requestID:o.requestID}}export function encodeRejoinGroupInfo(r){return o.encode(JSON.stringify(r,e))}export function decodeRejoinGroupInfo(e){let o=JSON.parse(n.decode(e),r);if("string"!=typeof o?.requestID)throw Error("invalid rejoin-groupinfo: missing requestID");if(!(o.groupInfo instanceof Uint8Array))throw Error("invalid rejoin-groupinfo: missing groupInfo bytes");return{requestID:o.requestID,groupInfo:o.groupInfo}}
@@ -1,34 +0,0 @@
1
- /**
2
- * Outer wire-frame discriminator for group hub payloads.
3
- *
4
- * The hub relay treats a group send payload as opaque bytes. Kubun prepends a
5
- * single `kind` byte so the receiver can route a payload to the right decoder
6
- * without trial-decryption:
7
- *
8
- * - `app` — an MLS-encrypted application broadcast (`GroupBroadcastMessage`),
9
- * decoded via `handle.decrypt` + `deserializeBroadcast`.
10
- * - `mls` — a framed MLS handshake message (a Commit), fed straight to
11
- * `handle.processMessage`. The handshake is self-protected (encrypted
12
- * `PrivateMessage`), so it is NOT wrapped in the application-encryption path.
13
- *
14
- * The discriminator lives entirely in kubun's payload bytes; the hub protocol
15
- * is unchanged.
16
- */
17
- export declare const WireKind: {
18
- readonly app: 0;
19
- readonly mls: 1;
20
- readonly rejoinRequest: 2;
21
- readonly rejoinGroupInfo: 3;
22
- };
23
- export type WireKind = (typeof WireKind)[keyof typeof WireKind];
24
- /** Prepend the `kind` byte to a payload. */
25
- export declare function frameWire(kind: WireKind, payload: Uint8Array): Uint8Array;
26
- /**
27
- * Split a framed payload into its `kind` and the remaining payload bytes.
28
- * Throws on an empty frame or an unrecognized `kind` byte. The returned
29
- * payload is a view (`subarray`) over the input, not a copy.
30
- */
31
- export declare function unframeWire(framed: Uint8Array): {
32
- kind: WireKind;
33
- payload: Uint8Array;
34
- };
@@ -1 +0,0 @@
1
- export const WireKind={app:0,mls:1,rejoinRequest:2,rejoinGroupInfo:3};let r=new Set(Object.values(WireKind));export function frameWire(r,e){let n=new Uint8Array(e.length+1);return n[0]=r,n.set(e,1),n}export function unframeWire(e){if(0===e.length)throw Error("empty wire frame");let n=e[0];if(!r.has(n))throw Error(`unknown wire frame kind: ${n}`);return{kind:n,payload:e.subarray(1)}}
@@ -1,58 +0,0 @@
1
- import type { Logger } from '@kubun/logger';
2
- import type { GroupBroadcastMessage } from '../groups/broadcast.js';
3
- /** The reply payload a current member sends back, correlated by `requestID`. */
4
- type CatchupReply = Extract<GroupBroadcastMessage, {
5
- type: 'circle-catchup:reply';
6
- }>;
7
- export type CircleCatchupRequesterParams = {
8
- groupID: string;
9
- /** Fresh correlation id per request (incl. retries). */
10
- genRequestID: () => string;
11
- /** Broadcast a `circle-catchup:request` on the group channel. */
12
- sendRequest: (requestID: string) => Promise<void>;
13
- /**
14
- * Apply a received reply batch row-by-row (idempotent, order-independent).
15
- * Mirrors `applyCatchupReply` bound to the local store/engine context.
16
- */
17
- applyReply: (reply: CatchupReply) => Promise<void>;
18
- requestTimeoutMs?: number;
19
- maxAttempts?: number;
20
- backoffMs?: (attempt: number) => number;
21
- logger?: Logger;
22
- };
23
- /**
24
- * Drives a rejoining device's circle/member catch-up for one group: broadcast a
25
- * `circle-catchup:request`, await the first matching `circle-catchup:reply`
26
- * (correlated by `requestID`) within a timeout, apply it, done. On timeout,
27
- * retry with a capped full-jitter backoff up to `maxAttempts`.
28
- *
29
- * Mirrors the rejoin manager's request/await/retry shape, minus any MLS
30
- * join/confirm — there is no Commit to broadcast and no head to confirm against,
31
- * because `applyReply` is idempotent and order-independent: one good reply
32
- * lands the snapshot, and a duplicate or late reply is a harmless no-op.
33
- *
34
- * Exhaustion is NON-FATAL: a device that never hears back simply keeps the
35
- * state it has; it resolves without throwing.
36
- */
37
- export declare class CircleCatchupRequester {
38
- #private;
39
- constructor(params: CircleCatchupRequesterParams);
40
- get inFlight(): boolean;
41
- /**
42
- * Route a received `circle-catchup:reply`: resolve the matching in-flight
43
- * waiter. Unknown `requestID`s (peers' requests, stale or duplicate replies
44
- * for an already-resolved attempt) are ignored — harmless.
45
- */
46
- onCatchupReply(requestID: string, reply: CatchupReply): void;
47
- /**
48
- * Run the catch-up flow. Idempotent: a concurrent call while one is in flight
49
- * shares the existing run's promise rather than starting a second.
50
- */
51
- run(): Promise<void>;
52
- /**
53
- * Stop a running catch-up and release timers. Idempotent; the loop checks
54
- * `#disposed` between attempts, pending waiters resolve `null`. On channel close.
55
- */
56
- dispose(): void;
57
- }
58
- export {};
@@ -1 +0,0 @@
1
- import{sleep as e,unref as t}from"../utils.js";export class CircleCatchupRequester{#e;#t;#s;#i;#r;#l;#a;#u;#n=new Map;#o=null;#h=!1;constructor(e){this.#e=e.groupID,this.#t=e.genRequestID,this.#s=e.sendRequest,this.#i=e.applyReply,this.#r=e.requestTimeoutMs??5e3,this.#l=e.maxAttempts??8,this.#a=e.backoffMs??s,this.#u=e.logger}get inFlight(){return null!=this.#o}onCatchupReply(e,t){let s=this.#n.get(e);null!=s&&(this.#n.delete(e),s.resolve(t))}run(){if(null!=this.#o)return this.#o;let e=this.#p().finally(()=>{this.#o=null});return this.#o=e,e}async #p(){for(let t=0;t<this.#l&&!this.#h;t++){if(t>0&&(await e(this.#a(t)),this.#h))return;let s=await this.#c(this.#r);if(null!=s)try{await this.#i(s);return}catch(e){this.#u?.warn("circle catch-up: applying reply failed",{groupID:this.#e,error:e})}}this.#h||this.#u?.warn("circle catch-up: no usable reply, giving up",{groupID:this.#e,attempts:this.#l})}async #c(e){let t=this.#t(),s=this.#g(t,e);try{await this.#s(t)}catch(e){return this.#m(t),this.#u?.warn("circle catch-up: request send failed",{groupID:this.#e,error:e}),null}return s}#g(e,s){return new Promise(i=>{let r=setTimeout(()=>{this.#n.delete(e),i(null)},s);t(r),this.#n.set(e,{resolve:e=>{clearTimeout(r),i(e)},cancel:()=>{clearTimeout(r),i(null)}})})}#m(e){let t=this.#n.get(e);null!=t&&(this.#n.delete(e),t.cancel())}dispose(){for(let e of(this.#h=!0,this.#n.values()))e.cancel();this.#n.clear()}}function s(e){return Math.floor(Math.random()*Math.min(500*2**(e-1),5e3))}
@@ -1,50 +0,0 @@
1
- import type { Logger } from '@kubun/logger';
2
- import type { Circle, CircleMember } from '@kubun/store-p2p';
3
- import type { CircleMemberSnapshot, CircleSnapshot } from '../groups/broadcast.js';
4
- /**
5
- * Reads the group's circles + circle members from the store. Mirrors the
6
- * `listCirclesByGroup` / `listCircleMembers` store API, both returning
7
- * tombstoned rows when `includeRemoved` is set so the snapshot can carry
8
- * removed entries.
9
- */
10
- export type CircleCatchupStoreReader = {
11
- listCirclesByGroup: (groupID: string, options?: {
12
- includeRemoved?: boolean;
13
- }) => Promise<Array<Circle>>;
14
- listCircleMembers: (circleID: string, options?: {
15
- includeRemoved?: boolean;
16
- }) => Promise<Array<CircleMember>>;
17
- };
18
- export type CircleCatchupResponderParams = {
19
- groupID: string;
20
- /** Reads the group's circle + circle-member rows (including tombstoned). */
21
- store: CircleCatchupStoreReader;
22
- /** Broadcast a `circle-catchup:reply` to the group channel. */
23
- sendCatchupReply: (requestID: string, circles: Array<CircleSnapshot>, circleMembers: Array<CircleMemberSnapshot>) => Promise<void>;
24
- /** Jitter delay (ms) before replying. Defaults to a random 0..250ms. */
25
- nextJitterMs?: () => number;
26
- /** How long a `requestID` stays suppressed after being answered. */
27
- suppressTTLMs?: number;
28
- logger?: Logger;
29
- };
30
- /**
31
- * Answers `circle-catchup:request`s with one batched snapshot of the group's
32
- * circle + circle-member rows (including tombstoned, each carrying its authored
33
- * HLC). Every current member could reply, so jitter (random pre-reply delay) +
34
- * suppression (cancel on seeing a peer's reply for the same `requestID`)
35
- * collapse the storm to ~1 reply. Keyed on `requestID` (fresh per attempt);
36
- * suppression entries TTL out to bound memory.
37
- *
38
- * Mirrors the rejoin responder's storm-collapse shape; suppression collapses
39
- * only duplicates of one `requestID`, so distinct `requestID`s still draw one
40
- * reply each.
41
- */
42
- export declare class CircleCatchupResponder {
43
- #private;
44
- constructor(params: CircleCatchupResponderParams);
45
- /** Handle an inbound `circle-catchup:request`: schedule a jittered, suppressible reply. */
46
- onCatchupRequest(requestID: string): void;
47
- /** Handle an observed `circle-catchup:reply` (own or peer's): suppress this request. */
48
- onCatchupReplySeen(requestID: string): void;
49
- dispose(): void;
50
- }
@@ -1 +0,0 @@
1
- import{unref as e}from"../utils.js";function s(e){return{id:e.id,groupID:e.group_id,name:e.name,description:e.description,catalogIDs:e.catalog_ids,hlc:e.hlc,removedAtHLC:e.removed_at_hlc??null}}export class CircleCatchupResponder{#e;#s;#t;#r;#i;#p;#o=new Map;#l=new Map;constructor(e){this.#e=e.groupID,this.#s=e.store,this.#t=e.sendCatchupReply,this.#r=e.nextJitterMs??(()=>Math.floor(250*Math.random())),this.#i=e.suppressTTLMs??5e3,this.#p=e.logger}onCatchupRequest(s){if(this.#l.has(s)||this.#o.has(s))return;let t=setTimeout(()=>{this.#o.delete(s),this.#h(s)},this.#r());e(t),this.#o.set(s,t)}onCatchupReplySeen(e){let s=this.#o.get(e);null!=s&&(clearTimeout(s),this.#o.delete(e)),this.#n(e)}dispose(){for(let e of this.#o.values())clearTimeout(e);for(let e of(this.#o.clear(),this.#l.values()))clearTimeout(e);this.#l.clear()}async #h(e){if(!this.#l.has(e)){this.#n(e);try{let{circles:s,circleMembers:t}=await this.#u();await this.#t(e,s,t)}catch(s){this.#p?.warn("circle catch-up responder failed to reply",{groupID:this.#e,requestID:e,error:s})}}}async #u(){let e=await this.#s.listCirclesByGroup(this.#e,{includeRemoved:!0}),t=e.map(s),r=[];for(let s of e)for(let e of(await this.#s.listCircleMembers(s.id,{includeRemoved:!0})))r.push({circleID:e.circle_id,memberDID:e.member_did,role:e.role,hlc:e.hlc,removedAtHLC:e.removed_at_hlc??null});return{circles:t,circleMembers:r}}#n(s){let t=this.#l.get(s);null!=t&&clearTimeout(t);let r=setTimeout(()=>{this.#l.delete(s)},this.#i);e(r),this.#l.set(s,r)}}
@@ -1,43 +0,0 @@
1
- import type { Client } from '@enkaku/client';
2
- import type { HubProtocol } from '@enkaku/hub-protocol';
3
- import type { Logger } from '@kubun/logger';
4
- import type { CreateHubClient } from './relay-manager.js';
5
- export type HubClientRef = {
6
- client: Client<HubProtocol>;
7
- refID: string;
8
- };
9
- /**
10
- * Fired once per freshly-spawned hub client when its first signed response
11
- * arrives. The pool binds the hub URL into the call site; consumers (hub
12
- * manager `captureServerDID`) use the URL to look up the local hub row and
13
- * apply the conflict matrix. Errors are caught and logged — capture never
14
- * blocks message forwarding, even on `HubServerDIDConflictError`.
15
- */
16
- export type OnServerDIDObserved = (params: {
17
- hubURL: string;
18
- serverDID: string;
19
- }) => void | Promise<void>;
20
- /**
21
- * Resolves the locally-pinned `server_did` for a hub URL at spawn time. When
22
- * the hub row for this URL exists and has `server_did` set, the pool passes
23
- * it as `expectedServerDID` to the client factory so the wrapper enforces
24
- * the pin. Returning `null` falls back to TOFU.
25
- *
26
- * Resolution happens fresh on every spawn so `updateHub({ serverDID: null })`
27
- * re-arms TOFU automatically: the next disposed/evicted client triggers a
28
- * fresh resolve.
29
- */
30
- export type ResolvePinnedServerDID = (hubURL: string) => Promise<string | null>;
31
- export type HubConnectionPoolParams = {
32
- createHubClient: CreateHubClient;
33
- logger?: Logger;
34
- onServerDIDObserved?: OnServerDIDObserved;
35
- resolvePinnedDID?: ResolvePinnedServerDID;
36
- };
37
- export declare class HubConnectionPool {
38
- #private;
39
- constructor(params: HubConnectionPoolParams);
40
- acquire(hubURL: string): Promise<HubClientRef>;
41
- release(hubURL: string, refID: string): Promise<void>;
42
- disposeAll(): Promise<void>;
43
- }
@@ -1 +0,0 @@
1
- export class HubConnectionPool{#e;#t;#i;#r;#s=new Map;#n=0;constructor(e){this.#e=e.createHubClient,this.#t=e.logger,this.#i=e.onServerDIDObserved,this.#r=e.resolvePinnedDID}async acquire(e){let t=this.#s.get(e);if(null!=t&&null!=t.disposing&&(await t.disposing,this.#s.get(e)===t&&this.#s.delete(e),t=void 0),null==t){let i,r=this.#i;if(null!=this.#r)try{let t=await this.#r(e);null!=t&&(i=t)}catch(t){this.#t?.warn("failed to resolve pinned server DID, falling back to TOFU",{hubURL:e,error:t})}let s=await this.#e(e,{onServerDID:null==r?void 0:async t=>{try{await r({hubURL:e,serverDID:t})}catch(i){this.#t?.warn("hub server DID capture failed",{hubURL:e,serverDID:t,error:i})}},expectedServerDID:i,onMismatch:null==i?void 0:e=>{this.#t?.warn("hub server DID mismatch — pinning enforcement triggered",{hubURL:e.hubURL,expectedServerDID:e.expectedServerDID,observedServerDID:e.observedServerDID})}}),n={client:s,refs:new Set};t=n,this.#s.set(e,n),s.disposed.then(()=>{this.#s.get(e)===n&&(this.#t?.debug("hub client disposed externally, evicting pool entry",{hubURL:e,outstandingRefs:[...n.refs]}),this.#s.delete(e))}).catch(()=>{})}let i=String(++this.#n);return t.refs.add(i),{client:t.client,refID:i}}async release(e,t){let i=this.#s.get(e);if(null==i)return void this.#t?.debug("HubConnectionPool.release: unknown hubURL",{hubURL:e,refID:t});if(!i.refs.has(t))return void this.#t?.debug("HubConnectionPool.release: unknown refID",{hubURL:e,refID:t});if(i.refs.delete(t),i.refs.size>0)return;let r=i.client.dispose();i.disposing=r;try{await r}finally{this.#s.get(e)===i&&this.#s.delete(e)}}async disposeAll(){let e=Array.from(this.#s.entries());await Promise.all(e.map(async([e,t])=>{t.refs.size>0&&this.#t?.warn("Disposing hub client with outstanding refs",{hubURL:e,refs:Array.from(t.refs)}),await t.client.dispose()})),this.#s.clear()}}
@@ -1,18 +0,0 @@
1
- import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
2
- import type { ReceiveOutcome } from './receive-handler.js';
3
- export type EpochStaleDetectorParams = {
4
- groupID: string;
5
- monitor: GroupHealthMonitor;
6
- };
7
- /**
8
- * Maps a group's receive outcomes to `epoch-stale` signals (one per group):
9
- * `applied` → reset; `decrypt-failed` → signal; `commit-out-of-order` → signal
10
- * only on a REPEAT `sequenceID` (first sighting is normal replay; a repeat means
11
- * the gap-filling Commit was GC'd at the hub and never arrives); else no signal.
12
- * The seen-set grows only while stuck and clears on the next `applied`.
13
- */
14
- export declare class EpochStaleDetector {
15
- #private;
16
- constructor(params: EpochStaleDetectorParams);
17
- onReceiveOutcome(outcome: ReceiveOutcome, sequenceID: string): void;
18
- }
@@ -1 +0,0 @@
1
- export class EpochStaleDetector{#e;#t;#r=new Set;constructor(e){this.#e=e.groupID,this.#t=e.monitor}onReceiveOutcome(e,t){switch(e){case"applied":this.#r.clear(),this.#t.reset(this.#e,"epoch-stale");return;case"decrypt-failed":this.#t.signal(this.#e,"epoch-stale");return;case"commit-out-of-order":this.#r.has(t)?this.#t.signal(this.#e,"epoch-stale"):this.#r.add(t);return;default:return}}}
@@ -1,15 +0,0 @@
1
- import type { Logger } from '@kubun/logger';
2
- import type { BroadcastEvent } from '../groups/broadcast-service.js';
3
- import type { P2PEventEmitter } from '../groups/events.js';
4
- /**
5
- * Map a `BroadcastEvent` from `BroadcastService` into the matching
6
- * `P2PEventEmitter` event.
7
- *
8
- * - Events with `applied === false` are ignored (no state change).
9
- * - `catalog:*` messages are currently a no-op. Catalog send-side broadcasts
10
- * and event propagation are deferred to a future task (see design spec).
11
- * - Payloads are built from `event.affected.row`, which `processBroadcast`
12
- * captures at the appropriate moment (after-state for create / update,
13
- * before-state for delete / remove).
14
- */
15
- export declare function forwardRemoteBroadcast(emitter: P2PEventEmitter, event: BroadcastEvent, logger?: Logger): Promise<void>;
@@ -1 +0,0 @@
1
- import{toISO as e}from"../context/types.js";export async function forwardRemoteBroadcast(r,t,a){if(!t.applied)return;let{message:i,affected:c}=t;if("delegation:share"!==i.type){if(null==c)return void a?.warn("remote broadcast applied but affected row missing",{type:i.type});switch(i.type){case"circle:create":{if("circle"!==c.kind)return;let{row:t}=c;await r.emit("circleCreated",{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at)});return}case"circle:update":{if("circle"!==c.kind)return;let{row:t}=c,a=null!=i.update.catalogIDs?"circleCatalogsChanged":"circleDataChanged";await r.emit(a,{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at),circleID:t.id});return}case"circle:delete":{if("circle"!==c.kind)return;let{row:t}=c;await r.emit("circleDeleted",{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at)});return}case"member:add":{if("member"!==c.kind)return;let{row:t}=c;await r.emit("circleMemberAdded",{circleID:t.circle_id,memberDID:t.member_did,role:t.role,createdAt:e(t.created_at)});return}case"member:remove":{if("member"!==c.kind)return;let{row:t}=c;await r.emit("circleMemberRemoved",{circleID:t.circle_id,memberDID:t.member_did,role:t.role,createdAt:e(t.created_at)});return}case"group:update":{if("group"!==c.kind)return;let{row:t}=c;await r.emit("groupDataChanged",{id:t.id,name:t.name,description:t.description,createdBy:t.created_by,createdAt:e(t.created_at),groupID:t.id});return}case"catalog:create":case"catalog:update":case"catalog:delete":return void a?.debug("catalog broadcast ignored (not yet wired)",{type:i.type})}}}
@@ -1,89 +0,0 @@
1
- import { Disposer } from '@enkaku/async';
2
- import type { OwnIdentity } from '@enkaku/token';
3
- import type { Logger } from '@kubun/logger';
4
- import type { P2PStoreAPI } from '@kubun/store-p2p';
5
- import type { GroupBroadcastMessage } from '../groups/broadcast.js';
6
- import type { BroadcastService } from '../groups/broadcast-service.js';
7
- import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
8
- import type { RejoinResult } from '../types.js';
9
- import type { HubConnection } from './hub-connection.js';
10
- import { RejoinManager } from './rejoin-manager.js';
11
- export type AckBatchParams = {
12
- flushMs: number;
13
- flushMax: number;
14
- send: (ids: Array<string>) => Promise<void>;
15
- logger?: Logger;
16
- };
17
- export declare class AckBatch {
18
- #private;
19
- constructor(params: AckBatchParams);
20
- add(id: string): void;
21
- /**
22
- * Drain the pending id set and send it via the configured sender. Never
23
- * rejects: send errors are logged and the ids are dropped silently (the hub
24
- * will redeliver unacknowledged messages, so the next receive will produce a
25
- * fresh ack).
26
- */
27
- flush(): Promise<void>;
28
- dispose(): void;
29
- }
30
- export declare function computeBackoff(attempt: number, base: number, max: number, jitter: number): number;
31
- export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
32
- export type GroupChannelParams = {
33
- groupID: string;
34
- deviceID: string;
35
- hubConnection: HubConnection;
36
- broadcastService: BroadcastService;
37
- p2pStore: P2PStoreAPI;
38
- logger?: Logger;
39
- /** When provided, receive outcomes feed epoch-stale health detection and the
40
- * rejoin flow becomes available (both need the shared health monitor). */
41
- monitor?: GroupHealthMonitor;
42
- /** Device identity — required by `joinGroupExternal` in the rejoin flow. */
43
- identity?: OwnIdentity;
44
- /** Fresh correlation ids for rejoin requests. */
45
- getRandomID?: () => string;
46
- /**
47
- * Pre-built rejoin manager, overriding the one this channel would construct
48
- * from `monitor`/`identity`/`getRandomID`. Lets the rejoin orchestration be
49
- * driven without standing up real MLS; production callers leave it unset.
50
- */
51
- rejoinManager?: RejoinManager;
52
- };
53
- /**
54
- * Per-group handle: thin wrapper around a shared `HubConnection`.
55
- *
56
- * `open()` subscribes to the connection (issuing `hub/group/join` once),
57
- * `close()` unsubscribes (drains the per-group mutex then issues
58
- * `hub/group/leave`), and `broadcast()` runs the MLS encrypt+send pipeline
59
- * inside `HubConnection.runInGroup()` so the per-group mutex serializes send
60
- * and receive on the same MLS state.
61
- *
62
- * All transport state (refID, ack batching, receive loop, reconnect/backoff)
63
- * lives on `HubConnection` and is shared across every channel for that
64
- * `hubURL`. A peer with N groups uses ONE `hub/receive` stream per
65
- * `(deviceDID, hubURL)` rather than N — this preserves the hub-server's
66
- * single-writer-per-DID invariant for bridge peers.
67
- */
68
- export declare class GroupChannel extends Disposer {
69
- #private;
70
- constructor(params: GroupChannelParams);
71
- open(): Promise<void>;
72
- /**
73
- * Run stale-device recovery for this group. Available only when constructed
74
- * with a health monitor + identity; returns `recovery-failed` otherwise.
75
- */
76
- rejoinGroup(): Promise<RejoinResult>;
77
- close(): Promise<void>;
78
- broadcast(message: GroupBroadcastMessage, opts?: {
79
- timeoutMs?: number;
80
- }): Promise<void>;
81
- /**
82
- * Fan out a pre-produced MLS handshake Commit to the group's existing
83
- * members. Mirrors {@link broadcast} but routes through `handleSendCommit`
84
- * (no application encryption — the Commit is already self-protected).
85
- */
86
- sendCommit(commitBytes: Uint8Array, opts?: {
87
- timeoutMs?: number;
88
- }): Promise<void>;
89
- }
@@ -1 +0,0 @@
1
- import{Disposer as e}from"@enkaku/async";import{toB64 as r}from"@enkaku/codec";import{CircleCatchupRequester as t}from"./circle-catchup-requester.js";import{CircleCatchupResponder as o}from"./circle-catchup-responder.js";import{EpochStaleDetector as s}from"./epoch-stale-detector.js";import{handleReceivedMessage as i}from"./receive-handler.js";import{RejoinManager as n}from"./rejoin-manager.js";import{RejoinResponder as a}from"./rejoin-responder.js";import{handleSendBroadcast as u,handleSendCommit as h}from"./send-handler.js";export class AckBatch{#e;#r;#t;#o;#s=new Set;#i=null;constructor(e){this.#e=e.flushMs,this.#r=e.flushMax,this.#t=e.send,this.#o=e.logger}add(e){let r=0===this.#s.size;if(this.#s.add(e),this.#s.size>=this.#r){null!==this.#i&&(clearTimeout(this.#i),this.#i=null),this.flush();return}r&&null===this.#i&&(this.#i=setTimeout(()=>{this.#i=null,this.flush()},this.#e))}async flush(){if(null!==this.#i&&(clearTimeout(this.#i),this.#i=null),0===this.#s.size)return;let e=Array.from(this.#s);this.#s.clear();try{await this.#t(e)}catch(r){this.#o?.error("ack batch send failed",{error:r,count:e.length})}}dispose(){null!==this.#i&&(clearTimeout(this.#i),this.#i=null),this.#s.clear()}}export function computeBackoff(e,r,t,o){let s=Math.min(r*2**e*(1+o*(2*Math.random()-1)),t);return s<0?0:s}export function abortableSleep(e,r){return new Promise((t,o)=>{if(r.aborted)return void o(r.reason??Error("aborted"));let s=setTimeout(()=>{r.removeEventListener("abort",i),t()},e),i=()=>{clearTimeout(s),r.removeEventListener("abort",i),o(r.reason??Error("aborted"))};r.addEventListener("abort",i)})}export class GroupChannel extends e{#n;#a;#u;#h;#p;#o;#c;#d;#l;#g;#m;#I=!1;constructor(e){super({dispose:async()=>{await this.#f()}}),this.#n=e.groupID,this.#a=e.deviceID,this.#u=e.hubConnection,this.#h=e.broadcastService,this.#p=e.p2pStore,this.#o=e.logger,null!=e.monitor&&(this.#c=new s({groupID:e.groupID,monitor:e.monitor})),this.#d=new a({groupID:e.groupID,exportGroupInfo:()=>this.#h.exportGroupInfo(this.#n),sendGroupInfo:(e,r)=>this.#D(e,r),logger:e.logger}),null!=e.rejoinManager?this.#l=e.rejoinManager:null!=e.monitor&&null!=e.identity&&null!=e.getRandomID&&(this.#l=new n({groupID:e.groupID,identity:e.identity,readCredential:()=>this.#h.readCredential(this.#n),genRequestID:e.getRandomID,sendRequest:e=>this.#b(e),sendCommit:e=>this.sendCommit(e),replaceHandle:e=>this.#h.replaceHandle(this.#n,e),monitor:e.monitor,logger:e.logger})),this.#g=new o({groupID:e.groupID,store:this.#p,sendCatchupReply:(e,r,t)=>this.#y(e,r,t),logger:e.logger}),null!=e.getRandomID&&(this.#m=new t({groupID:e.groupID,genRequestID:e.getRandomID,sendRequest:e=>this.#R(e),applyReply:async e=>{await this.#u.runInGroup(this.#n,()=>this.#h.applyCatchupReply(this.#n,e))},logger:e.logger}))}async open(){if(this.#I)throw Error("GroupChannel already opened");let e=await this.#p.getMLSState(this.#n,this.#a);if(null==e)throw Error(`MLS state missing for group ${this.#n}; cannot join hub group`);await this.#u.subscribe(this.#n,e.credential,e=>this.#j(e)),this.#I=!0}async #j(e){let r=await i({groupID:this.#n,message:e,broadcastService:this.#h,logger:this.#o});this.#c?.onReceiveOutcome(r.outcome,r.sequenceID);let t=r.result;if(t?.kind==="rejoin-request")this.#d.onRejoinRequest(t.requestID);else if(t?.kind==="rejoin-groupinfo")this.#d.onRejoinGroupInfoSeen(t.requestID),this.#l?.onGroupInfo(t.requestID,t.groupInfo);else if(t?.kind==="app"){let e=t.message;"circle-catchup:request"===e.type?this.#g.onCatchupRequest(e.requestID):"circle-catchup:reply"===e.type&&(this.#g.onCatchupReplySeen(e.requestID),this.#m?.onCatchupReply(e.requestID,e))}return{ack:r.ack}}async rejoinGroup(){if(null==this.#l)return{status:"recovery-failed"};let e=await this.#l.rejoin();return"recovered"===e.status&&null!=this.#m&&this.#m.run().catch(e=>{this.#o?.warn("circle catch-up after rejoin failed",{groupID:this.#n,error:e})}),e}#b(e){return this.#I?this.#h.prepareSendRejoinRequest({requestID:e,send:e=>this.#u.broadcast(this.#n,r(e))}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for rejoin request`))}#D(e,t){return this.#I?this.#h.prepareSendRejoinGroupInfo({requestID:e,groupInfo:t,send:e=>this.#u.broadcast(this.#n,r(e))}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for rejoin reply`))}#R(e){return this.#I?this.#h.prepareSend({groupID:this.#n,message:{type:"circle-catchup:request",requestID:e},send:e=>this.#u.broadcast(this.#n,r(e))}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for catch-up request`))}#y(e,t,o){return this.#I?this.#h.prepareSend({groupID:this.#n,message:{type:"circle-catchup:reply",requestID:e,circles:t,circleMembers:o},send:e=>this.#u.broadcast(this.#n,r(e))}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for catch-up reply`))}async close(){this.#I&&await this.dispose()}async #f(){if(this.#d.dispose(),this.#l?.dispose(),this.#g.dispose(),this.#m?.dispose(),this.#I){try{await this.#u.unsubscribe(this.#n)}catch(e){this.#o?.warn("hub-connection unsubscribe failed during close",{groupID:this.#n,error:e})}this.#I=!1}}broadcast(e,r){return this.#I?this.#u.runInGroup(this.#n,async()=>{if(!this.#I)throw Error(`GroupChannel for ${this.#n} is not ready for broadcast`);await u({groupID:this.#n,message:e,broadcastService:this.#h,send:(e,t)=>this.#u.broadcast(e,t,{timeoutMs:r?.timeoutMs}),logger:this.#o})},{timeoutMs:r?.timeoutMs}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for broadcast`))}sendCommit(e,r){return this.#I?this.#u.runInGroup(this.#n,async()=>{if(!this.#I)throw Error(`GroupChannel for ${this.#n} is not ready for sendCommit`);await h({groupID:this.#n,commitBytes:e,broadcastService:this.#h,send:(e,t)=>this.#u.broadcast(e,t,{timeoutMs:r?.timeoutMs}),logger:this.#o})},{timeoutMs:r?.timeoutMs}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for sendCommit`))}}