@kubun/plugin-p2p 0.14.0 → 0.15.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.
Files changed (55) 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/did-cache-seed.d.ts +26 -0
  18. package/lib/groups/did-cache-seed.js +38 -0
  19. package/lib/groups/grantor-authority.d.ts +65 -0
  20. package/lib/groups/grantor-authority.js +107 -0
  21. package/lib/groups/group-handlers.js +7 -0
  22. package/lib/groups/group-peer-manager.d.ts +32 -1
  23. package/lib/groups/group-peer-manager.js +77 -0
  24. package/lib/groups/group-protocols.d.ts +47 -0
  25. package/lib/groups/group-protocols.js +28 -0
  26. package/lib/hub/http-client.js +6 -1
  27. package/lib/hub/hub-like.js +5 -2
  28. package/lib/hub/peer-scoped-hub-view.d.ts +6 -0
  29. package/lib/hub/peer-scoped-hub-view.js +11 -1
  30. package/lib/hub/wiring.d.ts +31 -1
  31. package/lib/hub/wiring.js +20 -1
  32. package/lib/index.d.ts +14 -0
  33. package/lib/index.js +72 -4
  34. package/lib/protocol.d.ts +30 -0
  35. package/lib/protocol.js +36 -0
  36. package/lib/schema.js +78 -1
  37. package/lib/sync/handlers.js +21 -2
  38. package/lib/sync/held-delegations.d.ts +14 -0
  39. package/lib/sync/held-delegations.js +34 -0
  40. package/lib/sync/hub-tunnel-service-listener.d.ts +84 -0
  41. package/lib/sync/hub-tunnel-service-listener.js +294 -0
  42. package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
  43. package/lib/sync/hub-tunnel-service-provider.js +100 -0
  44. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -1
  45. package/lib/sync/hub-tunnel-sync-listener.js +6 -1
  46. package/lib/sync/service-tunnel-listeners.d.ts +41 -0
  47. package/lib/sync/service-tunnel-listeners.js +183 -0
  48. package/lib/sync/sync-manager.d.ts +7 -0
  49. package/lib/sync/sync-manager.js +4 -1
  50. package/lib/sync/tunnel-listeners.d.ts +7 -1
  51. package/lib/sync/tunnel-listeners.js +18 -0
  52. package/lib/sync/tunnel-topics.d.ts +19 -1
  53. package/lib/sync/tunnel-topics.js +7 -3
  54. package/lib/types.d.ts +172 -0
  55. package/package.json +49 -48
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ServerTransport } from '@enkaku/http-serve';
2
2
  import { Server } from '@enkaku/server';
3
3
  import { DirectTransports } from '@enkaku/transport';
4
- import { isFullIdentity } from '@kokuin/token';
4
+ import { createInMemoryDIDCache, isFullIdentity } from '@kokuin/token';
5
5
  import { createDeviceAuthority } from '@kubun/credential';
6
6
  import { resolveAllowedOrigin } from '@kubun/http-util';
7
7
  import { getGraphStore } from '@kubun/store-graph';
@@ -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';
@@ -198,6 +199,13 @@ export function createP2PPlugin(options) {
198
199
  const peerConnections = new PeerConnectionRegistry({
199
200
  logger: params.getLogger('peer-connections')
200
201
  });
202
+ // One plugin-lifetime DID cache, shared by every peer-lane Server. The
203
+ // tunnel listeners seed it from each group's MLS roster (a co-member's
204
+ // authenticated leaf long form), so a freshly-spawned per-session Server
205
+ // resolves a co-member's short-form `did:peer:4` issuer instead of defaulting
206
+ // its own empty cache. Content-addressed and monotonic — a stale entry is
207
+ // harmless, and it survives no process restart by design (re-seeded on bind).
208
+ const didCache = createInMemoryDIDCache();
201
209
  // The p2p plugin manages its own Enkaku server for sync handlers,
202
210
  // separate from the graph service's server.
203
211
  const syncServers = [];
@@ -221,6 +229,7 @@ export function createP2PPlugin(options) {
221
229
  transports: [
222
230
  directTransports.server
223
231
  ],
232
+ cache: didCache,
224
233
  signal
225
234
  });
226
235
  syncServers.push(server);
@@ -243,6 +252,47 @@ export function createP2PPlugin(options) {
243
252
  fetch: params.runtime.fetch
244
253
  });
245
254
  const hubReconnectBackoff = typeof options?.hub === 'object' ? options.hub.reconnectBackoff : undefined;
255
+ // Which services (if any) this device answers on an inbound service-lane
256
+ // tunnel session; `undefined` means the service lane is not configured.
257
+ const serviceConfig = options?.serviceTunnel == null || options.serviceTunnel === false ? undefined : options.serviceTunnel === true ? {
258
+ 'controller-log': true
259
+ } : options.serviceTunnel.services ?? {
260
+ 'controller-log': true
261
+ };
262
+ // `getAPI` only resolves once every plugin factory has returned (see
263
+ // `Registry.closeGate` in `@kubun/engine`), so it can't be awaited here.
264
+ // `resolvedServe` fills in later, before any service session actually
265
+ // spawns (spawning waits on `setupHubRelay`'s internal `ready`, which
266
+ // resolves after every factory returns) — `serviceServe` below is thus a
267
+ // synchronous `ServeService` even though its target resolves lazily.
268
+ let resolvedServe;
269
+ // A permanent resolution failure (service-server not installed) must not
270
+ // read as transient "not ready" — that would retry a plugin that will
271
+ // never arrive. Track it so the wrapper throws the terminal error instead.
272
+ let serveResolutionFailed = false;
273
+ if (serviceConfig != null) {
274
+ void params.engine.getAPI('service-server').then((api)=>{
275
+ resolvedServe = api.serve;
276
+ }).catch((error)=>{
277
+ serveResolutionFailed = true;
278
+ hubRelayLogger.error('serviceTunnel is enabled but the "service-server" plugin API could not be resolved; the service lane will not serve', {
279
+ error
280
+ });
281
+ });
282
+ }
283
+ const serviceServe = serviceConfig == null ? undefined : (serveParams)=>{
284
+ if (resolvedServe != null) {
285
+ return resolvedServe(serveParams);
286
+ }
287
+ // Terminal: the plugin API rejected and will not resolve. Stops the
288
+ // listener rather than letting it retry a plugin that never arrives.
289
+ if (serveResolutionFailed) {
290
+ throw new ServiceServeUnavailableError('serviceTunnel is enabled but the "service-server" plugin API could not be resolved');
291
+ }
292
+ // Transient: the API is still resolving (an early-spawn race). The
293
+ // listener retries on backoff and the next spawn finds it.
294
+ throw new ServiceServeNotReadyError('serviceTunnel is enabled but the "service-server" plugin API is not ready yet');
295
+ };
246
296
  const hub = setupHubRelay({
247
297
  identity,
248
298
  runtime: params.runtime,
@@ -264,6 +314,10 @@ export function createP2PPlugin(options) {
264
314
  storeUnreadable: receiveConfig.storeUnreadable,
265
315
  defaultAccessLevel,
266
316
  forwarding: options?.forwarding,
317
+ // The engine's controller-resolver seam, so a delegate-signed
318
+ // `credential:key-grant` wrapping received over the live broadcast lane can
319
+ // be authorized against the grantor's administer chain.
320
+ controllerResolverFor: params.controllerResolverFor,
267
321
  ...hubReconnectBackoff != null ? {
268
322
  hubReconnectBackoff
269
323
  } : {},
@@ -272,7 +326,15 @@ export function createP2PPlugin(options) {
272
326
  } : {},
273
327
  // The same handlers the direct and HTTP transports serve — a tunnel is a
274
328
  // route, so it must not reach a different sync implementation.
275
- syncHandlers: syncHandlers
329
+ syncHandlers: syncHandlers,
330
+ // The service lane's answering half; absent, `setupHubRelay` stands up
331
+ // no service listeners, so a device without `serviceTunnel` is unchanged.
332
+ ...serviceServe != null ? {
333
+ serviceServe,
334
+ services: serviceConfig
335
+ } : {},
336
+ // The shared roster-seeded cache reaches the tunnel listeners here.
337
+ cache: didCache
276
338
  });
277
339
  // Turn a `tunnel://<groupID>/<peerDID>` route into a live session over the
278
340
  // group's hub. Until this existed the scheme parsed and then failed at
@@ -350,6 +412,9 @@ export function createP2PPlugin(options) {
350
412
  adapter,
351
413
  hlc,
352
414
  maxDriftMS: params.maxDriftMS,
415
+ // The engine's controller-resolver seam, so the credential reconcile-apply
416
+ // lane (catch-up) can authorize a delegate-signed wrapping it pulls back.
417
+ controllerResolverFor: params.controllerResolverFor,
353
418
  emitter,
354
419
  runtime: params.runtime,
355
420
  autoAcceptPeers: options?.autoAcceptPeers,
@@ -507,6 +572,7 @@ export function createP2PPlugin(options) {
507
572
  onHubServerDIDChanged: (listener)=>emitter.on('hubServerDIDChanged', listener),
508
573
  createSyncTransport,
509
574
  requestLedgerCatchup: (groupID, options)=>hub.requestLedgerCatchup(groupID, options),
575
+ serviceTransportTo: (groupID, peerDID)=>hub.serviceTransportTo(groupID, peerDID),
510
576
  setLocalPeerProfile: async (profile)=>{
511
577
  await (await hub.presence()).setProfile(profile);
512
578
  },
@@ -573,7 +639,8 @@ export function createP2PPlugin(options) {
573
639
  logger: params.getLogger('sync-http-server'),
574
640
  transports: [
575
641
  httpSyncTransport
576
- ]
642
+ ],
643
+ cache: didCache
577
644
  });
578
645
  syncServers.push(httpSyncServer);
579
646
  httpAPI.registerProtocol(protocolName, httpSyncTransport.fetch.bind(httpSyncTransport));
@@ -616,7 +683,8 @@ export function createP2PPlugin(options) {
616
683
  logger: params.getLogger('peer-http-server'),
617
684
  transports: [
618
685
  httpPeerTransport
619
- ]
686
+ ],
687
+ cache: didCache
620
688
  });
621
689
  httpAPI.registerProtocol('peer', httpPeerTransport.fetch.bind(httpPeerTransport));
622
690
  })();
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: [
package/lib/schema.js CHANGED
@@ -581,6 +581,12 @@ extend type Query {
581
581
  one group. The store is per-device.
582
582
  """
583
583
  controlRequests(groupID: ID): [ControlRequest!]!
584
+ """
585
+ Prove this device holds the complete credential set the owner's manifests
586
+ promised it: the union of every per-grantor manifest's keys, minus those that
587
+ actually decrypt-verify here. An empty missing list means the pull is complete.
588
+ """
589
+ credentialProvisioningStatus(ownerDID: DID!): CredentialProvisioningStatus!
584
590
  }
585
591
 
586
592
  enum GroupHealthCondition {
@@ -644,6 +650,48 @@ type ControlRequest implements Node {
644
650
  settledAt: DateTimeISO
645
651
  }
646
652
 
653
+ """
654
+ One key's outcome from a grantDeviceCredentials batch: what happened when this
655
+ device tried to hand one of the owner's active credential keys to the recipient
656
+ device. Never an error thrown out of the batch — every key reports its own fate.
657
+ """
658
+ type CredentialGrantOutcome {
659
+ keyID: ID!
660
+ """
661
+ The key's version after this call, or null when nothing was granted
662
+ (not-held / error outcomes carry no version).
663
+ """
664
+ version: Int
665
+ outcome: String!
666
+ """Present on not-held and error; the caught error's message."""
667
+ reason: String
668
+ }
669
+
670
+ type GrantDeviceCredentialsPayload {
671
+ results: [CredentialGrantOutcome!]!
672
+ """A grantor-signed manifest claiming exactly the granted/refreshed keys above."""
673
+ manifest: String!
674
+ """The provisioning epoch this manifest was allocated under."""
675
+ epoch: Int!
676
+ }
677
+
678
+ """
679
+ Readiness of this device's credential provisioning for one owner. A key counts
680
+ materialized only when its wrapping decrypt-verifies (or, for a zero-entry key,
681
+ merely opens) — presence and a valid signature alone never suffice.
682
+ """
683
+ type CredentialProvisioningStatus {
684
+ complete: Boolean!
685
+ """The expected keyIDs not yet materialized on this device."""
686
+ missing: [ID!]!
687
+ """
688
+ Why complete is false: not-initiated (no expectation on record yet),
689
+ pending (an attempt is in flight, its floor not yet bound), or incomplete
690
+ (asked for, not yet fully materialized/proven). Absent when complete.
691
+ """
692
+ reason: String
693
+ }
694
+
647
695
  extend type Mutation {
648
696
  connectPeer(url: String!): ConnectPeerPayload!
649
697
  sharePeerGroup(peerDID: ID!, groupID: ID, name: String, send: ShareInput, receive: ShareReceiveInput): SharePeerGroupPayload!
@@ -654,7 +702,15 @@ extend type Mutation {
654
702
  DID comes from the request's KeyPackage credential; a disagreeing did field, or a
655
703
  DID already on the roster, is refused.
656
704
  """
657
- admitJoinRequest(groupID: ID!, joinRequest: String!, send: ShareInput, receive: ShareReceiveInput): AdmitJoinRequestPayload!
705
+ admitJoinRequest(groupID: ID!, joinRequest: String!, grants: [String!], send: ShareInput, receive: ShareReceiveInput): AdmitJoinRequestPayload!
706
+ """
707
+ Grant a device recipientDID this device's active credential keys for ownerDID,
708
+ resolving the recipient from the group's MLS roster. Returns every key's
709
+ outcome plus a grantor-signed manifest the recipient checks its store against.
710
+ minEpoch floors the allocated epoch at the recipient's held epoch, so a
711
+ reprovision attempt always supersedes a manifest it already holds. Defaults to 0.
712
+ """
713
+ grantDeviceCredentials(groupID: ID!, recipientDID: DID!, ownerDID: DID!, minEpoch: Int): GrantDeviceCredentialsPayload!
658
714
  joinPeerGroup(peerDID: ID!, groupID: ID!): JoinPeerGroupPayload!
659
715
  """
660
716
  Apply one circle's desired sync end state. A null argument leaves that dimension
@@ -880,6 +936,11 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
880
936
  const requests = await requireP2P(context).group.listControlRequests(args.groupID);
881
937
  return requests.map(toControlRequestSDL);
882
938
  },
939
+ credentialProvisioningStatus: async (_source, args, context)=>{
940
+ return await requireP2P(context).peer.credentialProvisioningStatus({
941
+ ownerDID: args.ownerDID
942
+ });
943
+ },
883
944
  ownDelegationTokens: async (_source, args, context)=>{
884
945
  const viewerDID = context.getViewer();
885
946
  if (viewerDID == null || context.p2p == null) {
@@ -948,6 +1009,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
948
1009
  return await requireP2P(context).peer.admitJoinRequest({
949
1010
  groupID: args.groupID,
950
1011
  joinRequest: args.joinRequest,
1012
+ grants: args.grants ?? undefined,
951
1013
  sendModels: send?.models ?? null,
952
1014
  receiveActivate: receive?.activate ?? false,
953
1015
  // Same owner-signed executor `sharePeerGroup` binds, so the rows this
@@ -966,6 +1028,21 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
966
1028
  }
967
1029
  });
968
1030
  },
1031
+ grantDeviceCredentials: async (_source, args, context)=>{
1032
+ const { results, manifest, epoch } = await requireP2P(context).peer.grantDeviceCredentials({
1033
+ groupID: args.groupID,
1034
+ recipientDID: args.recipientDID,
1035
+ ownerDID: args.ownerDID,
1036
+ ...args.minEpoch == null ? {} : {
1037
+ minEpoch: args.minEpoch
1038
+ }
1039
+ });
1040
+ return {
1041
+ results,
1042
+ manifest,
1043
+ epoch
1044
+ };
1045
+ },
969
1046
  joinPeerGroup: async (_source, args, context)=>{
970
1047
  return await requireP2P(context).peer.joinPeerGroup({
971
1048
  peerDID: args.peerDID,
@@ -283,14 +283,33 @@ export { checkSyncDelegation };
283
283
  ...toWireOp(op),
284
284
  subjectID: op.subject_id
285
285
  }));
286
+ // Durable PULL repair, independent of live broadcast: manifests addressed
287
+ // to the authenticated `callerDID`, never an owner-supplied recipient,
288
+ // so a manifest never leaks to a device other than the one it names.
289
+ let manifests;
290
+ if (ctx.param.owners != null && ctx.param.owners.length > 0) {
291
+ // Independent per-owner lookups — query concurrently, preserve owner order.
292
+ const perOwner = await Promise.all(ctx.param.owners.map((owner)=>store.listManifests({
293
+ ownerDID: owner,
294
+ recipientDID: callerDID
295
+ })));
296
+ manifests = perOwner.flatMap((rows)=>rows.map((row)=>({
297
+ manifest: row.token,
298
+ delegationTokens: row.delegationTokens
299
+ })));
300
+ }
286
301
  logger.debug('sync/reconcile-credentials served', {
287
302
  callerDID,
288
303
  bundles: bundles.length,
289
- tombstones: tombstones.length
304
+ tombstones: tombstones.length,
305
+ manifests: manifests?.length ?? 0
290
306
  });
291
307
  return {
292
308
  bundles,
293
- tombstones
309
+ tombstones,
310
+ ...manifests != null && {
311
+ manifests
312
+ }
294
313
  };
295
314
  }
296
315
  });
@@ -0,0 +1,14 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ /**
3
+ * The device's own held read delegations for a set of scope owners, as the JWT
4
+ * strings an outbound catch-up rides in `delegationTokens`.
5
+ *
6
+ * Each token is a capability the owner (grantor) issued to this device
7
+ * (audience); presenting it lets the peer's `checkSyncDelegation` arm of
8
+ * `authorizeScope` authorize a pull of that owner's documents — e.g. a paired
9
+ * device reading a controller's documents via the pairing-time delegation.
10
+ *
11
+ * Keyed `grantor: ownerDID, audience: selfDID` (the direction the gate checks).
12
+ * Owners equal to `selfDID` are skipped; results are de-duplicated.
13
+ */
14
+ export declare function resolveHeldDelegationTokens(stores: StoreProvider, selfDID: string, owners: Iterable<string>): Promise<Array<string>>;
@@ -0,0 +1,34 @@
1
+ import { DELEGATION_STORE, getDelegationStore } from '@kubun/store-delegation';
2
+ /**
3
+ * The device's own held read delegations for a set of scope owners, as the JWT
4
+ * strings an outbound catch-up rides in `delegationTokens`.
5
+ *
6
+ * Each token is a capability the owner (grantor) issued to this device
7
+ * (audience); presenting it lets the peer's `checkSyncDelegation` arm of
8
+ * `authorizeScope` authorize a pull of that owner's documents — e.g. a paired
9
+ * device reading a controller's documents via the pairing-time delegation.
10
+ *
11
+ * Keyed `grantor: ownerDID, audience: selfDID` (the direction the gate checks).
12
+ * Owners equal to `selfDID` are skipped; results are de-duplicated.
13
+ */ export async function resolveHeldDelegationTokens(stores, selfDID, owners) {
14
+ const distinctOwners = new Set();
15
+ for (const owner of owners){
16
+ if (owner !== selfDID) {
17
+ distinctOwners.add(owner);
18
+ }
19
+ }
20
+ if (distinctOwners.size === 0 || !stores.hasStore(DELEGATION_STORE)) {
21
+ return [];
22
+ }
23
+ const delegationStore = await getDelegationStore(stores);
24
+ // Independent per-owner lookups — query concurrently; the Set dedups regardless of order.
25
+ const perOwner = await Promise.all([
26
+ ...distinctOwners
27
+ ].map((ownerDID)=>delegationStore.getDelegationTokens({
28
+ grantor: ownerDID,
29
+ audience: selfDID
30
+ })));
31
+ return [
32
+ ...new Set(perOwner.flat().map((row)=>row.token))
33
+ ];
34
+ }
@@ -0,0 +1,84 @@
1
+ import type { ServerTransportOf } from '@enkaku/protocol';
2
+ import type { Server } from '@enkaku/server';
3
+ import type { DIDCache } from '@kokuin/token';
4
+ import type { Logger } from '@kubun/logger';
5
+ import type { ServiceConfig, ServiceProtocol } from '@kubun/plugin-service-api';
6
+ import { type MailboxHub } from '@kumiai/hub-tunnel';
7
+ import type { Runtime } from '@sozai/runtime';
8
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
9
+ /**
10
+ * Mirrors `ServiceServerPluginAPI['serve']` (`@kubun/plugin-service-server`)
11
+ * without importing it, so this listener has no direct dependency on that
12
+ * plugin. `serve()` applies provider discovery, handler/access-rule merge,
13
+ * handler-authorization wrapping, and controller-DID verification that a bare
14
+ * `new Server` would not.
15
+ */
16
+ export type ServeService = (params: {
17
+ transport: ServerTransportOf<ServiceProtocol>;
18
+ services: Record<string, ServiceConfig>;
19
+ requireAuth?: false;
20
+ /** Shared, roster-seeded DID cache forwarded to the served Server. */
21
+ cache?: DIDCache;
22
+ }) => Server<ServiceProtocol>;
23
+ /**
24
+ * The injected {@link ServeService} was called before the `service-server`
25
+ * plugin API had resolved. Transient — the listener retries on its normal backoff.
26
+ */
27
+ export declare class ServiceServeNotReadyError extends Error {
28
+ constructor(message: string);
29
+ }
30
+ /**
31
+ * The injected {@link ServeService} failed for a reason a retry cannot change
32
+ * (plugin absent, unknown/colliding service name). Terminal — the listener
33
+ * stops answering for this peer instead of spinning on backoff.
34
+ */
35
+ export declare class ServiceServeUnavailableError extends Error {
36
+ constructor(message: string, options?: {
37
+ cause?: unknown;
38
+ });
39
+ }
40
+ export type HubTunnelServiceListenerParams = {
41
+ /**
42
+ * Shared multi-subscriber device hub. Every listener on a device receives on
43
+ * the same topic, so the hub is fronted by a per-peer view that drops other
44
+ * peers' frames before they reach the cipher.
45
+ */
46
+ hub: MailboxHub;
47
+ registry: GroupHandleRegistry;
48
+ groupID: string;
49
+ localDID: string;
50
+ peerDID: string;
51
+ /**
52
+ * Platform primitives, including the per-session id generator. Threaded in
53
+ * from the caller since this package runs on React Native and browser too,
54
+ * not just node.
55
+ */
56
+ runtime: Runtime;
57
+ /** Injected `plugin-service-server` `serve()` — see {@link ServeService}. */
58
+ serve: ServeService;
59
+ /** Which services to serve, forwarded verbatim to {@link serve} on every spawn. */
60
+ services: Record<string, ServiceConfig>;
61
+ /**
62
+ * Shared, roster-seeded DID cache forwarded to {@link serve} on every spawn,
63
+ * so a co-member's short-form `did:peer:4` issuer resolves on the served
64
+ * Server.
65
+ */
66
+ cache?: DIDCache;
67
+ idleTimeoutMs?: number;
68
+ logger?: Logger;
69
+ };
70
+ /**
71
+ * Answer side of the service tunnel — the same directed hub-tunnel machinery as
72
+ * `HubTunnelSyncListener`, on a distinct lane so a service session never
73
+ * collides with a sync session's topics or ratchet generation.
74
+ *
75
+ * Unlike the sync listener, this does not build a bare `Server`: it hands the
76
+ * spawned transport to the injected {@link ServeService} callback so
77
+ * provider discovery, handler wrapping, and controller-DID verification apply.
78
+ */
79
+ export declare class HubTunnelServiceListener {
80
+ #private;
81
+ constructor(params: HubTunnelServiceListenerParams);
82
+ start(): void;
83
+ stop(): Promise<void>;
84
+ }