@kubun/plugin-p2p 0.13.1 → 0.15.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.
- package/lib/context/peer.js +250 -3
- package/lib/context/sync.js +134 -25
- package/lib/context/types.d.ts +7 -0
- package/lib/groups/broadcast-message.d.ts +29 -0
- package/lib/groups/broadcast.d.ts +11 -1
- package/lib/groups/broadcast.js +44 -2
- package/lib/groups/credential-apply.d.ts +64 -2
- package/lib/groups/credential-apply.js +215 -30
- package/lib/groups/credential-grant.d.ts +22 -0
- package/lib/groups/credential-grant.js +76 -2
- package/lib/groups/credential-manifest-token.d.ts +31 -0
- package/lib/groups/credential-manifest-token.js +49 -0
- package/lib/groups/credential-readiness.d.ts +69 -0
- package/lib/groups/credential-readiness.js +172 -0
- package/lib/groups/credential-wrapping-deps.d.ts +23 -0
- package/lib/groups/credential-wrapping-deps.js +25 -0
- package/lib/groups/grantor-authority.d.ts +65 -0
- package/lib/groups/grantor-authority.js +107 -0
- package/lib/groups/group-handlers.js +7 -0
- package/lib/groups/group-peer-manager.d.ts +25 -0
- package/lib/groups/group-peer-manager.js +71 -0
- package/lib/groups/group-protocols.d.ts +47 -0
- package/lib/groups/group-protocols.js +28 -0
- package/lib/hub/wiring.d.ts +24 -0
- package/lib/hub/wiring.js +17 -1
- package/lib/index.d.ts +14 -0
- package/lib/index.js +157 -6
- package/lib/peer/blob-fetch.d.ts +2 -18
- package/lib/protocol.d.ts +30 -0
- package/lib/protocol.js +36 -0
- package/lib/schema.d.ts +16 -1
- package/lib/schema.js +113 -4
- package/lib/sync/group-sync-workflow.d.ts +77 -0
- package/lib/sync/group-sync-workflow.js +96 -0
- package/lib/sync/handlers.js +21 -2
- package/lib/sync/held-delegations.d.ts +14 -0
- package/lib/sync/held-delegations.js +34 -0
- package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
- package/lib/sync/hub-tunnel-service-listener.js +289 -0
- package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
- package/lib/sync/hub-tunnel-service-provider.js +100 -0
- package/lib/sync/service-tunnel-listeners.d.ts +35 -0
- package/lib/sync/service-tunnel-listeners.js +165 -0
- package/lib/sync/sync-manager.d.ts +7 -0
- package/lib/sync/sync-manager.js +4 -1
- package/lib/sync/tunnel-topics.d.ts +19 -1
- package/lib/sync/tunnel-topics.js +7 -3
- package/lib/types.d.ts +182 -7
- package/lib/util/handler-error.d.ts +8 -5
- package/lib/util/handler-error.js +10 -23
- package/package.json +51 -46
|
@@ -6,6 +6,8 @@ import { createGroupPeer, RecoveryRequiredError } from '@kumiai/rpc';
|
|
|
6
6
|
import { createHubLike } from '../hub/hub-like.js';
|
|
7
7
|
import { createLoopbackLogHub } from '../hub/loopback-log-hub.js';
|
|
8
8
|
import { createHubServerDIDResolver } from '../hub/server-did.js';
|
|
9
|
+
import { HubTunnelServiceProvider } from '../sync/hub-tunnel-service-provider.js';
|
|
10
|
+
import { createServiceTunnelListeners } from '../sync/service-tunnel-listeners.js';
|
|
9
11
|
import { createTunnelListeners } from '../sync/tunnel-listeners.js';
|
|
10
12
|
import { applyAccessDefaultSetToken } from './access-default-apply.js';
|
|
11
13
|
import { createAnchorStore } from './anchor-store.js';
|
|
@@ -15,6 +17,7 @@ import { reprojectGroupSettings } from './circle-projection.js';
|
|
|
15
17
|
import { adoptCommitJournalBlob, readJournalRequestID, settleLostControlRequest } from './commit-adoption.js';
|
|
16
18
|
import { createCommitJournal } from './commit-journal.js';
|
|
17
19
|
import { settleControlRequest } from './control-request.js';
|
|
20
|
+
import { createCredentialWrappingDeps } from './credential-wrapping-deps.js';
|
|
18
21
|
import { createGroupCrypto } from './group-crypto.js';
|
|
19
22
|
import { buildGroupHandlers } from './group-handlers.js';
|
|
20
23
|
import { createGroupMLS } from './group-mls.js';
|
|
@@ -112,6 +115,14 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
|
|
|
112
115
|
auth: message.auth
|
|
113
116
|
});
|
|
114
117
|
return;
|
|
118
|
+
case 'credential:key-manifest':
|
|
119
|
+
// Both fields ride verbatim -- the token digests its own fields, so
|
|
120
|
+
// reshaping would break signature verification.
|
|
121
|
+
await peer.protocol('control').dispatch('control/credentialKeyManifest', {
|
|
122
|
+
manifest: message.manifest,
|
|
123
|
+
delegationTokens: message.delegationTokens
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
115
126
|
default:
|
|
116
127
|
{
|
|
117
128
|
// Exhaustiveness gate. A broadcast type with no case here used to fall off
|
|
@@ -466,12 +477,19 @@ export function createGroupPeerManager(params) {
|
|
|
466
477
|
// `hasStore` because `getStore` on an unregistered name throws, and a device
|
|
467
478
|
// with no credential plugin must still bind its peers.
|
|
468
479
|
const credentialStore = params.stores.hasStore(CREDENTIAL_STORE) ? await getCredentialStore(params.stores) : undefined;
|
|
480
|
+
// Delegate-wrapping admission deps, sourced from the engine's controller
|
|
481
|
+
// resolver. Absent seam → empty deps → fail-closed delegated wrapping.
|
|
482
|
+
const credentialWrappingDeps = await createCredentialWrappingDeps(params.stores, params.controllerResolverFor);
|
|
469
483
|
return {
|
|
470
484
|
p2pStore,
|
|
471
485
|
delegationStore,
|
|
472
486
|
...credentialStore != null ? {
|
|
473
487
|
credentialStore
|
|
474
488
|
} : {},
|
|
489
|
+
...credentialWrappingDeps.controllerResolver != null && credentialWrappingDeps.revocationChecker != null ? {
|
|
490
|
+
credentialControllerResolver: credentialWrappingDeps.controllerResolver,
|
|
491
|
+
credentialRevocationChecker: credentialWrappingDeps.revocationChecker
|
|
492
|
+
} : {},
|
|
475
493
|
graphStore: params.graphStore,
|
|
476
494
|
graph: params.graph,
|
|
477
495
|
selfDID: params.localDID,
|
|
@@ -811,6 +829,9 @@ export function createGroupPeerManager(params) {
|
|
|
811
829
|
// Declared ahead of the presence it is triggered from, and assigned below once
|
|
812
830
|
// the hub lookup it needs exists.
|
|
813
831
|
let tunnelListeners;
|
|
832
|
+
// Same shape, service lane — see the assignment below. Reconciled on every
|
|
833
|
+
// trigger `tunnelListeners` is, and disposed alongside it in `stop`.
|
|
834
|
+
let serviceTunnelListeners;
|
|
814
835
|
const presence = createPeerPresence({
|
|
815
836
|
stores: params.stores,
|
|
816
837
|
localDID: params.localDID,
|
|
@@ -821,6 +842,7 @@ export function createGroupPeerManager(params) {
|
|
|
821
842
|
onProfileSet: ()=>{
|
|
822
843
|
for (const groupID of joined){
|
|
823
844
|
void tunnelListeners?.reconcile(groupID);
|
|
845
|
+
void serviceTunnelListeners?.reconcile(groupID);
|
|
824
846
|
}
|
|
825
847
|
},
|
|
826
848
|
getGroupEpoch: (groupID)=>params.registry.groupEpoch(groupID) ?? undefined,
|
|
@@ -902,6 +924,25 @@ export function createGroupPeerManager(params) {
|
|
|
902
924
|
idleTimeoutMs: params.tunnelIdleTimeoutMs
|
|
903
925
|
} : {}
|
|
904
926
|
});
|
|
927
|
+
serviceTunnelListeners = params.serviceServe == null || params.services == null || tunnelRuntime == null ? undefined : createServiceTunnelListeners({
|
|
928
|
+
stores: params.stores,
|
|
929
|
+
registry: params.registry,
|
|
930
|
+
identity: params.identity,
|
|
931
|
+
localDID: params.localDID,
|
|
932
|
+
runtime: tunnelRuntime,
|
|
933
|
+
serve: params.serviceServe,
|
|
934
|
+
services: params.services,
|
|
935
|
+
tunnelHub: (groupID)=>{
|
|
936
|
+
const hubURL = bindings.get(groupID)?.values().next().value;
|
|
937
|
+
return hubURL == null ? undefined : getHubLike(hubURL);
|
|
938
|
+
},
|
|
939
|
+
...logger != null ? {
|
|
940
|
+
logger
|
|
941
|
+
} : {},
|
|
942
|
+
...params.tunnelIdleTimeoutMs != null ? {
|
|
943
|
+
idleTimeoutMs: params.tunnelIdleTimeoutMs
|
|
944
|
+
} : {}
|
|
945
|
+
});
|
|
905
946
|
// TRIGGER — epoch change. A member added at this epoch was never on the old
|
|
906
947
|
// topic, so every earlier announcement is unreachable to it: the rotation is
|
|
907
948
|
// exactly when the group needs to be told again who is here.
|
|
@@ -918,6 +959,7 @@ export function createGroupPeerManager(params) {
|
|
|
918
959
|
triggerUnsubscribes.push(params.registry.onEpochChanged((groupID)=>{
|
|
919
960
|
presence.scheduleAnnounce(groupID, 'epoch-changed');
|
|
920
961
|
void tunnelListeners?.reconcile(groupID);
|
|
962
|
+
void serviceTunnelListeners?.reconcile(groupID);
|
|
921
963
|
}));
|
|
922
964
|
const addGroup = async (groupID)=>{
|
|
923
965
|
joined.add(groupID);
|
|
@@ -940,6 +982,7 @@ export function createGroupPeerManager(params) {
|
|
|
940
982
|
});
|
|
941
983
|
// After the peers, so the binding the listeners read exists.
|
|
942
984
|
await tunnelListeners?.reconcile(groupID);
|
|
985
|
+
await serviceTunnelListeners?.reconcile(groupID);
|
|
943
986
|
};
|
|
944
987
|
/** Groups with a catch-up round already open, so the triggers cannot stack. */ const policyCatchupInFlight = new Set();
|
|
945
988
|
/** Groups already asked once on connect — see {@link schedulePolicyCatchup}. */ const policyCatchupAsked = new Set();
|
|
@@ -1074,6 +1117,7 @@ export function createGroupPeerManager(params) {
|
|
|
1074
1117
|
async removeGroup (groupID) {
|
|
1075
1118
|
joined.delete(groupID);
|
|
1076
1119
|
await tunnelListeners?.removeGroup(groupID);
|
|
1120
|
+
await serviceTunnelListeners?.removeGroup(groupID);
|
|
1077
1121
|
const groupSet = bindings.get(groupID);
|
|
1078
1122
|
const urls = groupSet != null ? [
|
|
1079
1123
|
...groupSet
|
|
@@ -1085,6 +1129,7 @@ export function createGroupPeerManager(params) {
|
|
|
1085
1129
|
},
|
|
1086
1130
|
async reconcileTunnelListeners (groupID) {
|
|
1087
1131
|
await tunnelListeners?.reconcile(groupID);
|
|
1132
|
+
await serviceTunnelListeners?.reconcile(groupID);
|
|
1088
1133
|
},
|
|
1089
1134
|
async addBinding (groupID, hubURL) {
|
|
1090
1135
|
if (!joined.has(groupID)) {
|
|
@@ -1105,12 +1150,14 @@ export function createGroupPeerManager(params) {
|
|
|
1105
1150
|
// A group with no hub could not be dialled at all, so this is where its
|
|
1106
1151
|
// members become answerable.
|
|
1107
1152
|
await tunnelListeners?.reconcile(groupID);
|
|
1153
|
+
await serviceTunnelListeners?.reconcile(groupID);
|
|
1108
1154
|
},
|
|
1109
1155
|
async removeBinding (groupID, hubURL) {
|
|
1110
1156
|
await disposePeer(groupID, hubURL);
|
|
1111
1157
|
// The listeners hold the drain of whichever hub is bound; the one they were
|
|
1112
1158
|
// built on may be the one that just left.
|
|
1113
1159
|
await tunnelListeners?.reconcile(groupID);
|
|
1160
|
+
await serviceTunnelListeners?.reconcile(groupID);
|
|
1114
1161
|
},
|
|
1115
1162
|
broadcast: broadcastToPeers,
|
|
1116
1163
|
async selectCommitPeer (groupID) {
|
|
@@ -1331,6 +1378,29 @@ export function createGroupPeerManager(params) {
|
|
|
1331
1378
|
const hubURL = bindings.get(groupID)?.values().next().value;
|
|
1332
1379
|
return hubURL == null ? undefined : getHubLike(hubURL);
|
|
1333
1380
|
},
|
|
1381
|
+
async serviceTransportTo (groupID, peerDID) {
|
|
1382
|
+
const hub = this.tunnelHub(groupID);
|
|
1383
|
+
// `tunnelRuntime` mirrors `params.runtime`, which is optional here (a test
|
|
1384
|
+
// harness may build a manager without one) — a caller with no runtime has
|
|
1385
|
+
// no id generator to hand the transport, so there is no session to build.
|
|
1386
|
+
if (hub == null || tunnelRuntime == null) {
|
|
1387
|
+
return undefined;
|
|
1388
|
+
}
|
|
1389
|
+
return new HubTunnelServiceProvider({
|
|
1390
|
+
hub,
|
|
1391
|
+
registry: params.registry,
|
|
1392
|
+
groupID,
|
|
1393
|
+
localDID: params.localDID,
|
|
1394
|
+
peerDID,
|
|
1395
|
+
runtime: tunnelRuntime,
|
|
1396
|
+
...params.tunnelIdleTimeoutMs != null ? {
|
|
1397
|
+
idleTimeoutMs: params.tunnelIdleTimeoutMs
|
|
1398
|
+
} : {},
|
|
1399
|
+
...logger != null ? {
|
|
1400
|
+
logger: logger.getChild('service-tunnel-session')
|
|
1401
|
+
} : {}
|
|
1402
|
+
});
|
|
1403
|
+
},
|
|
1334
1404
|
async retryHubs () {
|
|
1335
1405
|
const live = [
|
|
1336
1406
|
...hubLikes.values()
|
|
@@ -1348,6 +1418,7 @@ export function createGroupPeerManager(params) {
|
|
|
1348
1418
|
// Before the hubs go: each listener holds a subscription on one of them,
|
|
1349
1419
|
// and its spawn loop re-arms until it is told to stop.
|
|
1350
1420
|
await tunnelListeners?.dispose();
|
|
1421
|
+
await serviceTunnelListeners?.dispose();
|
|
1351
1422
|
for (const off of triggerUnsubscribes.splice(0)){
|
|
1352
1423
|
try {
|
|
1353
1424
|
off();
|
|
@@ -310,6 +310,29 @@ export declare const controlProtocol: {
|
|
|
310
310
|
readonly additionalProperties: false;
|
|
311
311
|
};
|
|
312
312
|
};
|
|
313
|
+
readonly 'control/credentialKeyManifest': {
|
|
314
|
+
readonly type: "event";
|
|
315
|
+
readonly retain: "log";
|
|
316
|
+
readonly description: "A grantor's advisory record of the key set it granted a recipient (the readiness union). `manifest` is the grantor's signed token; `delegationTokens` proves its administer chain over the owner. Group-wide because the receiver keys the row on the manifest's own owner/recipient/grantor. The manifest authorizes nothing — the receiver's gate keeps a bogus one out of the store.";
|
|
317
|
+
readonly data: {
|
|
318
|
+
readonly type: "object";
|
|
319
|
+
readonly properties: {
|
|
320
|
+
readonly manifest: {
|
|
321
|
+
readonly type: "string";
|
|
322
|
+
readonly description: "The grantor's signed manifest token; its issuer is the authoritative grantor.";
|
|
323
|
+
};
|
|
324
|
+
readonly delegationTokens: {
|
|
325
|
+
readonly type: "array";
|
|
326
|
+
readonly items: {
|
|
327
|
+
readonly type: "string";
|
|
328
|
+
};
|
|
329
|
+
readonly description: "The grantor's administer-chain delegation tokens, closest-to-grantor first. Empty when the grantor is the owner.";
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
readonly required: readonly ["manifest", "delegationTokens"];
|
|
333
|
+
readonly additionalProperties: false;
|
|
334
|
+
};
|
|
335
|
+
};
|
|
313
336
|
readonly 'control/groupLeaveRequest': {
|
|
314
337
|
readonly type: "event";
|
|
315
338
|
readonly retain: "ephemeral";
|
|
@@ -494,6 +517,7 @@ export type DelegationRevokeData = FromSchema<(typeof controlProtocol)['control/
|
|
|
494
517
|
export type AccessDefaultSetData = FromSchema<(typeof controlProtocol)['control/accessDefaultSet']['data']>;
|
|
495
518
|
export type AccessDefaultRemoveData = FromSchema<(typeof controlProtocol)['control/accessDefaultRemove']['data']>;
|
|
496
519
|
export type CredentialKeyGrantData = FromSchema<(typeof controlProtocol)['control/credentialKeyGrant']['data']>;
|
|
520
|
+
export type CredentialKeyManifestData = FromSchema<(typeof controlProtocol)['control/credentialKeyManifest']['data']>;
|
|
497
521
|
export type GroupLeaveRequestData = FromSchema<(typeof controlProtocol)['control/groupLeaveRequest']['data']>;
|
|
498
522
|
export type MutationApplyData = FromSchema<(typeof syncProtocol)['sync/mutationApply']['data']>;
|
|
499
523
|
export type PeerAnnounceData = FromSchema<(typeof peerProtocol)['peer/announce']['data']>;
|
|
@@ -806,6 +830,29 @@ export declare const groupProtocols: {
|
|
|
806
830
|
readonly additionalProperties: false;
|
|
807
831
|
};
|
|
808
832
|
};
|
|
833
|
+
readonly 'control/credentialKeyManifest': {
|
|
834
|
+
readonly type: "event";
|
|
835
|
+
readonly retain: "log";
|
|
836
|
+
readonly description: "A grantor's advisory record of the key set it granted a recipient (the readiness union). `manifest` is the grantor's signed token; `delegationTokens` proves its administer chain over the owner. Group-wide because the receiver keys the row on the manifest's own owner/recipient/grantor. The manifest authorizes nothing — the receiver's gate keeps a bogus one out of the store.";
|
|
837
|
+
readonly data: {
|
|
838
|
+
readonly type: "object";
|
|
839
|
+
readonly properties: {
|
|
840
|
+
readonly manifest: {
|
|
841
|
+
readonly type: "string";
|
|
842
|
+
readonly description: "The grantor's signed manifest token; its issuer is the authoritative grantor.";
|
|
843
|
+
};
|
|
844
|
+
readonly delegationTokens: {
|
|
845
|
+
readonly type: "array";
|
|
846
|
+
readonly items: {
|
|
847
|
+
readonly type: "string";
|
|
848
|
+
};
|
|
849
|
+
readonly description: "The grantor's administer-chain delegation tokens, closest-to-grantor first. Empty when the grantor is the owner.";
|
|
850
|
+
};
|
|
851
|
+
};
|
|
852
|
+
readonly required: readonly ["manifest", "delegationTokens"];
|
|
853
|
+
readonly additionalProperties: false;
|
|
854
|
+
};
|
|
855
|
+
};
|
|
809
856
|
readonly 'control/groupLeaveRequest': {
|
|
810
857
|
readonly type: "event";
|
|
811
858
|
readonly retain: "ephemeral";
|
|
@@ -395,6 +395,34 @@ import { defineGroupProtocol } from '@kumiai/rpc';
|
|
|
395
395
|
additionalProperties: false
|
|
396
396
|
}
|
|
397
397
|
},
|
|
398
|
+
'control/credentialKeyManifest': {
|
|
399
|
+
type: 'event',
|
|
400
|
+
// Retained like the grant: an offline recipient must still find this on
|
|
401
|
+
// the epoch's app topic later, so it costs one log frame.
|
|
402
|
+
retain: 'log',
|
|
403
|
+
description: "A grantor's advisory record of the key set it granted a recipient (the readiness union). `manifest` is the grantor's signed token; `delegationTokens` proves its administer chain over the owner. Group-wide because the receiver keys the row on the manifest's own owner/recipient/grantor. The manifest authorizes nothing — the receiver's gate keeps a bogus one out of the store.",
|
|
404
|
+
data: {
|
|
405
|
+
type: 'object',
|
|
406
|
+
properties: {
|
|
407
|
+
manifest: {
|
|
408
|
+
type: 'string',
|
|
409
|
+
description: "The grantor's signed manifest token; its issuer is the authoritative grantor."
|
|
410
|
+
},
|
|
411
|
+
delegationTokens: {
|
|
412
|
+
type: 'array',
|
|
413
|
+
items: {
|
|
414
|
+
type: 'string'
|
|
415
|
+
},
|
|
416
|
+
description: "The grantor's administer-chain delegation tokens, closest-to-grantor first. Empty when the grantor is the owner."
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
required: [
|
|
420
|
+
'manifest',
|
|
421
|
+
'delegationTokens'
|
|
422
|
+
],
|
|
423
|
+
additionalProperties: false
|
|
424
|
+
}
|
|
425
|
+
},
|
|
398
426
|
'control/groupLeaveRequest': {
|
|
399
427
|
type: 'event',
|
|
400
428
|
retain: 'ephemeral',
|
package/lib/hub/wiring.d.ts
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
+
import type { ClientTransportOf } from '@enkaku/protocol';
|
|
1
2
|
import type { ProcedureHandlers } from '@enkaku/server';
|
|
2
3
|
import type { OwnIdentity } from '@kokuin/token';
|
|
3
4
|
import type { KubunDB } from '@kubun/db';
|
|
4
5
|
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
5
6
|
import type { HLC } from '@kubun/hlc';
|
|
6
7
|
import type { Logger } from '@kubun/logger';
|
|
8
|
+
import type { ServiceConfig, ServiceProtocol } from '@kubun/plugin-service-api';
|
|
7
9
|
import type { LaneResult, PendingCommit } from '@kumiai/rpc';
|
|
8
10
|
import type { Runtime } from '@sozai/runtime';
|
|
9
11
|
import type { GroupBroadcastMessage } from '../groups/broadcast-message.js';
|
|
12
|
+
import type { ControllerResolverFor } from '../groups/credential-wrapping-deps.js';
|
|
10
13
|
import type { P2PEventEmitter } from '../groups/events.js';
|
|
11
14
|
import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
|
|
12
15
|
import type { BuildLedgerRedrive } from '../groups/group-peer-manager.js';
|
|
13
16
|
import type { PeerPresence } from '../groups/peer-presence.js';
|
|
14
17
|
import type { SyncProtocol } from '../protocol.js';
|
|
15
18
|
import type { ForwardingConfig } from '../sync/forwarder.js';
|
|
19
|
+
import type { ServeService } from '../sync/hub-tunnel-service-listener.js';
|
|
16
20
|
import type { SyncTransportProvider } from '../sync/sync-client.js';
|
|
17
21
|
import type { LedgerCatchupOptions, LedgerCatchupSummary, RejoinResult, StoreUnreadableMode } from '../types.js';
|
|
18
22
|
import type { CreateHubClient } from './http-client.js';
|
|
@@ -65,6 +69,12 @@ export type HubWiring = {
|
|
|
65
69
|
* carries is authorized per document at the far end exactly as an HTTP one is.
|
|
66
70
|
*/
|
|
67
71
|
syncTransportTo: (groupID: string, peerDID: string) => Promise<SyncTransportProvider | undefined>;
|
|
72
|
+
/**
|
|
73
|
+
* Build the transport for one directed SERVICE-lane session to a co-member,
|
|
74
|
+
* relayed by the group's hub — the mirror of {@link syncTransportTo} on the
|
|
75
|
+
* service lane. `undefined` when the group has no hub bound.
|
|
76
|
+
*/
|
|
77
|
+
serviceTransportTo: (groupID: string, peerDID: string) => Promise<ClientTransportOf<ServiceProtocol> | undefined>;
|
|
68
78
|
dispose: () => Promise<void>;
|
|
69
79
|
};
|
|
70
80
|
export type SetupHubRelayParams = {
|
|
@@ -113,6 +123,12 @@ export type SetupHubRelayParams = {
|
|
|
113
123
|
* stamp is bounded there the same way the graph lane bounds a mutation's.
|
|
114
124
|
*/
|
|
115
125
|
maxDriftMS: number;
|
|
126
|
+
/**
|
|
127
|
+
* The engine's `did:kokuin:` controller-resolver seam, forwarded to the apply
|
|
128
|
+
* path so a delegate-signed `credential:key-grant` wrapping can be authorized.
|
|
129
|
+
* Omitted, the delegated wrapping stays fail-closed.
|
|
130
|
+
*/
|
|
131
|
+
controllerResolverFor?: ControllerResolverFor;
|
|
116
132
|
/**
|
|
117
133
|
* Reconnect-backoff overrides forwarded to every hub adapter. Test-only; when
|
|
118
134
|
* omitted the adapter runs on its production defaults.
|
|
@@ -130,5 +146,13 @@ export type SetupHubRelayParams = {
|
|
|
130
146
|
* answers none.
|
|
131
147
|
*/
|
|
132
148
|
syncHandlers?: ProcedureHandlers<SyncProtocol>;
|
|
149
|
+
/**
|
|
150
|
+
* Injected `plugin-service-server` `serve()`, for answering an inbound
|
|
151
|
+
* service-lane tunnel session — see {@link ServeService}. Paired with
|
|
152
|
+
* `services`: both present is what serves the lane.
|
|
153
|
+
*/
|
|
154
|
+
serviceServe?: ServeService;
|
|
155
|
+
/** Which services to serve on an inbound service-lane session. */
|
|
156
|
+
services?: Record<string, ServiceConfig>;
|
|
133
157
|
};
|
|
134
158
|
export declare function setupHubRelay(params: SetupHubRelayParams): HubWiring;
|
package/lib/hub/wiring.js
CHANGED
|
@@ -10,7 +10,7 @@ import { HubTunnelSyncProvider } from '../sync/hub-tunnel-sync-provider.js';
|
|
|
10
10
|
* once per candidate before reporting `no-route`.
|
|
11
11
|
*/ const DEFAULT_TUNNEL_IDLE_TIMEOUT_MS = 30_000;
|
|
12
12
|
export function setupHubRelay(params) {
|
|
13
|
-
const { identity, runtime, db, graph, emitter, createHubClient, registry, buildLedgerRedrive, logger, storeUnreadable, defaultAccessLevel, forwarding, hlc, maxDriftMS, hubReconnectBackoff, tunnelIdleTimeoutMs } = params;
|
|
13
|
+
const { identity, runtime, db, graph, emitter, createHubClient, registry, buildLedgerRedrive, logger, storeUnreadable, defaultAccessLevel, forwarding, hlc, maxDriftMS, controllerResolverFor, hubReconnectBackoff, tunnelIdleTimeoutMs } = params;
|
|
14
14
|
const unsubscribes = [];
|
|
15
15
|
// Late-bound `ready` resolving to the live manager. The fire-and-forget
|
|
16
16
|
// schedulers chain off it so a caller can enqueue work before boot completes;
|
|
@@ -68,6 +68,10 @@ export function setupHubRelay(params) {
|
|
|
68
68
|
logger: logger.getChild('tunnel-session')
|
|
69
69
|
});
|
|
70
70
|
};
|
|
71
|
+
const serviceTransportTo = async (groupID, peerDID)=>{
|
|
72
|
+
const provider = await (await ready).serviceTransportTo(groupID, peerDID);
|
|
73
|
+
return provider == null ? undefined : await provider.createServiceTransport();
|
|
74
|
+
};
|
|
71
75
|
ready = (async ()=>{
|
|
72
76
|
const [p2pStore, graphStore] = await Promise.all([
|
|
73
77
|
db.getStore('p2p'),
|
|
@@ -90,6 +94,9 @@ export function setupHubRelay(params) {
|
|
|
90
94
|
storeUnreadable,
|
|
91
95
|
defaultAccessLevel,
|
|
92
96
|
forwarding,
|
|
97
|
+
...controllerResolverFor != null ? {
|
|
98
|
+
controllerResolverFor
|
|
99
|
+
} : {},
|
|
93
100
|
// The manager's own fan-out, reached through the same late-bound `ready`
|
|
94
101
|
// every other scheduler chains off — a forward is one more broadcast, so
|
|
95
102
|
// it takes the path a locally-authored one already takes.
|
|
@@ -102,6 +109,14 @@ export function setupHubRelay(params) {
|
|
|
102
109
|
...params.syncHandlers != null ? {
|
|
103
110
|
syncHandlers: params.syncHandlers
|
|
104
111
|
} : {},
|
|
112
|
+
// The service lane's answering half; both present is what serves it,
|
|
113
|
+
// either absent and no service listeners stand up.
|
|
114
|
+
...params.serviceServe != null ? {
|
|
115
|
+
serviceServe: params.serviceServe
|
|
116
|
+
} : {},
|
|
117
|
+
...params.services != null ? {
|
|
118
|
+
services: params.services
|
|
119
|
+
} : {},
|
|
105
120
|
tunnelIdleTimeoutMs: tunnelIdleTimeoutMs ?? DEFAULT_TUNNEL_IDLE_TIMEOUT_MS
|
|
106
121
|
});
|
|
107
122
|
unsubscribes.push(emitter.on('groupJoined', (group)=>manager.addGroup(group.id).catch((error)=>{
|
|
@@ -170,6 +185,7 @@ export function setupHubRelay(params) {
|
|
|
170
185
|
presence,
|
|
171
186
|
retryHubs,
|
|
172
187
|
syncTransportTo,
|
|
188
|
+
serviceTransportTo,
|
|
173
189
|
dispose: async ()=>{
|
|
174
190
|
for (const off of unsubscribes){
|
|
175
191
|
try {
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DefaultAccessLevel, KubunPlugin, PluginFactoryParams } from '@kubun/engine';
|
|
2
|
+
import type { ServiceConfig } from '@kubun/plugin-service-api';
|
|
2
3
|
import { type CreateHubClient } from './hub/http-client.js';
|
|
3
4
|
import { type BroadcastBatchConfig } from './sync/broadcast-queue.js';
|
|
4
5
|
import { type PushSyncConfig } from './sync/broadcast-sender.js';
|
|
@@ -172,5 +173,18 @@ export type P2PPluginOptions = {
|
|
|
172
173
|
* which a headless test would otherwise have to wait out per offline peer.
|
|
173
174
|
*/
|
|
174
175
|
tunnelIdleTimeoutMs?: number;
|
|
176
|
+
/**
|
|
177
|
+
* Serve service-lane sessions over the hub tunnel, on a distinct protocol
|
|
178
|
+
* from sync so ratchet generation is never shared. Requires the
|
|
179
|
+
* `service-server` plugin; `serviceTransportTo` still resolves for dialing
|
|
180
|
+
* out even when this device serves nothing.
|
|
181
|
+
* - `true` / `{}` — serve `{ 'controller-log': true }`.
|
|
182
|
+
* - `{ services }` — serve exactly this config (falls back to
|
|
183
|
+
* `{ 'controller-log': true }` when `services` is omitted).
|
|
184
|
+
* - absent / `false` (default) — no service-lane listener.
|
|
185
|
+
*/
|
|
186
|
+
serviceTunnel?: boolean | {
|
|
187
|
+
services?: Record<string, ServiceConfig>;
|
|
188
|
+
};
|
|
175
189
|
};
|
|
176
190
|
export declare function createP2PPlugin(options?: P2PPluginOptions): (params: PluginFactoryParams) => KubunPlugin;
|
package/lib/index.js
CHANGED
|
@@ -28,7 +28,9 @@ import { createP2PSchemaExtension } from './schema.js';
|
|
|
28
28
|
import { wireAccessDefaultSender } from './sync/access-default-sender.js';
|
|
29
29
|
import { createBroadcastQueue, DEFAULT_BROADCAST_BATCH_CONFIG } from './sync/broadcast-queue.js';
|
|
30
30
|
import { DEFAULT_PUSH_SYNC_CONFIG, wireBroadcastSender } from './sync/broadcast-sender.js';
|
|
31
|
+
import { createGroupSyncWorkflow, GROUP_SYNC_CONCURRENCY, GROUP_SYNC_WORKFLOW, wireGroupPeriodicSyncArming } from './sync/group-sync-workflow.js';
|
|
31
32
|
import { createSyncHandlers } from './sync/handlers.js';
|
|
33
|
+
import { ServiceServeNotReadyError, ServiceServeUnavailableError } from './sync/hub-tunnel-service-listener.js';
|
|
32
34
|
import { SyncManager } from './sync/sync-manager.js';
|
|
33
35
|
export { GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, NOT_GROUP_ADMIN, requireGroupAdmin } from './context/require-admin.js';
|
|
34
36
|
export { ADMIN_ROLE_ENTRY_TYPE, foldAdminRoster } from './groups/admin-roster.js';
|
|
@@ -76,6 +78,26 @@ export { SyncManager } from './sync/sync-manager.js';
|
|
|
76
78
|
function isOwnIdentity(identity) {
|
|
77
79
|
return isFullIdentity(identity) && 'privateKey' in identity;
|
|
78
80
|
}
|
|
81
|
+
// Fixed 30s after a pass that pulled mutations; idle 2m→30m, offline 1m→30m,
|
|
82
|
+
// error 30s→15m (both backing off exponentially). Group catch-up is a cheap
|
|
83
|
+
// merkle diff, so the productive cadence is tighter than the connector's.
|
|
84
|
+
const DEFAULT_GROUP_PERIODIC_SYNC_POLICY = {
|
|
85
|
+
changed: 30_000,
|
|
86
|
+
idle: {
|
|
87
|
+
base: 120_000,
|
|
88
|
+
max: 1_800_000
|
|
89
|
+
},
|
|
90
|
+
offline: {
|
|
91
|
+
base: 60_000,
|
|
92
|
+
max: 1_800_000,
|
|
93
|
+
backoff: 'exponential'
|
|
94
|
+
},
|
|
95
|
+
error: {
|
|
96
|
+
base: 30_000,
|
|
97
|
+
max: 900_000,
|
|
98
|
+
backoff: 'exponential'
|
|
99
|
+
}
|
|
100
|
+
};
|
|
79
101
|
export function createP2PPlugin(options) {
|
|
80
102
|
return (params)=>{
|
|
81
103
|
params.db.register(p2pStoreDefinition);
|
|
@@ -178,7 +200,7 @@ export function createP2PPlugin(options) {
|
|
|
178
200
|
logger: params.getLogger('peer-connections')
|
|
179
201
|
});
|
|
180
202
|
// The p2p plugin manages its own Enkaku server for sync handlers,
|
|
181
|
-
// separate from
|
|
203
|
+
// separate from the graph service's server.
|
|
182
204
|
const syncServers = [];
|
|
183
205
|
function createSyncTransport(signal) {
|
|
184
206
|
const directTransports = new DirectTransports({
|
|
@@ -222,6 +244,47 @@ export function createP2PPlugin(options) {
|
|
|
222
244
|
fetch: params.runtime.fetch
|
|
223
245
|
});
|
|
224
246
|
const hubReconnectBackoff = typeof options?.hub === 'object' ? options.hub.reconnectBackoff : undefined;
|
|
247
|
+
// Which services (if any) this device answers on an inbound service-lane
|
|
248
|
+
// tunnel session; `undefined` means the service lane is not configured.
|
|
249
|
+
const serviceConfig = options?.serviceTunnel == null || options.serviceTunnel === false ? undefined : options.serviceTunnel === true ? {
|
|
250
|
+
'controller-log': true
|
|
251
|
+
} : options.serviceTunnel.services ?? {
|
|
252
|
+
'controller-log': true
|
|
253
|
+
};
|
|
254
|
+
// `getAPI` only resolves once every plugin factory has returned (see
|
|
255
|
+
// `Registry.closeGate` in `@kubun/engine`), so it can't be awaited here.
|
|
256
|
+
// `resolvedServe` fills in later, before any service session actually
|
|
257
|
+
// spawns (spawning waits on `setupHubRelay`'s internal `ready`, which
|
|
258
|
+
// resolves after every factory returns) — `serviceServe` below is thus a
|
|
259
|
+
// synchronous `ServeService` even though its target resolves lazily.
|
|
260
|
+
let resolvedServe;
|
|
261
|
+
// A permanent resolution failure (service-server not installed) must not
|
|
262
|
+
// read as transient "not ready" — that would retry a plugin that will
|
|
263
|
+
// never arrive. Track it so the wrapper throws the terminal error instead.
|
|
264
|
+
let serveResolutionFailed = false;
|
|
265
|
+
if (serviceConfig != null) {
|
|
266
|
+
void params.engine.getAPI('service-server').then((api)=>{
|
|
267
|
+
resolvedServe = api.serve;
|
|
268
|
+
}).catch((error)=>{
|
|
269
|
+
serveResolutionFailed = true;
|
|
270
|
+
hubRelayLogger.error('serviceTunnel is enabled but the "service-server" plugin API could not be resolved; the service lane will not serve', {
|
|
271
|
+
error
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const serviceServe = serviceConfig == null ? undefined : (serveParams)=>{
|
|
276
|
+
if (resolvedServe != null) {
|
|
277
|
+
return resolvedServe(serveParams);
|
|
278
|
+
}
|
|
279
|
+
// Terminal: the plugin API rejected and will not resolve. Stops the
|
|
280
|
+
// listener rather than letting it retry a plugin that never arrives.
|
|
281
|
+
if (serveResolutionFailed) {
|
|
282
|
+
throw new ServiceServeUnavailableError('serviceTunnel is enabled but the "service-server" plugin API could not be resolved');
|
|
283
|
+
}
|
|
284
|
+
// Transient: the API is still resolving (an early-spawn race). The
|
|
285
|
+
// listener retries on backoff and the next spawn finds it.
|
|
286
|
+
throw new ServiceServeNotReadyError('serviceTunnel is enabled but the "service-server" plugin API is not ready yet');
|
|
287
|
+
};
|
|
225
288
|
const hub = setupHubRelay({
|
|
226
289
|
identity,
|
|
227
290
|
runtime: params.runtime,
|
|
@@ -243,6 +306,10 @@ export function createP2PPlugin(options) {
|
|
|
243
306
|
storeUnreadable: receiveConfig.storeUnreadable,
|
|
244
307
|
defaultAccessLevel,
|
|
245
308
|
forwarding: options?.forwarding,
|
|
309
|
+
// The engine's controller-resolver seam, so a delegate-signed
|
|
310
|
+
// `credential:key-grant` wrapping received over the live broadcast lane can
|
|
311
|
+
// be authorized against the grantor's administer chain.
|
|
312
|
+
controllerResolverFor: params.controllerResolverFor,
|
|
246
313
|
...hubReconnectBackoff != null ? {
|
|
247
314
|
hubReconnectBackoff
|
|
248
315
|
} : {},
|
|
@@ -251,7 +318,13 @@ export function createP2PPlugin(options) {
|
|
|
251
318
|
} : {},
|
|
252
319
|
// The same handlers the direct and HTTP transports serve — a tunnel is a
|
|
253
320
|
// route, so it must not reach a different sync implementation.
|
|
254
|
-
syncHandlers: syncHandlers
|
|
321
|
+
syncHandlers: syncHandlers,
|
|
322
|
+
// The service lane's answering half; absent, `setupHubRelay` stands up
|
|
323
|
+
// no service listeners, so a device without `serviceTunnel` is unchanged.
|
|
324
|
+
...serviceServe != null ? {
|
|
325
|
+
serviceServe,
|
|
326
|
+
services: serviceConfig
|
|
327
|
+
} : {}
|
|
255
328
|
});
|
|
256
329
|
// Turn a `tunnel://<groupID>/<peerDID>` route into a live session over the
|
|
257
330
|
// group's hub. Until this existed the scheme parsed and then failed at
|
|
@@ -329,6 +402,9 @@ export function createP2PPlugin(options) {
|
|
|
329
402
|
adapter,
|
|
330
403
|
hlc,
|
|
331
404
|
maxDriftMS: params.maxDriftMS,
|
|
405
|
+
// The engine's controller-resolver seam, so the credential reconcile-apply
|
|
406
|
+
// lane (catch-up) can authorize a delegate-signed wrapping it pulls back.
|
|
407
|
+
controllerResolverFor: params.controllerResolverFor,
|
|
332
408
|
emitter,
|
|
333
409
|
runtime: params.runtime,
|
|
334
410
|
autoAcceptPeers: options?.autoAcceptPeers,
|
|
@@ -390,8 +466,74 @@ export function createP2PPlugin(options) {
|
|
|
390
466
|
logger: params.getLogger('controller-handlers'),
|
|
391
467
|
autoAcceptPeers: options?.autoAcceptPeers
|
|
392
468
|
});
|
|
469
|
+
// Recurring group catch-up over the workflow engine's adaptive scheduler. One
|
|
470
|
+
// `catchUpWithBestPeer` pass is one durable tick, run viewer-independent through
|
|
471
|
+
// the SAME sync context the GraphQL surface and the API use, so a scheduled
|
|
472
|
+
// catch-up cannot ask for scopes an explicit one would not.
|
|
473
|
+
const groupSyncWorkflow = createGroupSyncWorkflow({
|
|
474
|
+
catchUpWithBestPeer: (groupID)=>createSyncContext(null, buildContextDeps(params.db)).catchUpWithBestPeer(groupID),
|
|
475
|
+
logger: params.getLogger('group-sync')
|
|
476
|
+
});
|
|
477
|
+
let workflowAPIPromise;
|
|
478
|
+
const getWorkflowAPI = ()=>{
|
|
479
|
+
if (workflowAPIPromise == null) {
|
|
480
|
+
workflowAPIPromise = params.engine.getAPI('workflow').then((api)=>{
|
|
481
|
+
api.defineQueue(GROUP_SYNC_WORKFLOW, {
|
|
482
|
+
concurrency: GROUP_SYNC_CONCURRENCY
|
|
483
|
+
});
|
|
484
|
+
api.register(groupSyncWorkflow.definition);
|
|
485
|
+
return api;
|
|
486
|
+
}).catch(()=>undefined);
|
|
487
|
+
}
|
|
488
|
+
return workflowAPIPromise;
|
|
489
|
+
};
|
|
490
|
+
// Resolve once at boot so the definition + queue register before any due
|
|
491
|
+
// schedule fires: an implicit schedule armed on a prior run is promoted by the
|
|
492
|
+
// engine at startup and needs its handler present, which the connector's
|
|
493
|
+
// lazy-only resolve does not have to guarantee (nothing schedules it implicitly).
|
|
494
|
+
void getWorkflowAPI();
|
|
495
|
+
// Implicit activation: arm a schedule the first time a group is joined,
|
|
496
|
+
// insert-if-absent (see `wireGroupPeriodicSyncArming`).
|
|
497
|
+
wireGroupPeriodicSyncArming({
|
|
498
|
+
emitter,
|
|
499
|
+
getWorkflowAPI,
|
|
500
|
+
policy: DEFAULT_GROUP_PERIODIC_SYNC_POLICY,
|
|
501
|
+
logger: params.getLogger('group-sync')
|
|
502
|
+
});
|
|
503
|
+
// Explicit control. `enable` always (re)activates — the deliberate opposite of
|
|
504
|
+
// the implicit insert-if-absent guard — so the user turning it back on wins.
|
|
505
|
+
const groupPeriodicSyncControl = {
|
|
506
|
+
enableGroupPeriodicSync: async (groupID, policyOverride)=>{
|
|
507
|
+
const api = await getWorkflowAPI();
|
|
508
|
+
if (api == null) {
|
|
509
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
510
|
+
}
|
|
511
|
+
const { id } = await api.scheduleAdaptive(GROUP_SYNC_WORKFLOW, {
|
|
512
|
+
groupID
|
|
513
|
+
}, {
|
|
514
|
+
policy: policyOverride ?? DEFAULT_GROUP_PERIODIC_SYNC_POLICY,
|
|
515
|
+
subjectKey: groupID
|
|
516
|
+
});
|
|
517
|
+
return api.getPeriodicSync(id);
|
|
518
|
+
},
|
|
519
|
+
disableGroupPeriodicSync: async (groupID)=>{
|
|
520
|
+
const api = await getWorkflowAPI();
|
|
521
|
+
if (api == null) {
|
|
522
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
523
|
+
}
|
|
524
|
+
return api.setPeriodicSyncEnabled(`${GROUP_SYNC_WORKFLOW}:${groupID}`, false);
|
|
525
|
+
},
|
|
526
|
+
getGroupPeriodicSync: async (groupID)=>{
|
|
527
|
+
const api = await getWorkflowAPI();
|
|
528
|
+
if (api == null) {
|
|
529
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
530
|
+
}
|
|
531
|
+
return api.getPeriodicSync(`${GROUP_SYNC_WORKFLOW}:${groupID}`);
|
|
532
|
+
}
|
|
533
|
+
};
|
|
393
534
|
const pluginAPI = {
|
|
394
535
|
hubReady: hub.ready,
|
|
536
|
+
...groupPeriodicSyncControl,
|
|
395
537
|
addPeer: (config)=>syncManager.addPeer({
|
|
396
538
|
config,
|
|
397
539
|
stores: params.db
|
|
@@ -420,6 +562,7 @@ export function createP2PPlugin(options) {
|
|
|
420
562
|
onHubServerDIDChanged: (listener)=>emitter.on('hubServerDIDChanged', listener),
|
|
421
563
|
createSyncTransport,
|
|
422
564
|
requestLedgerCatchup: (groupID, options)=>hub.requestLedgerCatchup(groupID, options),
|
|
565
|
+
serviceTransportTo: (groupID, peerDID)=>hub.serviceTransportTo(groupID, peerDID),
|
|
423
566
|
setLocalPeerProfile: async (profile)=>{
|
|
424
567
|
await (await hub.presence()).setProfile(profile);
|
|
425
568
|
},
|
|
@@ -432,11 +575,19 @@ export function createP2PPlugin(options) {
|
|
|
432
575
|
peerConnections,
|
|
433
576
|
getBlobAPI: ()=>params.engine.getAPI('blob'),
|
|
434
577
|
fetch: params.runtime.fetch
|
|
435
|
-
}, attachmentID, options)
|
|
436
|
-
|
|
578
|
+
}, attachmentID, options)
|
|
579
|
+
};
|
|
580
|
+
// The controller resolver pulls a `did:kokuin:` log over `controller/get-log`
|
|
581
|
+
// from a connected group peer on a store miss/expiry. Registered as a
|
|
582
|
+
// `networked` provider (the engine times it out and folds the result) rather
|
|
583
|
+
// than an API member the engine reached by string.
|
|
584
|
+
params.registerProvider('controller-log-source', {
|
|
585
|
+
name: 'p2p',
|
|
586
|
+
transport: 'networked',
|
|
587
|
+
fetch: (did)=>fetchControllerLog({
|
|
437
588
|
peerConnections
|
|
438
589
|
}, did)
|
|
439
|
-
};
|
|
590
|
+
});
|
|
440
591
|
let httpSyncTransport;
|
|
441
592
|
let httpSyncServer;
|
|
442
593
|
let httpPeerTransport;
|
|
@@ -528,7 +679,7 @@ export function createP2PPlugin(options) {
|
|
|
528
679
|
}
|
|
529
680
|
return {
|
|
530
681
|
name: 'p2p',
|
|
531
|
-
schemaExtension: (
|
|
682
|
+
schemaExtension: (config)=>createP2PSchemaExtension(emitter, syncManager, params.getLogger('p2p-schema'), groupPeriodicSyncControl, config),
|
|
532
683
|
api: pluginAPI,
|
|
533
684
|
createContextFactory: ()=>{
|
|
534
685
|
return (ctx, stores)=>{
|