@kubun/plugin-p2p 0.15.1 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  import type { ProcedureHandlers } from '@enkaku/server';
2
- import type { DIDCache, OwnIdentity } from '@kokuin/token';
2
+ import { type DIDCache, type OwnIdentity } from '@kokuin/token';
3
3
  import type { StoreProvider } from '@kubun/db';
4
4
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
5
5
  import type { HLC } from '@kubun/hlc';
6
6
  import type { Logger } from '@kubun/logger';
7
+ import type { WorkflowDiscoverOptions, WorkflowDiscoverReply } from '@kubun/plugin-p2p-api';
7
8
  import type { ServiceConfig } from '@kubun/plugin-service-api';
8
9
  import type { GraphStoreAPI } from '@kubun/store-graph';
9
10
  import { type GroupPeer, type LaneResult, type PendingCommit } from '@kumiai/rpc';
@@ -20,6 +21,7 @@ import { type SettleLostControlRequestDeps } from './commit-adoption.js';
20
21
  import { type ControllerResolverFor } from './credential-wrapping-deps.js';
21
22
  import type { P2PEventEmitter } from './events.js';
22
23
  import type { GroupHandleRegistry } from './group-handle-registry.js';
24
+ import { type WorkflowCommandAPI } from './group-handlers.js';
23
25
  import { type GroupProtocols } from './group-protocols.js';
24
26
  import { type PeerPresence } from './peer-presence.js';
25
27
  /**
@@ -64,6 +66,22 @@ export type GroupPeerManagerParams = {
64
66
  graph: GraphInternals;
65
67
  /** Device-wide monotonic clock shared with the engine and MLS receive path. */
66
68
  hlc: HLC;
69
+ /**
70
+ * The engine's identity gate, forwarded to the presence coordinator so a
71
+ * self-started announce awaits verification before it mints under `hlc`.
72
+ * Absent for a by-hand test manager, whose ungated clock needs no wait.
73
+ */
74
+ ready?: () => Promise<void>;
75
+ /**
76
+ * Resolve the device's workflow API, if the workflow plugin is loaded. When it
77
+ * resolves non-null a `workflow` namespace is mounted on each hub peer so
78
+ * co-members can gather this device's remotely-observable instances; null
79
+ * leaves the peer's protocol map unchanged. Async because the engine resolves
80
+ * plugin APIs only after every factory has returned — a peer is created on
81
+ * `groupJoined`, well after that, so it is normally already resolved. Absent
82
+ * for a by-hand test manager, which mounts no workflow lane.
83
+ */
84
+ getWorkflowAPI?: () => Promise<WorkflowCommandAPI | undefined>;
67
85
  /**
68
86
  * The engine's future-drift bound, forwarded to the apply path. Optional: a
69
87
  * suite that builds a manager by hand falls back to the same default the
@@ -141,17 +159,8 @@ export type GroupPeerManagerParams = {
141
159
  /** @see TunnelListenersParams.idleTimeoutMs */
142
160
  tunnelIdleTimeoutMs?: number;
143
161
  };
144
- /**
145
- * Coordinates the per-(group, hub) `GroupPeer`s for one device full multi-hub.
146
- *
147
- * The manager owns one reconnecting `HubLike` per hub URL (created lazily on
148
- * first use, shared across every group bound to that hub via the multi-subscriber
149
- * adapter) and one `GroupPeer` per (group, hub) pair. A peer exists iff the group
150
- * is joined AND bound to that hub. Lifecycle mirrors the old `HubRelayManager`,
151
- * but per (group, hub) instead of single-hub: the wiring phase drives `addGroup`
152
- * / `removeGroup` / `addBinding` / `removeBinding` from the emitter and holds the
153
- * unsubscribes. The manager does not subscribe to the emitter itself.
154
- */
162
+ /** The directed (non-gather) `workflow/*` procedures {@link GroupPeerManager.requestWorkflow} carries. */
163
+ export type WorkflowRequestMethod = 'workflow/list' | 'workflow/status' | 'workflow/enqueue' | 'workflow/cancel' | 'workflow/retry';
155
164
  export type GroupPeerManager = {
156
165
  /** Bring up peers for each already-bound hub of each joined group. */
157
166
  start: (groupIDs: Array<string>) => Promise<void>;
@@ -174,6 +183,13 @@ export type GroupPeerManager = {
174
183
  reconcileTunnelListeners: (groupID: string) => Promise<void>;
175
184
  /** Fan a broadcast message out across every hub-peer of a group. */
176
185
  broadcast: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
186
+ /**
187
+ * Ask every hub-peer of the group to re-drive a subscription its hub refused — the recovery for a
188
+ * device whose group topics were subscribed on join, before its app-level authorization landed,
189
+ * and so were refused `AuthorizationDeniedError` and latched. Synchronous and idempotent (a no-op
190
+ * on a peer holding nothing refused); the app calls it once it knows the device is authorized.
191
+ */
192
+ reauthorize: (groupID: string) => void;
177
193
  /**
178
194
  * This device's presence on the peer lane: what it advertises, and who it asks.
179
195
  * The manager owns it because every internal trigger fires from here — a hub
@@ -181,6 +197,24 @@ export type GroupPeerManager = {
181
197
  * the group's live peers.
182
198
  */
183
199
  presence: PeerPresence;
200
+ /**
201
+ * Fan `workflow/discover` out across every hub-peer of the group and return one
202
+ * entry per DISTINCT responder, deduped by the authenticated `senderDID` the
203
+ * gather envelope carried (self dropped), each `{ senderDID, instances }`. The
204
+ * multi-hub transport behind the caller-facing control-plane API — a member
205
+ * reachable through two hubs answers on both and is counted once.
206
+ */
207
+ discoverWorkflows: (groupID: string, options?: WorkflowDiscoverOptions) => Promise<Array<WorkflowDiscoverReply>>;
208
+ /**
209
+ * Directed `workflow/*` request to one co-member over the group's hub:
210
+ * `.to(targetDID).request(method, { param })` on a bound hub-peer, retried
211
+ * across the remaining bindings only when a hub THROWS (an infra fault before
212
+ * anything is delivered) — never on a returned business refusal, which is the
213
+ * answer. Backs list/status/enqueue/cancel/retry; returns the raw wire result
214
+ * for the typed pluginAPI method to shape. Throws when the group has no live
215
+ * hub-peer, or rethrows the last hub's error.
216
+ */
217
+ requestWorkflow: (groupID: string, targetDID: string, method: WorkflowRequestMethod, param?: Record<string, unknown>) => Promise<unknown>;
184
218
  /**
185
219
  * The peer for the group's canonical commit hub — the single hub a group
186
220
  * commits through. Resolves the designated commit hub; falls back to the live
@@ -1,3 +1,4 @@
1
+ import { normalizeDID } from '@kokuin/token';
1
2
  import { CREDENTIAL_STORE, getCredentialStore } from '@kubun/store-credential';
2
3
  import { getDelegationStore } from '@kubun/store-delegation';
3
4
  import { getP2PStore } from '@kubun/store-p2p';
@@ -19,9 +20,9 @@ import { createCommitJournal } from './commit-journal.js';
19
20
  import { settleControlRequest } from './control-request.js';
20
21
  import { createCredentialWrappingDeps } from './credential-wrapping-deps.js';
21
22
  import { createGroupCrypto } from './group-crypto.js';
22
- import { buildGroupHandlers } from './group-handlers.js';
23
+ import { buildGroupHandlers, composeGroupHandlers } from './group-handlers.js';
23
24
  import { createGroupMLS } from './group-mls.js';
24
- import { groupProtocols } from './group-protocols.js';
25
+ import { composeGroupProtocols } from './group-protocols.js';
25
26
  import { ledgerEntryDigest } from './ledger.js';
26
27
  import { bootstrapGroupLedger } from './ledger-adopt.js';
27
28
  import { createPeerPresence } from './peer-presence.js';
@@ -37,20 +38,26 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
37
38
  switch(message.type){
38
39
  case 'delegation:share':
39
40
  await peer.protocol('control').dispatch('control/delegationShare', {
40
- token: message.token,
41
- hlc: message.hlc
41
+ data: {
42
+ token: message.token,
43
+ hlc: message.hlc
44
+ }
42
45
  });
43
46
  return;
44
47
  case 'delegation:revoke':
45
48
  await peer.protocol('control').dispatch('control/delegationRevoke', {
46
- token: message.token,
47
- hlc: message.hlc
49
+ data: {
50
+ token: message.token,
51
+ hlc: message.hlc
52
+ }
48
53
  });
49
54
  return;
50
55
  case 'group:leaveRequest':
51
56
  await peer.protocol('control').dispatch('control/groupLeaveRequest', {
52
- memberDID: message.memberDID,
53
- hlc: message.hlc
57
+ data: {
58
+ memberDID: message.memberDID,
59
+ hlc: message.hlc
60
+ }
54
61
  });
55
62
  return;
56
63
  case 'access-default:set':
@@ -58,26 +65,32 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
58
65
  // just its author, so a payload that drops or renames one produces a frame
59
66
  // the receiver verifies and correctly rejects.
60
67
  await peer.protocol('control').dispatch('control/accessDefaultSet', {
61
- modelID: message.modelID,
62
- permissionType: message.permissionType,
63
- rule: message.rule,
64
- ownerDID: message.ownerDID,
65
- hlc: message.hlc,
66
- auth: message.auth
68
+ data: {
69
+ modelID: message.modelID,
70
+ permissionType: message.permissionType,
71
+ rule: message.rule,
72
+ ownerDID: message.ownerDID,
73
+ hlc: message.hlc,
74
+ auth: message.auth
75
+ }
67
76
  });
68
77
  return;
69
78
  case 'access-default:remove':
70
79
  await peer.protocol('control').dispatch('control/accessDefaultRemove', {
71
- modelID: message.modelID,
72
- permissionTypes: message.permissionTypes,
73
- ownerDID: message.ownerDID,
74
- hlc: message.hlc,
75
- auth: message.auth
80
+ data: {
81
+ modelID: message.modelID,
82
+ permissionTypes: message.permissionTypes,
83
+ ownerDID: message.ownerDID,
84
+ hlc: message.hlc,
85
+ auth: message.auth
86
+ }
76
87
  });
77
88
  return;
78
89
  case 'mutation:apply':
79
90
  await peer.protocol('sync').dispatch('sync/mutationApply', {
80
- entries: message.entries
91
+ data: {
92
+ entries: message.entries
93
+ }
81
94
  });
82
95
  return;
83
96
  case 'peer:announce':
@@ -85,10 +98,12 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
85
98
  // lane resolved the sender to; putting it on the wire would create the
86
99
  // self-asserted DID field the whole design exists to avoid.
87
100
  await peer.protocol('peer').dispatch('peer/announce', {
88
- label: message.label,
89
- availability: message.availability,
90
- capabilities: message.capabilities,
91
- hlc: message.hlc
101
+ data: {
102
+ label: message.label,
103
+ availability: message.availability,
104
+ capabilities: message.capabilities,
105
+ hlc: message.hlc
106
+ }
92
107
  });
93
108
  return;
94
109
  case 'catalog:create':
@@ -104,23 +119,27 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
104
119
  // payload that dropped or rewrote one produces a frame the receiver
105
120
  // verifies and correctly rejects.
106
121
  await peer.protocol('control').dispatch('control/credentialKeyGrant', {
107
- keyID: message.keyID,
108
- keyVersion: message.keyVersion,
109
- suite: message.suite,
110
- ownerDID: message.ownerDID,
111
- keyOp: message.keyOp,
112
- keyBranches: message.keyBranches,
113
- wrapping: message.wrapping,
114
- entries: message.entries,
115
- auth: message.auth
122
+ data: {
123
+ keyID: message.keyID,
124
+ keyVersion: message.keyVersion,
125
+ suite: message.suite,
126
+ ownerDID: message.ownerDID,
127
+ keyOp: message.keyOp,
128
+ keyBranches: message.keyBranches,
129
+ wrapping: message.wrapping,
130
+ entries: message.entries,
131
+ auth: message.auth
132
+ }
116
133
  });
117
134
  return;
118
135
  case 'credential:key-manifest':
119
136
  // Both fields ride verbatim -- the token digests its own fields, so
120
137
  // reshaping would break signature verification.
121
138
  await peer.protocol('control').dispatch('control/credentialKeyManifest', {
122
- manifest: message.manifest,
123
- delegationTokens: message.delegationTokens
139
+ data: {
140
+ manifest: message.manifest,
141
+ delegationTokens: message.delegationTokens
142
+ }
124
143
  });
125
144
  return;
126
145
  default:
@@ -137,15 +156,14 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
137
156
  * True while a rotation has torn the per-epoch protocol runtimes down and not yet
138
157
  * rebuilt them.
139
158
  *
140
- * `createGroupPeer.rebuildEpoch` clears its runtime map synchronously and repopulates
141
- * it after awaiting every teardown, and `dispatch` takes no mutex — upstream says so
142
- * where it re-reads the anchor around a seal. So a broadcast that arrives inside that
143
- * window reaches `surfaceFor` with an empty map and gets `Unknown protocol: <name>`,
144
- * which is a transient rotation artefact and not a routing mistake. It is matched on
145
- * the message because the throw is a bare `Error`.
159
+ * `createGroupPeer.rebuildEpoch` clears its runtime map synchronously and
160
+ * repopulates it after awaiting every teardown, and `dispatch` takes no mutex. So
161
+ * a broadcast arriving inside that window reaches `surfaceFor` with an empty map
162
+ * and gets `Unknown protocol: <name>` a transient rotation artefact, not a
163
+ * routing mistake. Matched on the message because the throw is a bare `Error`.
146
164
  *
147
- * Announce-on-epoch-change is exactly the trigger that publishes at a rotation, so
148
- * this window is on its normal path rather than at its edge.
165
+ * Announce-on-epoch-change publishes exactly at a rotation, so this window is on
166
+ * its normal path rather than at its edge.
149
167
  */ function isEpochRebuilding(error) {
150
168
  const message = error instanceof Error ? error.message : String(error);
151
169
  return message.startsWith('Unknown protocol: ');
@@ -580,7 +598,7 @@ export function createGroupPeerManager(params) {
580
598
  // (createGroupPeer restores the anchored MLS handle + opens the receive drain
581
599
  // in its constructor), so this must only run once the group's MLS state is
582
600
  // seeded (i.e. after `groupJoined`).
583
- const instantiatePeer = ({ groupID, hub, processParams, initialEpoch })=>createGroupPeer({
601
+ const instantiatePeer = ({ groupID, hub, processParams, initialEpoch, workflowAPI })=>createGroupPeer({
584
602
  hub,
585
603
  crypto: createGroupCrypto({
586
604
  registry: params.registry,
@@ -626,17 +644,17 @@ export function createGroupPeerManager(params) {
626
644
  } : {}
627
645
  }),
628
646
  localDID: params.localDID,
629
- protocols: groupProtocols,
630
- handlers: buildGroupHandlers(processParams, groupID),
647
+ // The workflow lane is mounted only when this device runs the workflow
648
+ // plugin — composed into FRESH maps, never mutating the shared statics.
649
+ protocols: composeGroupProtocols(workflowAPI != null),
650
+ handlers: composeGroupHandlers(buildGroupHandlers(processParams, groupID), workflowAPI),
631
651
  // App frames published while this device was away aged out of the hub's
632
- // retention before it came back for them. The frames themselves are gone
633
- // for good — nothing here replays them.
652
+ // retention before it came back — gone for good, nothing here replays them.
634
653
  //
635
- // TRIGGER — a gap below the retention floor. It is not an error: what the
636
- // gap means is "someone may have said something I will never read", and the
637
- // repair for the peer lane is to re-announce and re-ask rather than to
638
- // reconstruct the lost frames. An announcement is idempotent state, so a
639
- // fresh one supersedes every lost one.
654
+ // TRIGGER — a gap below the retention floor. Not an error: it means "someone
655
+ // may have said something I will never read", and the peer lane's repair is
656
+ // to re-announce and re-ask, not reconstruct the lost frames. An
657
+ // announcement is idempotent, so a fresh one supersedes every lost one.
640
658
  onAppWindowPruned: (event)=>{
641
659
  logger?.warn('app frames aged out below the hub retention floor', {
642
660
  groupID: event.groupID,
@@ -661,7 +679,14 @@ export function createGroupPeerManager(params) {
661
679
  if (peers.has(key)) {
662
680
  return;
663
681
  }
664
- const processParams = await buildProcessParams();
682
+ // Resolved in parallel with the store params: the engine memoizes the API,
683
+ // and `createPeer` runs on `groupJoined` — well after the engine's plugin
684
+ // gate closes — so this resolves without blocking. The re-checks below cover
685
+ // this suspension point exactly as they cover `buildProcessParams`.
686
+ const [processParams, workflowAPI] = await Promise.all([
687
+ buildProcessParams(),
688
+ params.getWorkflowAPI?.() ?? Promise.resolve(undefined)
689
+ ]);
665
690
  // Re-check after the await: a concurrent call may have installed this peer,
666
691
  // or `removeGroup` may have left the group, while the store resolved. The
667
692
  // emitter handlers (groupJoined/groupLeft/hubBound/hubUnbound) are not
@@ -680,7 +705,8 @@ export function createGroupPeerManager(params) {
680
705
  groupID,
681
706
  hub: getHubLike(hubURL),
682
707
  processParams,
683
- initialEpoch
708
+ initialEpoch,
709
+ workflowAPI
684
710
  });
685
711
  peers.set(key, peer);
686
712
  let groupSet = bindings.get(groupID);
@@ -836,6 +862,9 @@ export function createGroupPeerManager(params) {
836
862
  stores: params.stores,
837
863
  localDID: params.localDID,
838
864
  hlc: params.hlc,
865
+ ...params.ready != null ? {
866
+ ready: params.ready
867
+ } : {},
839
868
  // Declaring a profile is what puts this device in co-members' projections,
840
869
  // so it is also what makes it dialable — the listeners come up here rather
841
870
  // than waiting for the next epoch.
@@ -880,7 +909,10 @@ export function createGroupPeerManager(params) {
880
909
  return [];
881
910
  }
882
911
  try {
883
- return await throughRotation(()=>peer.protocol('peer').gather('peer/query', param, gatherOptions), 'gather peer/query', logger);
912
+ return await throughRotation(()=>peer.protocol('peer').gather('peer/query', {
913
+ param,
914
+ ...gatherOptions
915
+ }), 'gather peer/query', logger);
884
916
  } catch (error) {
885
917
  logger?.warn('peer query gather failed on a hub', {
886
918
  groupID,
@@ -1061,7 +1093,9 @@ export function createGroupPeerManager(params) {
1061
1093
  if (commitPeer == null) {
1062
1094
  return 0;
1063
1095
  }
1064
- const replies = await commitPeer.protocol('control').gather('control/policyCatchup', {});
1096
+ const replies = await commitPeer.protocol('control').gather('control/policyCatchup', {
1097
+ param: {}
1098
+ });
1065
1099
  // Every reply is applied, unlike the ledger's: a member restates only its
1066
1100
  // own rules, so two replies are two owners' policies rather than two
1067
1101
  // accounts of one thing. Nothing here chooses between them — each token is
@@ -1120,6 +1154,103 @@ export function createGroupPeerManager(params) {
1120
1154
  },
1121
1155
  addGroup,
1122
1156
  presence,
1157
+ async discoverWorkflows (groupID, options) {
1158
+ const groupSet = bindings.get(groupID);
1159
+ if (groupSet == null || groupSet.size === 0) {
1160
+ return [];
1161
+ }
1162
+ // Bound the fan-out like `peer/query`: absent options, the gather runs its
1163
+ // default window; a caller passing a member-count `quorum` returns as soon
1164
+ // as everyone answers rather than waiting it out.
1165
+ const gatherOptions = {
1166
+ ...options?.timeoutMs != null ? {
1167
+ timeoutMs: options.timeoutMs
1168
+ } : {},
1169
+ ...options?.quorum != null ? {
1170
+ quorum: options.quorum
1171
+ } : {}
1172
+ };
1173
+ // Fan out over every hub-peer concurrently, exactly as `presence.query`
1174
+ // does — a dark hub burns only its own window, not the next one's.
1175
+ const gathered = await Promise.all(Array.from(groupSet, async (hubURL)=>{
1176
+ const peer = peers.get(peerKey(groupID, hubURL));
1177
+ if (peer == null) {
1178
+ return [];
1179
+ }
1180
+ try {
1181
+ return await throughRotation(()=>peer.protocol('workflow').gather('workflow/discover', {
1182
+ param: {},
1183
+ ...gatherOptions
1184
+ }), 'gather workflow/discover', logger);
1185
+ } catch (error) {
1186
+ logger?.warn('workflow discover gather failed on a hub', {
1187
+ groupID,
1188
+ hubURL,
1189
+ error
1190
+ });
1191
+ return [];
1192
+ }
1193
+ }));
1194
+ // Attributed by the authenticated envelope, deduped by DID, self dropped —
1195
+ // the same rule `peer-presence` applies to a `peer/query` reply. A member
1196
+ // reachable via two hubs answers on both; the first wins, the rest are
1197
+ // dropped rather than counted twice.
1198
+ const selfDID = normalizeDID(params.localDID);
1199
+ const seen = new Set();
1200
+ const replies = [];
1201
+ for (const reply of gathered.flat()){
1202
+ const senderDID = normalizeDID(reply.senderDID);
1203
+ if (senderDID === selfDID || seen.has(senderDID)) {
1204
+ continue;
1205
+ }
1206
+ seen.add(senderDID);
1207
+ // The wire result is a bare `InstanceStatus[]`; the body names no device.
1208
+ replies.push({
1209
+ senderDID,
1210
+ instances: reply.value ?? []
1211
+ });
1212
+ }
1213
+ return replies;
1214
+ },
1215
+ async requestWorkflow (groupID, targetDID, method, param) {
1216
+ const groupSet = bindings.get(groupID);
1217
+ if (groupSet == null || groupSet.size === 0) {
1218
+ throw new Error(`No hub-peer to reach ${targetDID} for group ${groupID}`);
1219
+ }
1220
+ // First bound hub wins; the rest are fallthrough routes to the SAME
1221
+ // mailbox, tried only when a hub throws before delivering. A returned
1222
+ // refusal is the answer and stops here — it is never a reason to re-send,
1223
+ // which for a mutating command would risk a second execution.
1224
+ let lastError;
1225
+ for (const hubURL of groupSet){
1226
+ const peer = peers.get(peerKey(groupID, hubURL));
1227
+ if (peer == null) {
1228
+ continue;
1229
+ }
1230
+ try {
1231
+ return await throughRotation(async ()=>{
1232
+ // Generic invoker: the enkaku client types `request`'s arg per
1233
+ // method, but this carries any of the five directed procedures, so
1234
+ // it is reached through a loose surface and the typed pluginAPI
1235
+ // method shapes the result. The `{ param }` wrapping is the enkaku
1236
+ // request-arg envelope, as `.to(...).request(m, { param })` elsewhere.
1237
+ const client = await peer.protocol('workflow').to(targetDID);
1238
+ return await client.request(method, {
1239
+ param
1240
+ });
1241
+ }, `request ${method}`, logger);
1242
+ } catch (error) {
1243
+ lastError = error;
1244
+ logger?.warn('workflow request failed on a hub', {
1245
+ groupID,
1246
+ hubURL,
1247
+ method,
1248
+ error
1249
+ });
1250
+ }
1251
+ }
1252
+ throw lastError ?? new Error(`No hub-peer to reach ${targetDID} for group ${groupID}`);
1253
+ },
1123
1254
  async removeGroup (groupID) {
1124
1255
  joined.delete(groupID);
1125
1256
  await tunnelListeners?.removeGroup(groupID);
@@ -1166,6 +1297,23 @@ export function createGroupPeerManager(params) {
1166
1297
  await serviceTunnelListeners?.reconcile(groupID);
1167
1298
  },
1168
1299
  broadcast: broadcastToPeers,
1300
+ reauthorize (groupID) {
1301
+ // Ask every hub-peer of the group to re-drive any subscription its hub refused. The one case
1302
+ // this exists for: a device subscribes its group topics the instant it joins (`addGroup` ->
1303
+ // `createPeer`), before the APP-level authorization that gates them at the hub has landed, so
1304
+ // the hub answers `AuthorizationDeniedError` and the peer's mux latches it permanent (a busy
1305
+ // retry against an answer would be worse). The app cannot un-latch it by re-joining — the
1306
+ // roster is invisible to this layer — so once the app knows the device is authorized (its
1307
+ // membership row has replicated) it calls this. `GroupPeer.reauthorize()` is synchronous and
1308
+ // idempotent, and a no-op on a peer holding nothing refused, so this just fans it over the
1309
+ // live hub-peers with no error surface of its own. The loopback peer never subscribes a hub,
1310
+ // so it is deliberately skipped.
1311
+ const groupSet = bindings.get(groupID);
1312
+ if (groupSet == null) return;
1313
+ for (const hubURL of groupSet){
1314
+ peers.get(peerKey(groupID, hubURL))?.reauthorize();
1315
+ }
1316
+ },
1169
1317
  async selectCommitPeer (groupID) {
1170
1318
  const groupSet = bindings.get(groupID);
1171
1319
  if (groupSet == null || groupSet.size === 0) {
@@ -1312,7 +1460,10 @@ export function createGroupPeerManager(params) {
1312
1460
  return [];
1313
1461
  }
1314
1462
  try {
1315
- return await peer.protocol('control').gather('control/ledgerCatchup', {}, gatherOptions);
1463
+ return await peer.protocol('control').gather('control/ledgerCatchup', {
1464
+ param: {},
1465
+ ...gatherOptions
1466
+ });
1316
1467
  } catch (error) {
1317
1468
  logger?.warn('ledger catch-up gather failed on a hub', {
1318
1469
  groupID,