@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
package/lib/types.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import type { ClientTransportOf } from '@enkaku/protocol';
2
+ import type { AccessLevel } from '@kubun/store-graph';
3
+ import type { ControlRequestKind, ControlRequestOutcome } from '@kubun/store-p2p';
4
+ import type { PeerAvailability, PeerCapability } from './groups/group-protocols.js';
5
+ import type { PeerConnection } from './peer/connection-registry.js';
2
6
  import type { SyncDirection, SyncProtocol } from './protocol.js';
3
7
  import type { PeerConfig, PeerConfigWithID } from './sync/peer-registry.js';
4
8
  import type { SyncScope } from './sync/sync-client.js';
@@ -30,8 +34,29 @@ export type CircleData = {
30
34
  groupID: string;
31
35
  name: string;
32
36
  description: string;
37
+ /** Admin-authored: every group member reaches an open circle implicitly. */
38
+ open: boolean;
33
39
  createdAt: string;
34
40
  };
41
+ /** `pending` until the commit lands, then whichever outcome it landed on. */
42
+ export type ControlRequestStatusValue = 'pending' | ControlRequestOutcome;
43
+ /**
44
+ * One entry of this device's control-request queue. Retained after settling,
45
+ * whatever the outcome, until the app acks it.
46
+ *
47
+ * Every `request*` operation below returns one of these rather than the row it
48
+ * asks for: a control entry moves a projection only when the commit carrying it is
49
+ * ENACTED, so at return the requested state is nowhere yet.
50
+ */
51
+ export type ControlRequestData = {
52
+ id: string;
53
+ groupID: string;
54
+ kind: ControlRequestKind;
55
+ status: ControlRequestStatusValue;
56
+ error: string | null;
57
+ enqueuedAt: string;
58
+ settledAt: string | null;
59
+ };
35
60
  export type CircleMemberData = {
36
61
  circleID: string;
37
62
  memberDID: string;
@@ -44,6 +69,11 @@ export type CatalogData = {
44
69
  name: string;
45
70
  description: string;
46
71
  filterCriteria: string;
72
+ /** Local sync activation: true = active, false = known (not activated). */
73
+ active: boolean;
74
+ /** First-discovery provenance; null for own catalogs. */
75
+ sourceGroupID: string | null;
76
+ sourceCircleID: string | null;
47
77
  createdAt: string;
48
78
  };
49
79
  export type HubData = {
@@ -54,8 +84,34 @@ export type HubData = {
54
84
  createdAt: string;
55
85
  updatedAt: string | null;
56
86
  };
87
+ /**
88
+ * A hub answered under a DID other than the one this device had recorded, and
89
+ * the new one has been adopted. A rotated hub key is routine, so the device
90
+ * reconnects rather than refusing — but it is never silent: the application is
91
+ * told which hub changed identity, and from what to what.
92
+ */
93
+ export type HubServerDIDChangedData = {
94
+ hubURL: string;
95
+ /** The DID that was recorded before the change — never absent, or there is no change. */
96
+ previousServerDID: string;
97
+ /** The DID the hub now advertises at `/info`, now recorded in its place. */
98
+ serverDID: string;
99
+ };
100
+ export type InviteToGroupInput = {
101
+ groupID: string;
102
+ joinRequest: string;
103
+ grants?: Array<string>;
104
+ revocations?: Array<string>;
105
+ };
57
106
  export type InviteToGroupData = {
58
- invitePayload: string;
107
+ /** The settled request the invite was enacted through. */
108
+ request: ControlRequestData;
109
+ /**
110
+ * Welcome + ratchet tree for the joiner. `null` while the Add commit is parked
111
+ * (inviter offline) and `request.status` is `pending`. A settled-negative invite
112
+ * throws rather than returning null here.
113
+ */
114
+ invitePayload: string | null;
59
115
  groupID: string;
60
116
  groupName: string;
61
117
  };
@@ -65,16 +121,35 @@ export type JoinGroupData = {
65
121
  export type LeaveGroupData = {
66
122
  groupID: string;
67
123
  };
68
- export type RemoveGroupMemberData = {
124
+ export type RemoveGroupMemberInput = {
69
125
  groupID: string;
126
+ memberDID: string;
70
127
  };
71
128
  export type DeleteCatalogData = {
72
129
  deletedID: string;
73
130
  };
74
- export type DeleteCircleData = {
75
- deletedID: string;
131
+ export type CreateCircleInput = {
132
+ groupID: string;
133
+ name: string;
134
+ description?: string;
135
+ /**
136
+ * Internal callers only, never the GraphQL surface, and it MUST be DERIVED:
137
+ * `circle.def` is whole-value LWW per subject, so two callers naming one id merge
138
+ * two access scopes — and a member subject embeds the circle id, so both callers'
139
+ * grants land in the merged scope. A derived id is safe because a collision then
140
+ * means they meant the same circle. Omitted, one is minted.
141
+ */
142
+ circleID?: string;
143
+ };
144
+ export type AddCircleMemberInput = {
145
+ circleID: string;
146
+ memberDID: string;
147
+ role: string;
148
+ };
149
+ export type DeleteCircleInput = {
150
+ circleID: string;
76
151
  };
77
- export type RemoveCircleMemberData = {
152
+ export type RemoveCircleMemberInput = {
78
153
  circleID: string;
79
154
  memberDID: string;
80
155
  };
@@ -82,6 +157,17 @@ export type UpdateGroupInput = {
82
157
  groupID: string;
83
158
  name?: string;
84
159
  description?: string;
160
+ /** Circles admins mark open. */
161
+ openCircleIDs?: Array<string>;
162
+ /**
163
+ * The group's commit-hub designation: the shared hub identity every device
164
+ * folds and commits through. Setting it is admin re-designation; omitting it
165
+ * leaves the prior designation in place.
166
+ */
167
+ commitHub?: {
168
+ url: string;
169
+ serverDID?: string | null;
170
+ };
85
171
  };
86
172
  export type UpdateCircleInput = {
87
173
  circleID: string;
@@ -100,43 +186,154 @@ export type GroupRequestContext = {
100
186
  }) => Promise<GroupData>;
101
187
  list: () => Promise<Array<GroupData>>;
102
188
  get: (id: string) => Promise<GroupData | null>;
103
- addMember: (groupID: string, memberDID: string, role: string) => Promise<GroupMemberData>;
104
- listMembers: (groupID: string) => Promise<Array<GroupMemberData>>;
105
- createCircle: (params: {
189
+ /**
190
+ * Throw unless the calling identity is an admin of the group, with
191
+ * `extensions.code = 'KB14'` and `extensions.reason = 'NOT_GROUP_ADMIN'`.
192
+ *
193
+ * Exposed so a caller can gate BEFORE entering the transaction wrapper, which
194
+ * re-raises and strips extensions — that ordering is what keeps the code visible.
195
+ */
196
+ requireAdmin: (groupID: string, action: string) => Promise<void>;
197
+ /**
198
+ * `requireAdmin` for an operation that only knows a circle: resolves the
199
+ * circle's group and gates on that.
200
+ *
201
+ * An unknown circle resolves to no group and is NOT refused here, so a missing
202
+ * circle never reads as an authority problem.
203
+ */
204
+ requireCircleAdmin: (circleID: string, action: string) => Promise<void>;
205
+ /**
206
+ * Whether `did` is an admin right now, from the same membership row
207
+ * `requireAdmin` gates on — so a read surface cannot offer a control the gate
208
+ * refuses.
209
+ */
210
+ isAdmin: (groupID: string, did: string) => Promise<boolean>;
211
+ /**
212
+ * Promote or demote a member, by emitting a self-signed role ledger entry that
213
+ * rides an MLS commit. Authority is the member row's mirrored role.
214
+ *
215
+ * Demoting a DID that is not currently an admin is minted already settled.
216
+ */
217
+ requestSetMemberRole: (params: {
106
218
  groupID: string;
107
- name: string;
108
- description?: string;
109
- }) => Promise<CircleData>;
219
+ memberDID: string;
220
+ role: 'admin' | 'member';
221
+ }) => Promise<ControlRequestData>;
222
+ listMembers: (groupID: string) => Promise<Array<GroupMemberData>>;
223
+ /**
224
+ * Request a new circle, by emitting a self-signed `circle.def` entry that rides
225
+ * an MLS commit. Authority is the caller's mirrored admin role.
226
+ *
227
+ * The id is minted here and handed back immediately, because every step that
228
+ * follows a create names the circle by id.
229
+ */
230
+ requestCreateCircle: (input: CreateCircleInput) => Promise<{
231
+ request: ControlRequestData;
232
+ circleID: string;
233
+ }>;
110
234
  listCircles: (groupID: string) => Promise<Array<CircleData>>;
111
- addCircleMember: (circleID: string, memberDID: string, role: string) => Promise<CircleMemberData>;
235
+ /**
236
+ * Add a DID to a circle at a role, by emitting a self-signed `circle.member`
237
+ * entry that rides an MLS commit. Re-adding an active member at a DIFFERENT role
238
+ * is how a role change is expressed; re-adding at the same role settles `no_op`.
239
+ */
240
+ requestAddCircleMember: (input: AddCircleMemberInput) => Promise<ControlRequestData>;
112
241
  createCatalog: (params: {
113
242
  name: string;
114
243
  description?: string;
115
244
  filterCriteria: string;
245
+ /**
246
+ * Internal callers only, and it must be DERIVED so a re-run converges on the
247
+ * same catalog. Omitted, one is minted.
248
+ */
249
+ catalogID?: string;
116
250
  }) => Promise<CatalogData>;
117
- listCatalogs: (ownerDID?: string) => Promise<Array<CatalogData>>;
251
+ /** All locally-known catalogs (any owner), optionally filtered by activation. */
252
+ listStoredCatalogs: (filter?: {
253
+ active?: boolean;
254
+ }) => Promise<Array<CatalogData>>;
255
+ /**
256
+ * This device's un-acked control requests, oldest first. The store is
257
+ * per-device, so the queue is viewer-scoped by the database itself.
258
+ */
259
+ listControlRequests: (groupID?: string) => Promise<Array<ControlRequestData>>;
260
+ /** One control request by id, or `null` if it was never enqueued or is already acked. */
261
+ getControlRequest: (id: string) => Promise<ControlRequestData | null>;
118
262
  /**
119
- * Build an invite payload for a join request.
263
+ * Drop a settled request from the queue. Acking a `pending` (or unknown)
264
+ * request is a no-op, never an error.
265
+ */
266
+ ackControlRequest: (id: string) => Promise<void>;
267
+ /**
268
+ * Resolve when a control request reaches a terminal status, with the settled
269
+ * request. For a caller that must not run its next step until this one landed.
270
+ *
271
+ * Rejects when `timeoutMs` elapses with the request still pending: callers
272
+ * await this inside a mutation, where waiting forever is a request that never
273
+ * answers.
274
+ */
275
+ awaitSettle: (id: string, timeoutMs: number) => Promise<ControlRequestData>;
276
+ /** Turn on local sync for a known catalog. Throws on an unknown catalogID. */
277
+ activateCatalogSync: (catalogID: string) => Promise<CatalogData>;
278
+ /** Turn off local sync for a catalog. Throws on an unknown catalogID. */
279
+ deactivateCatalogSync: (catalogID: string) => Promise<CatalogData>;
280
+ /**
281
+ * Request an invite for a join request, by driving an MLS Add commit, and
282
+ * return the payload the accepted commit produced.
283
+ *
284
+ * AWAITS the commit — the payload exists only once the group takes the attempt
285
+ * that minted it, and it is deliberately not readable through the control-request
286
+ * node. So the CALLER MUST BE OUTSIDE any write transaction: the lane's own reads
287
+ * need that connection.
120
288
  *
121
289
  * `grants` are stringified write-capability JWTs the caller wants the joiner
122
290
  * to hold immediately on join. `revocations` are signed revocation JWTs the
123
291
  * caller wants the joiner to apply immediately — only verified ones travel;
124
292
  * pending revocations (cap not yet known by the inviter) do not.
125
293
  */
126
- invite: (groupID: string, joinRequest: string, grants?: Array<string>, revocations?: Array<string>) => Promise<InviteToGroupData>;
294
+ requestInvite: (input: InviteToGroupInput) => Promise<InviteToGroupData>;
127
295
  join: (invitePayload: string, joinRequestPayload: string) => Promise<JoinGroupData>;
128
296
  leave: (groupID: string) => Promise<LeaveGroupData>;
129
- removeMember: (groupID: string, memberDID: string) => Promise<RemoveGroupMemberData>;
130
- update: (input: UpdateGroupInput) => Promise<GroupData>;
131
- updateCircle: (input: UpdateCircleInput) => Promise<CircleData>;
297
+ /**
298
+ * Request a member's removal, by driving an MLS Remove commit that also
299
+ * carries the demotion entry an admin's eviction must enact. Authority is the
300
+ * caller's mirrored admin role, so a non-admin is refused before anything is
301
+ * written, as is a removal that would leave the group with no admin.
302
+ */
303
+ requestRemoveMember: (input: RemoveGroupMemberInput) => Promise<ControlRequestData>;
304
+ /**
305
+ * Request a change to the group's settings, by emitting a self-signed
306
+ * `group.settings` entry that rides an MLS commit. Authority is the caller's
307
+ * mirrored admin role. An input carrying no field beyond `groupID` is minted
308
+ * already settled `no_op`.
309
+ */
310
+ requestUpdate: (input: UpdateGroupInput) => Promise<ControlRequestData>;
311
+ /**
312
+ * Request a change to a circle's definition, by emitting a self-signed
313
+ * `circle.def` entry that rides an MLS commit. `circle.def` is whole-value LWW,
314
+ * so the patch is merged over the current definition BEFORE it is signed. An
315
+ * input carrying no field beyond `circleID` is minted already settled `no_op`.
316
+ */
317
+ requestUpdateCircle: (input: UpdateCircleInput) => Promise<ControlRequestData>;
132
318
  updateCatalog: (params: {
133
319
  catalogID: string;
134
320
  name?: string;
135
321
  filterCriteria?: string;
136
322
  }) => Promise<CatalogData>;
137
323
  deleteCatalog: (catalogID: string) => Promise<DeleteCatalogData>;
138
- deleteCircle: (circleID: string) => Promise<DeleteCircleData>;
139
- removeCircleMember: (circleID: string, memberDID: string) => Promise<RemoveCircleMemberData>;
324
+ /**
325
+ * Request that a circle be deleted, by emitting a self-signed `removed`
326
+ * definition entry that rides an MLS commit. Authority is the caller's mirrored
327
+ * admin role. A circle already absent or tombstoned is minted settled `no_op`.
328
+ */
329
+ requestDeleteCircle: (input: DeleteCircleInput) => Promise<ControlRequestData>;
330
+ /**
331
+ * Request that a DID be removed from a circle, by emitting a self-signed
332
+ * `removed` membership entry that rides an MLS commit. Same authority as
333
+ * {@link GroupRequestContext.requestDeleteCircle}. A DID that is not an active
334
+ * member settles `no_op`.
335
+ */
336
+ requestRemoveCircleMember: (input: RemoveCircleMemberInput) => Promise<ControlRequestData>;
140
337
  /**
141
338
  * Mint a `document/write` capability granting `to` write access on resources
142
339
  * matching `res`. Token is stored grantor-side, broadcast to the group, and
@@ -185,11 +382,341 @@ export type GroupRequestContext = {
185
382
  jti: string;
186
383
  }) => Promise<boolean>;
187
384
  };
385
+ /**
386
+ * Result of a `syncPeer` run, mirroring the `SyncPeerPayload` GraphQL type. All
387
+ * zero when the local device has no active catalog (the opt-in resting state):
388
+ * a clean no-op, never an error.
389
+ */
390
+ export type SyncPeerData = {
391
+ messagesReceived: number;
392
+ messagesSent: number;
393
+ divergentBuckets: number;
394
+ };
395
+ /**
396
+ * Why an automatic catch-up carried nothing — the distinction all-zero counts
397
+ * cannot draw, and the one a screen has to draw to say anything true.
398
+ *
399
+ * - `synced` — a session ran. Zero counts here mean the two devices agreed.
400
+ * - `no-scopes` — this device has activated no catalog. Nothing was attempted,
401
+ * and nothing is wrong: the opt-in resting state.
402
+ * - `no-candidates` — no co-member has ever announced merkle catch-up in this
403
+ * group. Peering is incomplete, or nobody else has come online yet.
404
+ * - `no-route` — candidates existed and none answered. Every one was tried.
405
+ */
406
+ export type PeerCatchUpOutcome = 'synced' | 'no-scopes' | 'no-candidates' | 'no-route';
407
+ /**
408
+ * What an automatic catch-up did, and — the part a caller cannot infer — WHICH
409
+ * peer the ranking picked. `peerDID` names the peer a session ran against, the
410
+ * last one tried when none answered, and `null` when none was tried at all.
411
+ *
412
+ * Read {@link outcome}, never the counts: three different situations produce
413
+ * all-zero, and only one of them is a device that is up to date.
414
+ */
415
+ export type PeerCatchUpData = SyncPeerData & {
416
+ peerDID: string | null;
417
+ outcome: PeerCatchUpOutcome;
418
+ /**
419
+ * Why the last candidate failed, when the outcome is `no-route`. Absent for
420
+ * every other outcome, and for a `no-route` whose failure carried no message.
421
+ *
422
+ * `no-route` on its own cannot separate a device that is switched off from a
423
+ * session that connected, was answered, and then starved — two states that
424
+ * want opposite responses from whoever reads them. The failure the loop
425
+ * already catches is the only thing that tells them apart, and it used to be
426
+ * dropped after a debug line.
427
+ */
428
+ failureReason?: string;
429
+ };
188
430
  export type SyncRequestContext = {
189
431
  addPeer: (config: PeerConfig) => Promise<void>;
190
432
  removePeer: (peerDID: string) => Promise<void>;
191
433
  listPeers: () => Promise<Array<PeerConfigWithID>>;
192
434
  getStatus: (peerDID?: string) => SyncStatus;
435
+ /**
436
+ * Run a sync session against one named device, scoped by the local device's
437
+ * ACTIVE catalogs (resolved via `resolveCatalogSyncScopes`). Zero active
438
+ * catalogs → an all-zero no-op payload.
439
+ *
440
+ * `groupID` picks the ROUTE and nothing else: a co-member is reached through
441
+ * that group's hub tunnel, falling back to the persisted registry for a peer
442
+ * added by URL. It does not narrow the scopes — those stay device-wide, which
443
+ * is what lets one session carry documents belonging to another group.
444
+ *
445
+ * Both lookups are persisted, so this works after a restart with no prior
446
+ * `connectPeer`. The peer is taken as given: a user-named device is served
447
+ * whatever its availability class says about it, so a phone is never refused.
448
+ * Unreachable is an error here, not a report — someone asked.
449
+ */
450
+ syncPeer: (groupID: string, peerDID: string) => Promise<SyncPeerData>;
451
+ /**
452
+ * Run the same session against the best candidate the group's discovery
453
+ * projection offers — availability tier first, most recently applied
454
+ * announcement breaking ties — over the same scopes {@link syncPeer} uses.
455
+ *
456
+ * Walks down the ranking when a candidate does not answer, because a row says
457
+ * a device once announced and never that it is on. Reports {@link
458
+ * PeerCatchUpData.outcome} rather than throwing: nobody named these peers, so
459
+ * nobody is waiting to be told which of them was off.
460
+ *
461
+ * No trigger and no timer: the caller decides WHEN (a foreground, a hub
462
+ * connect, a detected gap), and this decides only WHO.
463
+ */
464
+ catchUpWithBestPeer: (groupID: string) => Promise<PeerCatchUpData>;
465
+ /**
466
+ * Every co-member device this one has ever heard announce in the group.
467
+ *
468
+ * The PROJECTION, not a gather: a row records that a device once said what it
469
+ * is, and rows are never deleted on silence. So this is "what is known", and
470
+ * a gather is what annotates it with "and who answered just now" — a screen
471
+ * built on the gather alone shows a device disappearing every time it sleeps.
472
+ *
473
+ * Includes this device's own row, because the projection holds it and hiding
474
+ * it here would make the list disagree with the store for one DID. Callers
475
+ * rendering "my other devices" filter it out.
476
+ */
477
+ listPeerDevices: (groupID: string) => Promise<Array<PeerDeviceData>>;
478
+ };
479
+ /** One device's last word about itself, as the projection holds it. */
480
+ export type PeerDeviceData = {
481
+ peerDID: string;
482
+ /** Self-declared display hint, rendered beside the DID and never read as one. */
483
+ label: string;
484
+ availability: string;
485
+ capabilities: Array<PeerCapability>;
486
+ /** The announcer's own stamp, which the row is LWW-keyed by. */
487
+ hlc: string;
488
+ };
489
+ export type RemoteGroupInfoData = {
490
+ id: string;
491
+ name: string;
492
+ };
493
+ export type PeerInfoData = {
494
+ did: string;
495
+ endpoint: string;
496
+ groups: Array<RemoteGroupInfoData>;
497
+ };
498
+ /**
499
+ * Per-model outcome of an outbound share, mirroring the `CircleModelAccess`
500
+ * GraphQL enum. Reported only for the models the user explicitly listed in
501
+ * `send.models`. High-level ops write only fresh rows — an existing default is
502
+ * reported, never widened.
503
+ */
504
+ export type CircleModelAccess = 'MODEL_GRANTED' | 'MODEL_PRE_EXISTING' | 'DOCUMENT_ONLY';
505
+ export type ModelShareResultData = {
506
+ modelID: string;
507
+ access: CircleModelAccess;
508
+ };
509
+ export type SharePeerGroupParams = {
510
+ peerDID: string;
511
+ /** Share an existing local group. Mutually exclusive with `name`. */
512
+ groupID?: string | null;
513
+ /** Create a new group with this name. Mutually exclusive with `groupID`. */
514
+ name?: string | null;
515
+ /**
516
+ * The user's exact model selection from the share dialog. `null` = no send
517
+ * leg: no catalog is created and no default rows are written (plumbing only).
518
+ * An entry per listed model is returned in `modelAccess`.
519
+ */
520
+ sendModels?: Array<string> | null;
521
+ /**
522
+ * Opt the sharer into pulling the share scope: the share circle's catalog is
523
+ * activated (created active with a fresh `send`, or an existing catalog flipped
524
+ * active when re-sharing an already-configured group). Default `false` — the
525
+ * catalog is created known and the sharer configures pulling later.
526
+ */
527
+ receiveActivate: boolean;
528
+ /**
529
+ * Write the owner's read model-default naming the share circle, through the
530
+ * engine's owner-signed access-default executor. Bound by the resolver to
531
+ * `context.executeSetModelAccessDefaults` so `engine:access-default:set` fires
532
+ * and broadcast/invite seeding stay consistent. Called only for MODEL_GRANTED.
533
+ */
534
+ setReadDefaultForCircle: (modelID: string, circleID: string) => Promise<void>;
535
+ };
536
+ export type SharePeerGroupData = {
537
+ group: GroupData;
538
+ modelAccess: Array<ModelShareResultData>;
539
+ /** The share circle's catalog, or null when no `send` leg created/reused one. */
540
+ catalogID: string | null;
541
+ };
542
+ export type AdmitJoinRequestParams = {
543
+ /** The group to admit into. It must already exist on this device. */
544
+ groupID: string;
545
+ /**
546
+ * The join request its author emitted, verbatim. UNTRUSTED: nothing signed the
547
+ * hand-off, so the joiner's DID comes from the KeyPackage credential and the
548
+ * payload's own `did` field is only cross-checked against it.
549
+ */
550
+ joinRequest: string;
551
+ /** See {@link SharePeerGroupParams.sendModels}. */
552
+ sendModels?: Array<string> | null;
553
+ /** See {@link SharePeerGroupParams.receiveActivate}. */
554
+ receiveActivate: boolean;
555
+ /** See {@link SharePeerGroupParams.setReadDefaultForCircle}. */
556
+ setReadDefaultForCircle: (modelID: string, circleID: string) => Promise<void>;
557
+ };
558
+ export type AdmitJoinRequestData = SharePeerGroupData & {
559
+ /** The admitted DID, derived from the join request's KeyPackage credential. */
560
+ peerDID: string;
561
+ /**
562
+ * The invite the joiner completes with. Carries the designation above, so a
563
+ * joiner that completes it lands in the share circle already.
564
+ */
565
+ invitePayload: string;
566
+ };
567
+ export type JoinPeerGroupParams = {
568
+ peerDID: string;
569
+ /** The group to join, as discovered from the peer's `connectPeer` group list. */
570
+ groupID: string;
571
+ };
572
+ /** The read access-rule fields a `setCircleSync` add/remove writes for a model. */
573
+ export type SetReadDefaultParams = {
574
+ modelID: string;
575
+ accessLevel: AccessLevel;
576
+ allowedDIDs: Array<string> | null;
577
+ allowedCircles: Array<string> | null;
578
+ allowedGroups: Array<string> | null;
579
+ };
580
+ export type SetCircleSyncParams = {
581
+ circleID: string;
582
+ /**
583
+ * `true` activates every catalog in the circle; `false` deactivates them all;
584
+ * `null` leaves activation untouched. Purely local — no entry, no broadcast.
585
+ */
586
+ pull: boolean | null;
587
+ /**
588
+ * The caller's desired sharing END STATE into this circle. `{ models }` makes
589
+ * the caller's read defaults naming the circle become EXACTLY these models
590
+ * (`[]` stops sharing entirely); `null` leaves sharing untouched.
591
+ */
592
+ share: {
593
+ models: Array<string>;
594
+ } | null;
595
+ /**
596
+ * Owner-signed read access-default writer. Bound by the resolver to
597
+ * `context.executeSetModelAccessDefaults` (permissionType `read`) so
598
+ * `engine:access-default:set` fires and the broadcast/push seeding stay
599
+ * consistent. Only ever writes the caller's own rows.
600
+ */
601
+ setReadDefault: (params: SetReadDefaultParams) => Promise<void>;
602
+ /**
603
+ * Owner-signed read access-default remover. Bound by the resolver to
604
+ * `context.executeRemoveModelAccessDefaults(modelID, ['read'])` so
605
+ * `engine:access-default:removed` fires and the removal broadcasts. Used when
606
+ * dropping the circle empties a restricted rule of every grantee.
607
+ */
608
+ removeReadDefault: (modelID: string) => Promise<void>;
609
+ };
610
+ /**
611
+ * A circle the device can reach, carrying THIS device's sync state for it.
612
+ * Computed from local state alone (no wire entry, no network round-trip), and by
613
+ * one shared helper, so the join-time picker, the `circleSync` query, and
614
+ * `setCircleSync`'s post-state cannot drift from one another.
615
+ */
616
+ export type CircleSyncStateData = {
617
+ id: string;
618
+ groupID: string;
619
+ name: string;
620
+ description: string;
621
+ /** The circle's catalog IDs (the scope a pull activates together). */
622
+ catalogIDs: Array<string>;
623
+ /** Admin-authored: every group member reaches an open circle implicitly. */
624
+ open: boolean;
625
+ /**
626
+ * True only when the circle has at least one catalog AND every one is active
627
+ * on this device — the post-state of a pull that activates all the circle's
628
+ * catalogs together (and a pull-off that deactivates them all inverts). A
629
+ * zero-catalog circle has nothing to pull, so it is false.
630
+ */
631
+ pulling: boolean;
632
+ /**
633
+ * Models this device shares INTO the circle: its OWN read access-default rows
634
+ * whose `allowedCircles` names this circle. Write defaults are excluded.
635
+ */
636
+ sharing: Array<string>;
637
+ };
638
+ export type JoinPeerGroupData = {
639
+ group: GroupData;
640
+ /**
641
+ * Catalogs seeded (or already known) for this group — the group's circles'
642
+ * catalog IDs present locally. Apps render the receive side of the share
643
+ * settings from these; they are activated only behind `receiveActivate`.
644
+ */
645
+ knownCatalogIDs: Array<string>;
646
+ /**
647
+ * True when this device already held the full local group state (MLS state +
648
+ * own membership row) and the dance was skipped entirely, so the app can
649
+ * inform the user the group was already joined.
650
+ */
651
+ alreadyJoined: boolean;
652
+ /**
653
+ * The group's OPEN circles with this device's current `pulling` / `sharing`,
654
+ * so the app can render an opt-in picker. Open circles only — a closed circle
655
+ * the device holds a grant for is not a user opt-in and is excluded.
656
+ */
657
+ openCircles: Array<CircleSyncStateData>;
658
+ };
659
+ export type PeerRequestContext = {
660
+ /**
661
+ * Discover a peer at `url` (GET `/info`), validate it advertises the sync +
662
+ * peer protocols, open an Enkaku peer client with the engine identity, fetch
663
+ * the remote DID + groups via `peer/groups`, and hold the connection in
664
+ * memory keyed by the remote DID. Persists nothing durable.
665
+ */
666
+ connect: (url: string) => Promise<PeerInfoData>;
667
+ /**
668
+ * Compose the outbound share. Plumbing always: create/reuse a group, a share
669
+ * circle scoped to both DIDs, run the MLS invite dance over the held peer
670
+ * connection, register the sync peer. Access legs only from an explicit
671
+ * `sendModels`: a catalog over exactly those models plus fresh read model
672
+ * defaults naming the share circle (activated per `receiveActivate`). No send
673
+ * = no catalog, no rows. Every step is idempotent so a retry after a mid-flow
674
+ * failure no-ops the completed steps.
675
+ */
676
+ sharePeerGroup: (params: SharePeerGroupParams) => Promise<SharePeerGroupData>;
677
+ /**
678
+ * Admit the author of a join request into an existing group from local state
679
+ * alone — no peer connection is opened, read, or required. Designates the
680
+ * joiner's access exactly as `sharePeerGroup` does (the same function), then
681
+ * mints the invite that snapshots it, for the operator to carry to the joiner
682
+ * by whatever means it has.
683
+ *
684
+ * The admitted DID comes from the join request's KeyPackage credential, and a
685
+ * payload whose plaintext `did` disagrees with it is refused. A DID already in
686
+ * the MLS roster is also refused — never evicted, which is what the connected
687
+ * path does on the strength of a signature this one does not have.
688
+ */
689
+ admitJoinRequest: (params: AdmitJoinRequestParams) => Promise<AdmitJoinRequestData>;
690
+ /**
691
+ * Compose the inbound join. Plumbing only: mint a KeyPackage bundle, pull an
692
+ * invite from the held peer via the `group/invite` procedure, complete the
693
+ * MLS join (seeding ledger/circles/access-defaults/catalogs from the invite
694
+ * payload as KNOWN), register the sync peer, and trigger a ledger-catchup
695
+ * backstop. It writes no access rows, activates nothing, and runs no sync —
696
+ * the user opts into what to send/receive afterward via the explicit surfaces.
697
+ * The dance is skipped (and `alreadyJoined` reported) only when the device
698
+ * holds the full local group state — MLS state + own membership row; every
699
+ * step is idempotent so a retry re-runs the legs as no-ops and heals a partial
700
+ * run.
701
+ */
702
+ joinPeerGroup: (params: JoinPeerGroupParams) => Promise<JoinPeerGroupData>;
703
+ /**
704
+ * Apply one circle's desired sync end state idempotently, gated on the
705
+ * caller's own access to the circle. `pull` toggles local catalog activation;
706
+ * `share` makes the caller's sharing into the circle become EXACTLY
707
+ * `share.models` via a read-modify-write diff over the caller's own read
708
+ * defaults. Local pull first, then the share rows, then a delivery-guarantee
709
+ * push of the shared models' SET rows to the group's connected in-group peers.
710
+ * Every step is idempotent so a partial run heals on retry.
711
+ */
712
+ setCircleSync: (params: SetCircleSyncParams) => Promise<CircleSyncStateData>;
713
+ /**
714
+ * Every circle in the group this device can currently reach — explicitly or
715
+ * through the open arm — with this device's `pulling` / `sharing` for each. The
716
+ * read path behind the join-time picker: an app re-reads it to pick up a circle
717
+ * the admin opened or closed since the join.
718
+ */
719
+ listCircleSyncStates: (groupID: string) => Promise<Array<CircleSyncStateData>>;
193
720
  };
194
721
  export type P2PJoinRequestContext = {
195
722
  prepareRequest: () => Promise<{
@@ -280,6 +807,7 @@ export type P2PContext = {
280
807
  join: P2PJoinRequestContext;
281
808
  hub: HubRequestContext;
282
809
  delegation: DelegationRequestContext;
810
+ peer: PeerRequestContext;
283
811
  };
284
812
  declare module '@kubun/graphql' {
285
813
  interface PluginContextMap {
@@ -315,12 +843,87 @@ export type GroupHealthSnapshot = {
315
843
  export type RejoinResult = {
316
844
  status: 'recovered' | 'recovery-failed';
317
845
  };
846
+ /** Tuning for a ledger catch-up gather round-trip. */
847
+ export type LedgerCatchupOptions = {
848
+ /**
849
+ * Per-hub gather timeout in milliseconds. Omitted uses the broadcast client's
850
+ * own default. The gather always waits the full window unless `quorum` caps it.
851
+ */
852
+ timeoutMs?: number;
853
+ /** Resolve a hub's gather as soon as this many replies arrive. */
854
+ quorum?: number;
855
+ };
856
+ /** What a ledger catch-up round-trip folded into the local ledger. */
857
+ export type LedgerCatchupSummary = {
858
+ /**
859
+ * Size of the ledger adopted from the one accepted reply.
860
+ *
861
+ * NOT a count of what was new: a gather installs a whole ledger over whatever
862
+ * was held, so "new" is not a question it can answer — re-running a catch-up
863
+ * that changes nothing still reports the full size. Zero means no responder's
864
+ * ledger was accepted, which `responders` distinguishes from no responder at
865
+ * all.
866
+ */
867
+ applied: number;
868
+ /** Entries dropped because their signed token failed verification. */
869
+ rejected: number;
870
+ /** Replies considered, refused ones included (a member on two hubs counts twice). */
871
+ responders: number;
872
+ };
873
+ /**
874
+ * What this device says about itself on a group's peer lane. No DID: the
875
+ * announcer is the frame's MLS-authenticated sender, resolved by the receiver's
876
+ * own lane, so there is nothing here for a caller to claim to be.
877
+ */
878
+ export type LocalPeerAnnouncement = {
879
+ /** Display hint only — a device name, never an identity. */
880
+ label: string;
881
+ availability: PeerAvailability;
882
+ capabilities: Array<PeerCapability>;
883
+ };
884
+ /**
885
+ * One device that ANSWERED a gather, inside the window. Not a member list: a
886
+ * co-member that was asleep is absent from this and keeps its projection row.
887
+ */
888
+ export type GatheredPeer = LocalPeerAnnouncement & {
889
+ /** The MLS-authenticated responder. Never anything the reply body claimed. */
890
+ peerDID: string;
891
+ /** The responder's own stamp, the same one its projection row is keyed by. */
892
+ hlc: string;
893
+ };
894
+ export type PeerGatherOptions = {
895
+ /**
896
+ * Only devices answering one of these protocols reply. Omitted means any.
897
+ * Filtering at the responder is what bounds a gather in a large group — and it
898
+ * is what makes a future WebRTC query return the offer-capable subset for free.
899
+ */
900
+ protocols?: Array<string>;
901
+ /** How long to hold the window open. The gather always costs its full window unless `quorum` closes it. */
902
+ timeoutMs?: number;
903
+ /**
904
+ * Close a hub's window as soon as this many distinct devices have answered
905
+ * SUCCESSFULLY — a declined reply is dropped before it is counted, so a target
906
+ * that includes a device which cannot answer is a target that is never met.
907
+ * Counted per hub, then de-duplicated by DID across hubs afterwards.
908
+ *
909
+ * Omitted means every device this group's projection holds that advertises a
910
+ * matching capability, so a gather ends when the last one that could answer
911
+ * has. Pass a number to ask for less.
912
+ */
913
+ quorum?: number;
914
+ };
915
+ /**
916
+ * Why a presence announce/gather ran. Log-only: it names the trigger for a reader
917
+ * of the logs, and nothing branches on it.
918
+ */
919
+ export type PresenceReason = 'hub-connected' | 'epoch-changed' | 'app-window-pruned' | 'requested';
318
920
  export type SyncPluginAPI = {
319
921
  /** Resolves when the HTTP sync transport is registered (only present when http option is enabled). */
320
922
  syncReady?: Promise<void>;
321
923
  /**
322
- * Resolves when hub relay setup completes. `DISABLED_HUB.ready` is already
323
- * resolved when the `hub` plugin option is absent.
924
+ * Resolves when hub relay setup completes. The relay always runs — it is what
925
+ * gives each joined group its commit lane — so this resolves regardless of
926
+ * whether the `hub` plugin option is set.
324
927
  */
325
928
  hubReady: Promise<void>;
326
929
  addPeer(config: PeerConfig): Promise<void>;
@@ -334,7 +937,86 @@ export type SyncPluginAPI = {
334
937
  direction?: SyncDirection;
335
938
  }): Promise<SyncSessionResult>;
336
939
  getStatus(peerDID?: string): SyncStatus;
940
+ /**
941
+ * Catch up with the best candidate one group's discovery projection offers, over
942
+ * the local device's active-catalog scopes. See
943
+ * {@link SyncRequestContext.catchUpWithBestPeer} — the host decides when, this
944
+ * decides who, and an explicit `syncPeer` ignores the ranking entirely.
945
+ */
946
+ catchUpWithBestPeer(groupID: string): Promise<PeerCatchUpData>;
947
+ /**
948
+ * Every device this one has heard announce in the group — the projection, and
949
+ * never a liveness answer. {@link SyncPluginAPI.gatherPeers} is what says who
950
+ * is reachable now; a screen needs both, and the two are different questions.
951
+ */
952
+ listPeerDevices(groupID: string): Promise<Array<PeerDeviceData>>;
337
953
  onSyncEvent(callback: (event: SyncEvent) => void): () => void;
954
+ /**
955
+ * Subscribe to hub identity changes. A hub whose signing key rotated is
956
+ * reconnected to under its new DID without the application doing anything —
957
+ * this is how it learns that happened. Returns the unsubscribe.
958
+ */
959
+ onHubServerDIDChanged(listener: (data: HubServerDIDChangedData) => void): () => void;
338
960
  /** Create a sync-specific transport for direct in-process connections. */
339
961
  createSyncTransport(signal?: AbortSignal): ClientTransportOf<SyncProtocol>;
962
+ /**
963
+ * Ask the group's current members for the full control ledger and fold any
964
+ * missing entries locally. Backfills entries an invite snapshot missed (or a
965
+ * member missed while its peer was offline); every token self-authenticates,
966
+ * so the responders are untrusted and duplicates are idempotent no-ops. A
967
+ * no-op (all-zero summary) when no `hub` option is configured.
968
+ */
969
+ requestLedgerCatchup(groupID: string, options?: LedgerCatchupOptions): Promise<LedgerCatchupSummary>;
970
+ /**
971
+ * Declare what this device IS — its display label, its availability class and
972
+ * the capabilities it answers — and advertise it to every group it belongs to.
973
+ * THE one way to advertise: there is no per-group or bodies-only variant.
974
+ *
975
+ * This is the "local capability or label change" trigger: the host calls it when
976
+ * either changes, and every group hears the new shape without the host tracking
977
+ * which groups exist. The profile is durable, so every other trigger (a hub
978
+ * connect, an epoch rotation, a pruned app window) has something to announce
979
+ * after a restart. A device that never calls this advertises NOTHING — no label
980
+ * is invented for it, and it stays invisible to its co-members' discovery.
981
+ *
982
+ * Resolves once each group's frame is sealed and sent. A group with no live hub
983
+ * peer is a no-op rather than a throw, matching every other broadcast.
984
+ */
985
+ setLocalPeerProfile(profile: LocalPeerAnnouncement): Promise<void>;
986
+ /** What this device last declared itself to be, or `null` if it never has. */
987
+ getLocalPeerProfile(): Promise<LocalPeerAnnouncement | null>;
988
+ /**
989
+ * Ask a group's members who is reachable RIGHT NOW, optionally narrowed to
990
+ * devices answering named protocols, and fold every answer into the projection.
991
+ *
992
+ * THE RESULT IS "WHO ANSWERED WITHIN THE WINDOW", NEVER "WHO EXISTS". A device
993
+ * that was asleep is absent from it and keeps its projection row untouched;
994
+ * only a roster removal deletes a row. Surfacing this as a member list is how
995
+ * an empty result comes to read as "my laptop was removed from the group".
996
+ */
997
+ gatherPeers(groupID: string, options?: PeerGatherOptions): Promise<Array<GatheredPeer>>;
998
+ /**
999
+ * Announce this device and gather its co-members, in that order — what a host
1000
+ * calls when it comes to the foreground. The same pair the hub-connect and
1001
+ * pruned-window triggers run internally.
1002
+ */
1003
+ refreshPeerPresence(groupID: string, options?: PeerGatherOptions): Promise<Array<GatheredPeer>>;
1004
+ /**
1005
+ * Try this device's hubs now instead of waiting out the reconnect backoff, and
1006
+ * answer with whether any is connected afterwards.
1007
+ *
1008
+ * The backoff climbs to half a minute, which is the right cost for a device
1009
+ * nobody is watching and the wrong one for a person who has just started their
1010
+ * hub and is looking at the screen. Wire this to whatever that person presses.
1011
+ * `false` means the attempt was made and did not connect — the automatic
1012
+ * retries carry on either way.
1013
+ */
1014
+ retryHubConnection(): Promise<boolean>;
1015
+ /**
1016
+ * Look up the live in-memory connection to a peer, keyed by remote DID.
1017
+ * Present only after a successful `connectPeer`; the later dance ops
1018
+ * (`joinPeerGroup` / `sharePeerGroup`) dispatch their peer procedures over
1019
+ * this held connection.
1020
+ */
1021
+ getPeerConnection(peerDID: string): PeerConnection | undefined;
340
1022
  };