@kubun/plugin-p2p 0.14.0 → 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.
Files changed (45) hide show
  1. package/lib/context/peer.js +250 -3
  2. package/lib/context/sync.js +134 -25
  3. package/lib/context/types.d.ts +7 -0
  4. package/lib/groups/broadcast-message.d.ts +29 -0
  5. package/lib/groups/broadcast.d.ts +11 -1
  6. package/lib/groups/broadcast.js +44 -2
  7. package/lib/groups/credential-apply.d.ts +64 -2
  8. package/lib/groups/credential-apply.js +215 -30
  9. package/lib/groups/credential-grant.d.ts +22 -0
  10. package/lib/groups/credential-grant.js +76 -2
  11. package/lib/groups/credential-manifest-token.d.ts +31 -0
  12. package/lib/groups/credential-manifest-token.js +49 -0
  13. package/lib/groups/credential-readiness.d.ts +69 -0
  14. package/lib/groups/credential-readiness.js +172 -0
  15. package/lib/groups/credential-wrapping-deps.d.ts +23 -0
  16. package/lib/groups/credential-wrapping-deps.js +25 -0
  17. package/lib/groups/grantor-authority.d.ts +65 -0
  18. package/lib/groups/grantor-authority.js +107 -0
  19. package/lib/groups/group-handlers.js +7 -0
  20. package/lib/groups/group-peer-manager.d.ts +25 -0
  21. package/lib/groups/group-peer-manager.js +71 -0
  22. package/lib/groups/group-protocols.d.ts +47 -0
  23. package/lib/groups/group-protocols.js +28 -0
  24. package/lib/hub/wiring.d.ts +24 -0
  25. package/lib/hub/wiring.js +17 -1
  26. package/lib/index.d.ts +14 -0
  27. package/lib/index.js +57 -1
  28. package/lib/protocol.d.ts +30 -0
  29. package/lib/protocol.js +36 -0
  30. package/lib/schema.js +78 -1
  31. package/lib/sync/handlers.js +21 -2
  32. package/lib/sync/held-delegations.d.ts +14 -0
  33. package/lib/sync/held-delegations.js +34 -0
  34. package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
  35. package/lib/sync/hub-tunnel-service-listener.js +289 -0
  36. package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
  37. package/lib/sync/hub-tunnel-service-provider.js +100 -0
  38. package/lib/sync/service-tunnel-listeners.d.ts +35 -0
  39. package/lib/sync/service-tunnel-listeners.js +165 -0
  40. package/lib/sync/sync-manager.d.ts +7 -0
  41. package/lib/sync/sync-manager.js +4 -1
  42. package/lib/sync/tunnel-topics.d.ts +19 -1
  43. package/lib/sync/tunnel-topics.js +7 -3
  44. package/lib/types.d.ts +172 -0
  45. package/package.json +48 -47
@@ -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',
@@ -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
@@ -30,6 +30,7 @@ import { createBroadcastQueue, DEFAULT_BROADCAST_BATCH_CONFIG } from './sync/bro
30
30
  import { DEFAULT_PUSH_SYNC_CONFIG, wireBroadcastSender } from './sync/broadcast-sender.js';
31
31
  import { createGroupSyncWorkflow, GROUP_SYNC_CONCURRENCY, GROUP_SYNC_WORKFLOW, wireGroupPeriodicSyncArming } from './sync/group-sync-workflow.js';
32
32
  import { createSyncHandlers } from './sync/handlers.js';
33
+ import { ServiceServeNotReadyError, ServiceServeUnavailableError } from './sync/hub-tunnel-service-listener.js';
33
34
  import { SyncManager } from './sync/sync-manager.js';
34
35
  export { GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, NOT_GROUP_ADMIN, requireGroupAdmin } from './context/require-admin.js';
35
36
  export { ADMIN_ROLE_ENTRY_TYPE, foldAdminRoster } from './groups/admin-roster.js';
@@ -243,6 +244,47 @@ export function createP2PPlugin(options) {
243
244
  fetch: params.runtime.fetch
244
245
  });
245
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
+ };
246
288
  const hub = setupHubRelay({
247
289
  identity,
248
290
  runtime: params.runtime,
@@ -264,6 +306,10 @@ export function createP2PPlugin(options) {
264
306
  storeUnreadable: receiveConfig.storeUnreadable,
265
307
  defaultAccessLevel,
266
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,
267
313
  ...hubReconnectBackoff != null ? {
268
314
  hubReconnectBackoff
269
315
  } : {},
@@ -272,7 +318,13 @@ export function createP2PPlugin(options) {
272
318
  } : {},
273
319
  // The same handlers the direct and HTTP transports serve — a tunnel is a
274
320
  // route, so it must not reach a different sync implementation.
275
- 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
+ } : {}
276
328
  });
277
329
  // Turn a `tunnel://<groupID>/<peerDID>` route into a live session over the
278
330
  // group's hub. Until this existed the scheme parsed and then failed at
@@ -350,6 +402,9 @@ export function createP2PPlugin(options) {
350
402
  adapter,
351
403
  hlc,
352
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,
353
408
  emitter,
354
409
  runtime: params.runtime,
355
410
  autoAcceptPeers: options?.autoAcceptPeers,
@@ -507,6 +562,7 @@ export function createP2PPlugin(options) {
507
562
  onHubServerDIDChanged: (listener)=>emitter.on('hubServerDIDChanged', listener),
508
563
  createSyncTransport,
509
564
  requestLedgerCatchup: (groupID, options)=>hub.requestLedgerCatchup(groupID, options),
565
+ serviceTransportTo: (groupID, peerDID)=>hub.serviceTransportTo(groupID, peerDID),
510
566
  setLocalPeerProfile: async (profile)=>{
511
567
  await (await hub.presence()).setProfile(profile);
512
568
  },
package/lib/protocol.d.ts CHANGED
@@ -229,6 +229,13 @@ export declare const syncProtocol: {
229
229
  };
230
230
  readonly description: "Signed subject states the caller holds, for updates and revocation GC";
231
231
  };
232
+ readonly owners: {
233
+ readonly type: "array";
234
+ readonly items: {
235
+ readonly type: "string";
236
+ };
237
+ readonly description: "Owner DIDs to fetch the latest durable manifest for, addressed to the caller — the PULL repair channel independent of LIVE broadcast.";
238
+ };
232
239
  };
233
240
  readonly required: readonly ["held"];
234
241
  readonly additionalProperties: false;
@@ -327,6 +334,9 @@ export declare const syncProtocol: {
327
334
  readonly additionalProperties: false;
328
335
  };
329
336
  };
337
+ readonly grantorAuthority: {
338
+ readonly type: "string";
339
+ };
330
340
  };
331
341
  readonly required: readonly ["keyID", "keyVersion", "suite", "ownerDID", "keyOp", "keyBranches", "wrapping", "entries"];
332
342
  readonly additionalProperties: false;
@@ -362,6 +372,26 @@ export declare const syncProtocol: {
362
372
  };
363
373
  readonly description: "Tombstones for advertised held IDs that are now revoked (GC)";
364
374
  };
375
+ readonly manifests: {
376
+ readonly type: "array";
377
+ readonly items: {
378
+ readonly type: "object";
379
+ readonly properties: {
380
+ readonly manifest: {
381
+ readonly type: "string";
382
+ };
383
+ readonly delegationTokens: {
384
+ readonly type: "array";
385
+ readonly items: {
386
+ readonly type: "string";
387
+ };
388
+ };
389
+ };
390
+ readonly required: readonly ["manifest", "delegationTokens"];
391
+ readonly additionalProperties: false;
392
+ };
393
+ readonly description: "The latest stored manifest addressed to the caller for each requested owner — the durable repair channel independent of LIVE broadcast.";
394
+ };
365
395
  };
366
396
  readonly required: readonly ["bundles", "tombstones"];
367
397
  readonly additionalProperties: false;
package/lib/protocol.js CHANGED
@@ -290,6 +290,13 @@ export const syncProtocol = {
290
290
  additionalProperties: false
291
291
  },
292
292
  description: 'Signed subject states the caller holds, for updates and revocation GC'
293
+ },
294
+ owners: {
295
+ type: 'array',
296
+ items: {
297
+ type: 'string'
298
+ },
299
+ description: 'Owner DIDs to fetch the latest durable manifest for, addressed to the caller — the PULL repair channel independent of LIVE broadcast.'
293
300
  }
294
301
  },
295
302
  required: [
@@ -406,6 +413,12 @@ export const syncProtocol = {
406
413
  ],
407
414
  additionalProperties: false
408
415
  }
416
+ },
417
+ // Persisted grantor-authority envelope for a delegate-signed
418
+ // wrapping (absent for owner-signed); declared so it isn't an
419
+ // `additionalProperties: false` violation if result validation is enabled.
420
+ grantorAuthority: {
421
+ type: 'string'
409
422
  }
410
423
  },
411
424
  required: [
@@ -457,6 +470,29 @@ export const syncProtocol = {
457
470
  additionalProperties: false
458
471
  },
459
472
  description: 'Tombstones for advertised held IDs that are now revoked (GC)'
473
+ },
474
+ manifests: {
475
+ type: 'array',
476
+ items: {
477
+ type: 'object',
478
+ properties: {
479
+ manifest: {
480
+ type: 'string'
481
+ },
482
+ delegationTokens: {
483
+ type: 'array',
484
+ items: {
485
+ type: 'string'
486
+ }
487
+ }
488
+ },
489
+ required: [
490
+ 'manifest',
491
+ 'delegationTokens'
492
+ ],
493
+ additionalProperties: false
494
+ },
495
+ description: 'The latest stored manifest addressed to the caller for each requested owner — the durable repair channel independent of LIVE broadcast.'
460
496
  }
461
497
  },
462
498
  required: [