@kubun/plugin-p2p 0.15.0 → 0.16.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 (35) hide show
  1. package/lib/groups/did-cache-seed.d.ts +26 -0
  2. package/lib/groups/did-cache-seed.js +38 -0
  3. package/lib/groups/group-crypto.d.ts +8 -9
  4. package/lib/groups/group-crypto.js +51 -55
  5. package/lib/groups/group-handlers.d.ts +50 -14
  6. package/lib/groups/group-handlers.js +190 -24
  7. package/lib/groups/group-peer-manager.d.ts +52 -12
  8. package/lib/groups/group-peer-manager.js +215 -58
  9. package/lib/groups/group-protocols.d.ts +407 -0
  10. package/lib/groups/group-protocols.js +279 -11
  11. package/lib/groups/mls-codec.d.ts +19 -13
  12. package/lib/groups/mls-codec.js +28 -15
  13. package/lib/groups/peer-presence.d.ts +6 -0
  14. package/lib/groups/peer-presence.js +5 -0
  15. package/lib/hub/http-client.js +6 -1
  16. package/lib/hub/hub-like.js +5 -2
  17. package/lib/hub/loopback-log-hub.js +4 -1
  18. package/lib/hub/peer-scoped-hub-view.d.ts +6 -0
  19. package/lib/hub/peer-scoped-hub-view.js +11 -1
  20. package/lib/hub/wiring.d.ts +36 -7
  21. package/lib/hub/wiring.js +18 -0
  22. package/lib/index.js +95 -20
  23. package/lib/sync/hub-tunnel-service-listener.d.ts +9 -0
  24. package/lib/sync/hub-tunnel-service-listener.js +6 -1
  25. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -1
  26. package/lib/sync/hub-tunnel-sync-listener.js +6 -1
  27. package/lib/sync/service-tunnel-listeners.d.ts +7 -1
  28. package/lib/sync/service-tunnel-listeners.js +18 -0
  29. package/lib/sync/sync-manager.d.ts +1 -5
  30. package/lib/sync/sync-manager.js +0 -5
  31. package/lib/sync/tunnel-listeners.d.ts +7 -1
  32. package/lib/sync/tunnel-listeners.js +18 -0
  33. package/lib/types.d.ts +15 -1
  34. package/lib/types.js +4 -0
  35. package/package.json +48 -47
package/lib/hub/wiring.js CHANGED
@@ -44,6 +44,12 @@ export function setupHubRelay(params) {
44
44
  };
45
45
  const presence = async ()=>(await ready).presence;
46
46
  const retryHubs = async ()=>await (await ready).retryHubs();
47
+ const discoverWorkflows = async (groupID, options)=>await (await ready).discoverWorkflows(groupID, options);
48
+ const requestWorkflow = async (groupID, targetDID, method, param)=>await (await ready).requestWorkflow(groupID, targetDID, method, param);
49
+ const reauthorize = async (groupID)=>{
50
+ ;
51
+ (await ready).reauthorize(groupID);
52
+ };
47
53
  const syncTransportTo = async (groupID, peerDID)=>{
48
54
  const hub = (await ready).tunnelHub(groupID);
49
55
  if (hub == null) {
@@ -85,6 +91,12 @@ export function setupHubRelay(params) {
85
91
  graphStore,
86
92
  graph,
87
93
  hlc,
94
+ ...params.ready != null ? {
95
+ ready: params.ready
96
+ } : {},
97
+ ...params.getWorkflowAPI != null ? {
98
+ getWorkflowAPI: params.getWorkflowAPI
99
+ } : {},
88
100
  maxDriftMS,
89
101
  localDID: identity.id,
90
102
  identity,
@@ -117,6 +129,9 @@ export function setupHubRelay(params) {
117
129
  ...params.services != null ? {
118
130
  services: params.services
119
131
  } : {},
132
+ ...params.cache != null ? {
133
+ cache: params.cache
134
+ } : {},
120
135
  tunnelIdleTimeoutMs: tunnelIdleTimeoutMs ?? DEFAULT_TUNNEL_IDLE_TIMEOUT_MS
121
136
  });
122
137
  unsubscribes.push(emitter.on('groupJoined', (group)=>manager.addGroup(group.id).catch((error)=>{
@@ -184,6 +199,9 @@ export function setupHubRelay(params) {
184
199
  requestLedgerCatchup,
185
200
  presence,
186
201
  retryHubs,
202
+ discoverWorkflows,
203
+ requestWorkflow,
204
+ reauthorize,
187
205
  syncTransportTo,
188
206
  serviceTransportTo,
189
207
  dispose: async ()=>{
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';
@@ -107,11 +107,10 @@ export function createP2PPlugin(options) {
107
107
  }
108
108
  const identity = params.identity;
109
109
  // The engine's single device-wide clock. Sharing it across the p2p group/
110
- // circle/member metadata paths (context, GroupManager, BroadcastService)
111
- // keeps every write from this device on one monotonic counter, so two
112
- // writes in the same millisecond mint strictly-increasing timestamps rather
113
- // than identical ones (the second of which would silently lose under
114
- // last-writer-wins).
110
+ // circle/member metadata paths keeps every write from this device on one
111
+ // monotonic counter, so two writes in the same millisecond mint
112
+ // strictly-increasing timestamps rather than identical ones (the second of
113
+ // which would silently lose under last-writer-wins).
115
114
  const hlc = params.hlc;
116
115
  // Computed up front so SyncManager + sync handlers + the hub-relay
117
116
  // BroadcastService all see the same receive policy. Hot-path invariant:
@@ -199,6 +198,13 @@ export function createP2PPlugin(options) {
199
198
  const peerConnections = new PeerConnectionRegistry({
200
199
  logger: params.getLogger('peer-connections')
201
200
  });
201
+ // One plugin-lifetime DID cache, shared by every peer-lane Server. The
202
+ // tunnel listeners seed it from each group's MLS roster (a co-member's
203
+ // authenticated leaf long form), so a freshly-spawned per-session Server
204
+ // resolves a co-member's short-form `did:peer:4` issuer instead of defaulting
205
+ // its own empty cache. Content-addressed and monotonic — a stale entry is
206
+ // harmless, and it survives no process restart by design (re-seeded on bind).
207
+ const didCache = createInMemoryDIDCache();
202
208
  // The p2p plugin manages its own Enkaku server for sync handlers,
203
209
  // separate from the graph service's server.
204
210
  const syncServers = [];
@@ -222,6 +228,7 @@ export function createP2PPlugin(options) {
222
228
  transports: [
223
229
  directTransports.server
224
230
  ],
231
+ cache: didCache,
225
232
  signal
226
233
  });
227
234
  syncServers.push(server);
@@ -290,18 +297,33 @@ export function createP2PPlugin(options) {
290
297
  runtime: params.runtime,
291
298
  db: params.db,
292
299
  hlc,
300
+ // The engine's identity gate, forwarded to the presence coordinator so a
301
+ // self-started announce waits for verification before it mints.
302
+ ready: ()=>params.engine.ready(),
303
+ // A thunk, not the resolved value: `getWorkflowAPI` is defined further down
304
+ // this factory body, so it is only in the temporal dead zone until the body
305
+ // finishes — and the manager invokes this on peer creation, long after.
306
+ getWorkflowAPI: ()=>getWorkflowAPI(),
293
307
  maxDriftMS: params.maxDriftMS,
294
308
  graph: params.graph,
295
309
  emitter,
296
310
  createHubClient,
297
311
  registry,
298
312
  // Re-drive a lost `ledger` commit's surviving tokens through the group
299
- // manager's own ledger write path, so the seam stays a consumer of it.
300
- buildLedgerRedrive: (groupID, tokens, requestID)=>groupManager.buildEnactLedgerCommit({
313
+ // manager's own ledger write path, so the seam stays a consumer of it. The
314
+ // redrive is a self-start (the hub relay, not a request), so its commit
315
+ // build waits behind `ready()` before it mints under the engine's clock.
316
+ buildLedgerRedrive: (groupID, tokens, requestID)=>{
317
+ const build = groupManager.buildEnactLedgerCommit({
301
318
  groupID,
302
319
  tokens,
303
320
  requestID
304
- }),
321
+ });
322
+ return async ()=>{
323
+ await params.engine.ready();
324
+ return build();
325
+ };
326
+ },
305
327
  logger: hubRelayLogger,
306
328
  storeUnreadable: receiveConfig.storeUnreadable,
307
329
  defaultAccessLevel,
@@ -324,7 +346,9 @@ export function createP2PPlugin(options) {
324
346
  ...serviceServe != null ? {
325
347
  serviceServe,
326
348
  services: serviceConfig
327
- } : {}
349
+ } : {},
350
+ // The shared roster-seeded cache reaches the tunnel listeners here.
351
+ cache: didCache
328
352
  });
329
353
  // Turn a `tunnel://<groupID>/<peerDID>` route into a live session over the
330
354
  // group's hub. Until this existed the scheme parsed and then failed at
@@ -424,30 +448,47 @@ export function createP2PPlugin(options) {
424
448
  // use, executed on behalf of the verified caller against the plugin's own
425
449
  // DB (peer procedures run outside an engine transaction; `joinGroup` manages
426
450
  // its own inner transaction). No MLS logic is reimplemented here.
451
+ // The inbound peer dance stamps group/roster/ledger metadata under the
452
+ // engine's clock outside any GraphQL write entry, so each runner is a
453
+ // self-start driver: it awaits `ready()` before touching the deps that mint.
454
+ // `ready()` is resolved for a booted engine, so this only delays a dance that
455
+ // arrives in the window before identity verification settles.
427
456
  const peerHandlers = createPeerHandlers({
428
457
  db: params.db,
429
458
  identity,
430
459
  logger: params.getLogger('peer-handlers'),
431
460
  autoAcceptPeers: options?.autoAcceptPeers,
432
- runPrepareJoin: (callerDID)=>createJoinContext(// Out-of-band peer-handler context for the verified caller; the join ops
461
+ runPrepareJoin: async (callerDID)=>{
462
+ await params.engine.ready();
463
+ return createJoinContext(// Out-of-band peer-handler context for the verified caller; the join ops
433
464
  // never consult the authority, so a self-scoped device authority fits.
434
465
  {
435
466
  viewerDID: callerDID,
436
467
  credentialAuthority: createDeviceAuthority(callerDID)
437
- }, buildContextDeps(params.db)).prepareRequest(),
438
- runCompleteJoin: (callerDID, invitePayload)=>createJoinContext({
468
+ }, buildContextDeps(params.db)).prepareRequest();
469
+ },
470
+ runCompleteJoin: async (callerDID, invitePayload)=>{
471
+ await params.engine.ready();
472
+ return createJoinContext({
439
473
  viewerDID: callerDID,
440
474
  credentialAuthority: createDeviceAuthority(callerDID)
441
- }, buildContextDeps(params.db)).complete(invitePayload),
442
- runInvite: (callerDID, groupID, joinRequest)=>serveGroupInvite(buildContextDeps(params.db), {
475
+ }, buildContextDeps(params.db)).complete(invitePayload);
476
+ },
477
+ runInvite: async (callerDID, groupID, joinRequest)=>{
478
+ await params.engine.ready();
479
+ return serveGroupInvite(buildContextDeps(params.db), {
443
480
  callerDID,
444
481
  groupID,
445
482
  joinRequest
446
- }),
447
- runPushControl: (callerDID, param)=>servePushControl(buildContextDeps(params.db), {
483
+ });
484
+ },
485
+ runPushControl: async (callerDID, param)=>{
486
+ await params.engine.ready();
487
+ return servePushControl(buildContextDeps(params.db), {
448
488
  callerDID,
449
489
  ...param
450
- })
490
+ });
491
+ }
451
492
  });
452
493
  // Blob transfer control-lane handlers, mounted on the same peer server.
453
494
  const blobHandlers = createBlobHandlers({
@@ -557,6 +598,7 @@ export function createP2PPlugin(options) {
557
598
  // runners do: the scopes an automatic catch-up asks for cannot drift from
558
599
  // the ones an explicit one asks for if there is only one implementation.
559
600
  catchUpWithBestPeer: (groupID)=>createSyncContext(null, buildContextDeps(params.db)).catchUpWithBestPeer(groupID),
601
+ reauthorize: (groupID)=>hub.reauthorize(groupID),
560
602
  listPeerDevices: (groupID)=>createSyncContext(null, buildContextDeps(params.db)).listPeerDevices(groupID),
561
603
  onSyncEvent: (callback)=>syncManager.onSyncEvent(callback),
562
604
  onHubServerDIDChanged: (listener)=>emitter.on('hubServerDIDChanged', listener),
@@ -567,6 +609,37 @@ export function createP2PPlugin(options) {
567
609
  await (await hub.presence()).setProfile(profile);
568
610
  },
569
611
  getLocalPeerProfile: async ()=>await (await hub.presence()).getProfile(),
612
+ // The typed workflow control plane: `groupID` selects the peers, the hub
613
+ // wiring handles the multi-hub fan-out (discover) and directed `.to()`
614
+ // request (the rest). The wire results are shaped to the contract types
615
+ // here; a business refusal on a command travels as data, and a caller
616
+ // helper (plugin-workflow) reconstitutes it into a throw.
617
+ discoverWorkflows: (groupID, options)=>hub.discoverWorkflows(groupID, options),
618
+ listWorkflows: async (groupID, targetDID)=>// `workflow/list` takes no fields but its param schema is a (closed) object,
619
+ // so an omitted param serializes to an invalid request frame — pass `{}`.
620
+ await hub.requestWorkflow(groupID, targetDID, 'workflow/list', {}),
621
+ workflowStatus: async (groupID, targetDID, instanceID)=>await hub.requestWorkflow(groupID, targetDID, 'workflow/status', {
622
+ instanceID
623
+ }),
624
+ enqueueWorkflow: async (groupID, targetDID, name, params, opts)=>await hub.requestWorkflow(groupID, targetDID, 'workflow/enqueue', {
625
+ name,
626
+ params,
627
+ // A stable idempotency key makes the cross-hub retry safe: a directed
628
+ // request can throw on the RESPONSE leg (the target already enqueued,
629
+ // but the reply was lost), and the retry re-sends to the same mailbox.
630
+ // Under one key the target returns the existing instance instead of
631
+ // creating a second. A caller-supplied key wins; else one per call.
632
+ opts: {
633
+ ...opts,
634
+ idempotencyKey: opts?.idempotencyKey ?? crypto.randomUUID()
635
+ }
636
+ }),
637
+ cancelWorkflow: async (groupID, targetDID, instanceID)=>await hub.requestWorkflow(groupID, targetDID, 'workflow/cancel', {
638
+ instanceID
639
+ }),
640
+ retryWorkflow: async (groupID, targetDID, instanceID)=>await hub.requestWorkflow(groupID, targetDID, 'workflow/retry', {
641
+ instanceID
642
+ }),
570
643
  gatherPeers: async (groupID, options)=>await (await hub.presence()).gather(groupID, options),
571
644
  refreshPeerPresence: async (groupID, options)=>await (await hub.presence()).refresh(groupID, options),
572
645
  retryHubConnection: async ()=>await hub.retryHubs(),
@@ -629,7 +702,8 @@ export function createP2PPlugin(options) {
629
702
  logger: params.getLogger('sync-http-server'),
630
703
  transports: [
631
704
  httpSyncTransport
632
- ]
705
+ ],
706
+ cache: didCache
633
707
  });
634
708
  syncServers.push(httpSyncServer);
635
709
  httpAPI.registerProtocol(protocolName, httpSyncTransport.fetch.bind(httpSyncTransport));
@@ -672,7 +746,8 @@ export function createP2PPlugin(options) {
672
746
  logger: params.getLogger('peer-http-server'),
673
747
  transports: [
674
748
  httpPeerTransport
675
- ]
749
+ ],
750
+ cache: didCache
676
751
  });
677
752
  httpAPI.registerProtocol('peer', httpPeerTransport.fetch.bind(httpPeerTransport));
678
753
  })();
@@ -1,5 +1,6 @@
1
1
  import type { ServerTransportOf } from '@enkaku/protocol';
2
2
  import type { Server } from '@enkaku/server';
3
+ import type { DIDCache } from '@kokuin/token';
3
4
  import type { Logger } from '@kubun/logger';
4
5
  import type { ServiceConfig, ServiceProtocol } from '@kubun/plugin-service-api';
5
6
  import { type MailboxHub } from '@kumiai/hub-tunnel';
@@ -16,6 +17,8 @@ export type ServeService = (params: {
16
17
  transport: ServerTransportOf<ServiceProtocol>;
17
18
  services: Record<string, ServiceConfig>;
18
19
  requireAuth?: false;
20
+ /** Shared, roster-seeded DID cache forwarded to the served Server. */
21
+ cache?: DIDCache;
19
22
  }) => Server<ServiceProtocol>;
20
23
  /**
21
24
  * The injected {@link ServeService} was called before the `service-server`
@@ -55,6 +58,12 @@ export type HubTunnelServiceListenerParams = {
55
58
  serve: ServeService;
56
59
  /** Which services to serve, forwarded verbatim to {@link serve} on every spawn. */
57
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;
58
67
  idleTimeoutMs?: number;
59
68
  logger?: Logger;
60
69
  };
@@ -40,6 +40,7 @@ import { SERVICE_TUNNEL_PROTOCOL, tunnelTopic } from './tunnel-topics.js';
40
40
  #runtime;
41
41
  #serve;
42
42
  #services;
43
+ #cache;
43
44
  #idleTimeoutMs;
44
45
  #logger;
45
46
  #started = false;
@@ -64,6 +65,7 @@ import { SERVICE_TUNNEL_PROTOCOL, tunnelTopic } from './tunnel-topics.js';
64
65
  this.#runtime = params.runtime;
65
66
  this.#serve = params.serve;
66
67
  this.#services = params.services;
68
+ this.#cache = params.cache;
67
69
  this.#idleTimeoutMs = params.idleTimeoutMs;
68
70
  this.#logger = params.logger;
69
71
  }
@@ -225,7 +227,10 @@ import { SERVICE_TUNNEL_PROTOCOL, tunnelTopic } from './tunnel-topics.js';
225
227
  try {
226
228
  server = this.#serve({
227
229
  transport,
228
- services: this.#services
230
+ services: this.#services,
231
+ ...this.#cache != null ? {
232
+ cache: this.#cache
233
+ } : {}
229
234
  });
230
235
  } catch (error) {
231
236
  await transport.dispose().catch(()=>{
@@ -1,5 +1,5 @@
1
1
  import { type ProcedureHandlers } from '@enkaku/server';
2
- import type { OwnIdentity } from '@kokuin/token';
2
+ import type { DIDCache, OwnIdentity } from '@kokuin/token';
3
3
  import type { Logger } from '@kubun/logger';
4
4
  import { type MailboxHub, type ObservabilityEventListener } from '@kumiai/hub-tunnel';
5
5
  import type { Runtime } from '@sozai/runtime';
@@ -26,6 +26,13 @@ export type HubTunnelSyncListenerParams = {
26
26
  * keeps one generator across the whole plugin.
27
27
  */
28
28
  runtime: Runtime;
29
+ /**
30
+ * Shared, roster-seeded DID cache handed to every spawned session's Server, so
31
+ * a co-member's short-form `did:peer:4` issuer resolves without a resolver the
32
+ * blind hub lane has none of. A fresh per-session Server would otherwise
33
+ * default its own empty cache.
34
+ */
35
+ cache?: DIDCache;
29
36
  idleTimeoutMs?: number;
30
37
  reconnectTimeoutMs?: number;
31
38
  inboxCapacity?: number;
@@ -15,6 +15,7 @@ export class HubTunnelSyncListener {
15
15
  #identity;
16
16
  #syncHandlers;
17
17
  #runtime;
18
+ #cache;
18
19
  #idleTimeoutMs;
19
20
  #reconnectTimeoutMs;
20
21
  #inboxCapacity;
@@ -42,6 +43,7 @@ export class HubTunnelSyncListener {
42
43
  this.#identity = params.identity;
43
44
  this.#syncHandlers = params.syncHandlers;
44
45
  this.#runtime = params.runtime;
46
+ this.#cache = params.cache;
45
47
  this.#idleTimeoutMs = params.idleTimeoutMs;
46
48
  this.#reconnectTimeoutMs = params.reconnectTimeoutMs;
47
49
  this.#inboxCapacity = params.inboxCapacity;
@@ -203,7 +205,10 @@ export class HubTunnelSyncListener {
203
205
  },
204
206
  transports: [
205
207
  transport
206
- ]
208
+ ],
209
+ ...this.#cache != null ? {
210
+ cache: this.#cache
211
+ } : {}
207
212
  });
208
213
  const session = {
209
214
  transport,
@@ -1,4 +1,4 @@
1
- import type { OwnIdentity } from '@kokuin/token';
1
+ import type { DIDCache, OwnIdentity } from '@kokuin/token';
2
2
  import type { StoreProvider } from '@kubun/db';
3
3
  import type { Logger } from '@kubun/logger';
4
4
  import type { ServiceConfig } from '@kubun/plugin-service-api';
@@ -17,6 +17,12 @@ export type ServiceTunnelListenersParams = {
17
17
  serve: ServeService;
18
18
  /** Which services to serve, forwarded verbatim to `serve` on every spawn. */
19
19
  services: Record<string, ServiceConfig>;
20
+ /**
21
+ * Shared, plugin-lifetime DID cache. Seeded from each group's roster on every
22
+ * reconcile and forwarded to `serve` on every spawn, so a co-member's
23
+ * short-form `did:peer:4` issuer resolves on the served Server.
24
+ */
25
+ cache?: DIDCache;
20
26
  /** The group's device-wide drain, or `undefined` when no hub is bound. */
21
27
  tunnelHub: (groupID: string) => MailboxHub | undefined;
22
28
  logger?: Logger;
@@ -1,5 +1,6 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
2
  import { getP2PStore } from '@kubun/store-p2p';
3
+ import { seedDIDCacheFromRoster } from '../groups/did-cache-seed.js';
3
4
  import { HubTunnelServiceListener } from './hub-tunnel-service-listener.js';
4
5
  /**
5
6
  * The answering half of the service tunnel — one `HubTunnelServiceListener` per
@@ -63,6 +64,20 @@ import { HubTunnelServiceListener } from './hub-tunnel-service-listener.js';
63
64
  }
64
65
  attachedHubs.set(groupID, hub);
65
66
  attachedEpochs.set(groupID, epoch);
67
+ // Bind and membership change both land here, so this is the one seam where a
68
+ // co-member's roster long form is refreshed into the shared cache before any
69
+ // service session that must resolve its short-form issuer can spawn.
70
+ if (params.cache != null) {
71
+ await seedDIDCacheFromRoster({
72
+ cache: params.cache,
73
+ registry: params.registry,
74
+ groupID,
75
+ stores: params.stores,
76
+ ...logger != null ? {
77
+ logger
78
+ } : {}
79
+ });
80
+ }
66
81
  const members = await store.listGroupMembers(groupID);
67
82
  const wanted = new Set(members.map((member)=>normalizeDID(member.member_did)).filter((did)=>did !== selfDID));
68
83
  let group = listeners.get(groupID);
@@ -105,6 +120,9 @@ import { HubTunnelServiceListener } from './hub-tunnel-service-listener.js';
105
120
  runtime: params.runtime,
106
121
  serve: params.serve,
107
122
  services: params.services,
123
+ ...params.cache != null ? {
124
+ cache: params.cache
125
+ } : {},
108
126
  ...params.idleTimeoutMs != null ? {
109
127
  idleTimeoutMs: params.idleTimeoutMs
110
128
  } : {},
@@ -1,4 +1,4 @@
1
- import { type Identity, type SigningIdentity } from '@kokuin/token';
1
+ import { type Identity } from '@kokuin/token';
2
2
  import type { StoreProvider } from '@kubun/db';
3
3
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
4
4
  import type { Logger } from '@kubun/logger';
@@ -76,10 +76,6 @@ export declare class SyncManager {
76
76
  #private;
77
77
  constructor(params: SyncManagerParams);
78
78
  get peerRegistry(): PeerRegistry;
79
- /**
80
- * Configure the signing identity used for sync operations.
81
- */
82
- setIdentity(identity: SigningIdentity): void;
83
79
  /**
84
80
  * Configure the resolver for the transports built locally rather than dialled:
85
81
  * `direct://` (in-process) and `tunnel://` (relayed by the group's hub, whose
@@ -36,11 +36,6 @@ export class SyncManager {
36
36
  return this.#peerRegistry;
37
37
  }
38
38
  /**
39
- * Configure the signing identity used for sync operations.
40
- */ setIdentity(identity) {
41
- this.#identity = identity;
42
- }
43
- /**
44
39
  * Configure the resolver for the transports built locally rather than dialled:
45
40
  * `direct://` (in-process) and `tunnel://` (relayed by the group's hub, whose
46
41
  * group id arrives as the resolver's `route`).
@@ -1,5 +1,5 @@
1
1
  import type { ProcedureHandlers } from '@enkaku/server';
2
- import type { OwnIdentity } from '@kokuin/token';
2
+ import type { DIDCache, OwnIdentity } from '@kokuin/token';
3
3
  import type { StoreProvider } from '@kubun/db';
4
4
  import type { Logger } from '@kubun/logger';
5
5
  import type { MailboxHub } from '@kumiai/hub-tunnel';
@@ -13,6 +13,12 @@ export type TunnelListenersParams = {
13
13
  localDID: string;
14
14
  runtime: Runtime;
15
15
  syncHandlers: ProcedureHandlers<SyncProtocol>;
16
+ /**
17
+ * Shared, plugin-lifetime DID cache. Seeded from each group's roster on every
18
+ * reconcile (bind and membership change) and handed to every spawned session's
19
+ * Server, so a co-member's short-form `did:peer:4` issuer resolves there.
20
+ */
21
+ cache?: DIDCache;
16
22
  /** The group's device-wide drain, or `undefined` when no hub is bound. */
17
23
  tunnelHub: (groupID: string) => MailboxHub | undefined;
18
24
  logger?: Logger;
@@ -1,5 +1,6 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
2
  import { getP2PStore } from '@kubun/store-p2p';
3
+ import { seedDIDCacheFromRoster } from '../groups/did-cache-seed.js';
3
4
  import { HubTunnelSyncListener } from './hub-tunnel-sync-listener.js';
4
5
  /**
5
6
  * The answering half of the tunnel: one listener per co-member per group, so a
@@ -73,6 +74,20 @@ import { HubTunnelSyncListener } from './hub-tunnel-sync-listener.js';
73
74
  }
74
75
  attachedHubs.set(groupID, hub);
75
76
  attachedEpochs.set(groupID, epoch);
77
+ // Bind and membership change both land here, so this is the one seam where a
78
+ // co-member's roster long form is refreshed into the shared cache before any
79
+ // session that must resolve its short-form issuer can spawn.
80
+ if (params.cache != null) {
81
+ await seedDIDCacheFromRoster({
82
+ cache: params.cache,
83
+ registry: params.registry,
84
+ groupID,
85
+ stores: params.stores,
86
+ ...logger != null ? {
87
+ logger
88
+ } : {}
89
+ });
90
+ }
76
91
  const members = await store.listGroupMembers(groupID);
77
92
  const wanted = new Set(members.map((member)=>normalizeDID(member.member_did)).filter((did)=>did !== selfDID));
78
93
  let group = listeners.get(groupID);
@@ -115,6 +130,9 @@ import { HubTunnelSyncListener } from './hub-tunnel-sync-listener.js';
115
130
  identity: params.identity,
116
131
  syncHandlers: params.syncHandlers,
117
132
  runtime: params.runtime,
133
+ ...params.cache != null ? {
134
+ cache: params.cache
135
+ } : {},
118
136
  ...params.idleTimeoutMs != null ? {
119
137
  idleTimeoutMs: params.idleTimeoutMs
120
138
  } : {},
package/lib/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ClientTransportOf } from '@enkaku/protocol';
2
+ import type { WorkflowControlPlaneAPI } from '@kubun/plugin-p2p-api';
2
3
  import type { ServiceProtocol } from '@kubun/plugin-service-api';
3
4
  import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
4
5
  import type { AccessLevel } from '@kubun/store-graph';
@@ -1082,7 +1083,7 @@ export type PeerGatherOptions = {
1082
1083
  * of the logs, and nothing branches on it.
1083
1084
  */
1084
1085
  export type PresenceReason = 'hub-connected' | 'epoch-changed' | 'app-window-pruned' | 'requested';
1085
- export type SyncPluginAPI = {
1086
+ export type SyncPluginAPI = WorkflowControlPlaneAPI & {
1086
1087
  /** Resolves when the HTTP sync transport is registered (only present when http option is enabled). */
1087
1088
  syncReady?: Promise<void>;
1088
1089
  /**
@@ -1109,6 +1110,19 @@ export type SyncPluginAPI = {
1109
1110
  * decides who, and an explicit `syncPeer` ignores the ranking entirely.
1110
1111
  */
1111
1112
  catchUpWithBestPeer(groupID: string): Promise<PeerCatchUpData>;
1113
+ /**
1114
+ * Re-drive any group subscription this device's hub-peers latched as refused.
1115
+ *
1116
+ * A device subscribes its group topics the instant it joins, before the
1117
+ * APPLICATION-level authorization that gates them at the hub has landed — so
1118
+ * the hub answers `AuthorizationDeniedError` and the peer's mux latches it
1119
+ * permanent (a busy retry against an answer would be worse). This layer cannot
1120
+ * see the app's authorization state, so it cannot know on its own when to ask
1121
+ * again; the app calls this once it knows the device is authorized (e.g. its
1122
+ * membership row has replicated after pairing completes). Idempotent and a
1123
+ * no-op when nothing is refused — safe to call on every authorization change.
1124
+ */
1125
+ reauthorize(groupID: string): Promise<void>;
1112
1126
  /**
1113
1127
  * Every device this one has heard announce in the group — the projection, and
1114
1128
  * never a liveness answer. {@link SyncPluginAPI.gatherPeers} is what says who
package/lib/types.js CHANGED
@@ -1 +1,5 @@
1
+ // The six caller-facing workflow control-plane methods (discover/list/status/
2
+ // enqueue/cancel/retry, each keyed by `groupID`) are contributed by
3
+ // WorkflowControlPlaneAPI so this plugin's public surface and the type-only
4
+ // contract plugin-workflow imports cannot drift.
1
5
  export { };