@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
@@ -0,0 +1,294 @@
1
+ import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
2
+ import { APP_TOPIC_LABEL } from '@kumiai/rpc';
3
+ import { createGroupCrypto } from '../groups/group-crypto.js';
4
+ import { MLSEncryptor } from '../groups/mls-encryptor.js';
5
+ import { createPeerScopedHubView } from '../hub/peer-scoped-hub-view.js';
6
+ import { SERVICE_TUNNEL_PROTOCOL, tunnelTopic } from './tunnel-topics.js';
7
+ /**
8
+ * The injected {@link ServeService} was called before the `service-server`
9
+ * plugin API had resolved. Transient — the listener retries on its normal backoff.
10
+ */ export class ServiceServeNotReadyError extends Error {
11
+ constructor(message){
12
+ super(message);
13
+ this.name = 'ServiceServeNotReadyError';
14
+ }
15
+ }
16
+ /**
17
+ * The injected {@link ServeService} failed for a reason a retry cannot change
18
+ * (plugin absent, unknown/colliding service name). Terminal — the listener
19
+ * stops answering for this peer instead of spinning on backoff.
20
+ */ export class ServiceServeUnavailableError extends Error {
21
+ constructor(message, options){
22
+ super(message, options);
23
+ this.name = 'ServiceServeUnavailableError';
24
+ }
25
+ }
26
+ /**
27
+ * Answer side of the service tunnel — the same directed hub-tunnel machinery as
28
+ * `HubTunnelSyncListener`, on a distinct lane so a service session never
29
+ * collides with a sync session's topics or ratchet generation.
30
+ *
31
+ * Unlike the sync listener, this does not build a bare `Server`: it hands the
32
+ * spawned transport to the injected {@link ServeService} callback so
33
+ * provider discovery, handler wrapping, and controller-DID verification apply.
34
+ */ export class HubTunnelServiceListener {
35
+ #hub;
36
+ #registry;
37
+ #groupID;
38
+ #localDID;
39
+ #peerDID;
40
+ #runtime;
41
+ #serve;
42
+ #services;
43
+ #cache;
44
+ #idleTimeoutMs;
45
+ #logger;
46
+ #started = false;
47
+ #stopped = false;
48
+ /** Consecutive failed spawns, for the re-arm backoff. Reset by a success. */ #respawnAttempt = 0;
49
+ #respawnTimer;
50
+ #current;
51
+ // Single MLSEncryptor reused across spawns. Ordering across overlapping
52
+ // spawns (old transport's last decrypt vs. new spawn's first encrypt) is
53
+ // covered by the GroupHandleRegistry's per-group mutex.
54
+ #encryptor;
55
+ // Per-peer view over the device hub, built once and shared by every spawn: it
56
+ // holds the inbox subscription for the listener's whole life, so respawns
57
+ // neither re-arm nor release a topic other listeners are draining.
58
+ #peerHub;
59
+ constructor(params){
60
+ this.#hub = params.hub;
61
+ this.#registry = params.registry;
62
+ this.#groupID = params.groupID;
63
+ this.#localDID = params.localDID;
64
+ this.#peerDID = params.peerDID;
65
+ this.#runtime = params.runtime;
66
+ this.#serve = params.serve;
67
+ this.#services = params.services;
68
+ this.#cache = params.cache;
69
+ this.#idleTimeoutMs = params.idleTimeoutMs;
70
+ this.#logger = params.logger;
71
+ }
72
+ start() {
73
+ if (this.#started || this.#stopped) return;
74
+ this.#started = true;
75
+ this.#encryptor = new MLSEncryptor({
76
+ registry: this.#registry,
77
+ groupID: this.#groupID
78
+ });
79
+ this.#peerHub = createPeerScopedHubView({
80
+ hub: this.#hub,
81
+ peerDID: this.#peerDID
82
+ });
83
+ this.#spawnGuarded();
84
+ }
85
+ /**
86
+ * Spawn, and survive a spawn that throws. `#spawn` can reject (e.g.
87
+ * `exportSecret` when the group handle is unavailable); left bare that would
88
+ * be both an unhandled rejection and a listener that never answers again,
89
+ * since the only other respawn trigger — the transport's `disposed` event —
90
+ * can't fire for a session that was never built.
91
+ */ #spawnGuarded() {
92
+ void this.#spawn().then(()=>{
93
+ this.#respawnAttempt = 0;
94
+ }).catch((error)=>{
95
+ // Terminal serve() failure (plugin absent, unknown/colliding service):
96
+ // stop rather than respawn forever. `stop()` marks `#stopped`, so the
97
+ // scheduled-respawn path becomes a no-op; the dialing side idles out and
98
+ // falls back exactly as it does for a peer that never answers.
99
+ if (error instanceof ServiceServeUnavailableError) {
100
+ this.#logger?.error('hub tunnel service listener stopping: serve() failed permanently; the service lane will not answer for this peer', {
101
+ lane: 'service',
102
+ groupID: this.#groupID,
103
+ peerDID: this.#peerDID,
104
+ error
105
+ });
106
+ void this.stop();
107
+ return;
108
+ }
109
+ this.#logger?.warn('hub tunnel service listener spawn failed', {
110
+ lane: 'service',
111
+ groupID: this.#groupID,
112
+ peerDID: this.#peerDID,
113
+ attempt: this.#respawnAttempt,
114
+ error
115
+ });
116
+ this.#scheduleRespawn();
117
+ });
118
+ }
119
+ #scheduleRespawn() {
120
+ if (this.#stopped || this.#respawnTimer != null) return;
121
+ // Bounded: a handle that is unavailable because the group was left never
122
+ // becomes available, and this must not spin on it.
123
+ const delay = Math.min(30_000, 250 * 2 ** Math.min(this.#respawnAttempt++, 7));
124
+ const timer = setTimeout(()=>{
125
+ this.#respawnTimer = undefined;
126
+ if (this.#stopped) return;
127
+ this.#spawnGuarded();
128
+ }, delay);
129
+ timer.unref?.();
130
+ this.#respawnTimer = timer;
131
+ }
132
+ async stop() {
133
+ if (this.#stopped) return;
134
+ this.#stopped = true;
135
+ if (this.#respawnTimer != null) {
136
+ clearTimeout(this.#respawnTimer);
137
+ this.#respawnTimer = undefined;
138
+ }
139
+ const current = this.#current;
140
+ this.#current = undefined;
141
+ if (current != null) {
142
+ try {
143
+ await current.transport.dispose();
144
+ } catch {
145
+ // ignore — best-effort teardown
146
+ }
147
+ try {
148
+ await current.server.dispose();
149
+ } catch {
150
+ // ignore — best-effort teardown
151
+ }
152
+ }
153
+ this.#encryptor = undefined;
154
+ this.#peerHub = undefined;
155
+ }
156
+ async #spawn() {
157
+ if (this.#stopped) return;
158
+ const encryptor = this.#encryptor;
159
+ const peerHub = this.#peerHub;
160
+ if (encryptor == null || peerHub == null) {
161
+ throw new Error('HubTunnelServiceListener: not initialized; call start() first');
162
+ }
163
+ // Epoch-bound, role-scoped topics for this spawn, on the SERVICE lane — a
164
+ // distinct protocol label so a service session never shares a ratchet
165
+ // generation with a sync session. The server listens on this device's
166
+ // RESPONDER topic and replies on the peer's DIALER topic — the mirror of
167
+ // the dialing side, and disjoint from it. Topics rotate with the MLS
168
+ // epoch, so each spawn re-derives them; MLS decrypt inside the registry
169
+ // lock remains the authoritative membership/forward-secrecy gate.
170
+ // Seed epoch is unread here: this port never classifies commit frames.
171
+ const crypto = createGroupCrypto({
172
+ registry: this.#registry,
173
+ groupID: this.#groupID,
174
+ initialEpoch: 0,
175
+ runtime: this.#runtime
176
+ });
177
+ const secret = await crypto.exportSecret(APP_TOPIC_LABEL);
178
+ const epoch = crypto.epoch();
179
+ const sendTopicID = tunnelTopic(secret, epoch, 'dialer', this.#peerDID, SERVICE_TUNNEL_PROTOCOL);
180
+ const receiveTopicID = tunnelTopic(secret, epoch, 'responder', this.#localDID, SERVICE_TUNNEL_PROTOCOL);
181
+ // A stop() that landed while we awaited the handle must abort the spawn.
182
+ if (this.#stopped) return;
183
+ // No sessionID: a responder locks to whatever session dials it, so the id
184
+ // is not known until the first frame. `role` is what separates this
185
+ // listener's log lines from the dialer's on the same device at a glance.
186
+ const sessionLogger = this.#logger?.with({
187
+ role: 'listener',
188
+ lane: 'service',
189
+ groupID: this.#groupID,
190
+ peerDID: this.#peerDID,
191
+ epoch,
192
+ receiveTopicID
193
+ });
194
+ sessionLogger?.debug('tunnel session opening');
195
+ let transportRef;
196
+ const transport = createEncryptedHubTunnelTransport({
197
+ hub: peerHub,
198
+ encryptor,
199
+ groupID: this.#groupID,
200
+ sessionID: {
201
+ auto: true
202
+ },
203
+ localDID: this.#localDID,
204
+ sendTopicID,
205
+ receiveTopicID,
206
+ idleTimeoutMs: this.#idleTimeoutMs,
207
+ onSessionEnd: ()=>{
208
+ // Peer signaled end-of-session. Dispose the transport deterministically
209
+ // so the `disposed` event fires and the spawn loop re-arms for the
210
+ // next session arriving on the shared device drain.
211
+ void transportRef?.dispose().catch(()=>{
212
+ // ignore — best-effort
213
+ });
214
+ }
215
+ });
216
+ transportRef = transport;
217
+ // Hand the transport to the injected `serve()` rather than building a bare
218
+ // `Server` — the delta from the sync listener.
219
+ //
220
+ // `serve()` can throw synchronously (plugin API not yet resolved, or
221
+ // genuinely absent). By this point `transport` already exists and has
222
+ // already subscribed the hub, with no `disposed` handler attached yet to
223
+ // re-arm anything — left undisposed here, a throw would leak one
224
+ // orphaned, still-subscribed transport per retry. Dispose before
225
+ // rethrowing so every failed attempt cleans up after itself.
226
+ let server;
227
+ try {
228
+ server = this.#serve({
229
+ transport,
230
+ services: this.#services,
231
+ ...this.#cache != null ? {
232
+ cache: this.#cache
233
+ } : {}
234
+ });
235
+ } catch (error) {
236
+ await transport.dispose().catch(()=>{
237
+ // ignore — best-effort teardown
238
+ });
239
+ // Preserve transient/terminal classification for #spawnGuarded: a
240
+ // not-ready or already-terminal error rethrows as-is; any other throw is
241
+ // a permanent misconfiguration, wrapped terminal so the listener stops.
242
+ if (error instanceof ServiceServeNotReadyError || error instanceof ServiceServeUnavailableError) {
243
+ throw error;
244
+ }
245
+ throw new ServiceServeUnavailableError('service tunnel serve() failed permanently', {
246
+ cause: error
247
+ });
248
+ }
249
+ const session = {
250
+ transport,
251
+ server
252
+ };
253
+ this.#current = session;
254
+ // Defence in depth: no `await` sits between the `#stopped` check above and
255
+ // this assignment today, but re-checking after the assignment means a
256
+ // `stop()` landing in a future await window still gets torn down —
257
+ // whichever side runs second does the teardown.
258
+ if (this.#stopped) {
259
+ this.#current = undefined;
260
+ try {
261
+ await transport.dispose();
262
+ } catch {
263
+ // ignore — best-effort teardown
264
+ }
265
+ try {
266
+ await server.dispose();
267
+ } catch {
268
+ // ignore — best-effort teardown
269
+ }
270
+ return;
271
+ }
272
+ // `disposed` may fire more than once for a single session (disposing the
273
+ // server can re-enter the transport's dispose). Collapse to exactly one
274
+ // teardown + respawn so one session leaves exactly one successor.
275
+ let handled = false;
276
+ transport.events.on('disposed', ()=>{
277
+ if (handled) return;
278
+ handled = true;
279
+ sessionLogger?.debug('tunnel session closed');
280
+ if (this.#current === session) {
281
+ this.#current = undefined;
282
+ }
283
+ // Server stays around until its handlers drain; explicit cleanup here so
284
+ // long-running sessions don't leak server instances.
285
+ void server.dispose().catch(()=>{
286
+ // ignore
287
+ });
288
+ // Nothing re-arms the inbox here — this fires even after stop(), but the
289
+ // per-peer view never releases the topic, so a departing transport's
290
+ // teardown can't empty it. Respawn re-derives topics for the next session.
291
+ this.#spawnGuarded();
292
+ });
293
+ }
294
+ }
@@ -0,0 +1,46 @@
1
+ import type { ClientTransportOf } from '@enkaku/protocol';
2
+ import type { Logger } from '@kubun/logger';
3
+ import type { ServiceProtocol } from '@kubun/plugin-service-api';
4
+ import type { Runtime } from '@sozai/runtime';
5
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
6
+ import type { DeviceHub } from '../hub/hub-like.js';
7
+ export type HubTunnelServiceProviderParams = {
8
+ /**
9
+ * Shared multi-subscriber device hub (from `createHubLike`). Each session
10
+ * attaches its own sink to this device-wide drain, narrowed to the peer it
11
+ * dialled, and filters to `receiveTopicID`; lifecycle events ride
12
+ * `hub.events`.
13
+ */
14
+ hub: DeviceHub;
15
+ registry: GroupHandleRegistry;
16
+ groupID: string;
17
+ localDID: string;
18
+ peerDID: string;
19
+ /**
20
+ * Platform primitives, including the per-session id generator. Threaded in
21
+ * from the caller since this package runs on React Native and browser too,
22
+ * not just node.
23
+ */
24
+ runtime: Runtime;
25
+ idleTimeoutMs?: number;
26
+ logger?: Logger;
27
+ };
28
+ /**
29
+ * Dial side of the SERVICE tunnel — the same directed hub-tunnel machinery as
30
+ * {@link HubTunnelSyncProvider}, riding a distinct lane so a service session to
31
+ * a peer never collides with a sync session's topics or ratchet generation.
32
+ */
33
+ export declare class HubTunnelServiceProvider {
34
+ #private;
35
+ constructor(params: HubTunnelServiceProviderParams);
36
+ /**
37
+ * Build a fresh client transport for one tunnel service session.
38
+ *
39
+ * Topics are group- and role-scoped, derived from the current MLS epoch
40
+ * secret on the service lane: outbound frames publish to the peer's
41
+ * responder topic, and the transport subscribes to this device's own dialer
42
+ * topic. Topics rotate with the epoch, so callers create a new transport per
43
+ * session.
44
+ */
45
+ createServiceTransport(signal?: AbortSignal): Promise<ClientTransportOf<ServiceProtocol>>;
46
+ }
@@ -0,0 +1,100 @@
1
+ import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
2
+ import { APP_TOPIC_LABEL } from '@kumiai/rpc';
3
+ import { createGroupCrypto } from '../groups/group-crypto.js';
4
+ import { MLSEncryptor } from '../groups/mls-encryptor.js';
5
+ import { createSenderScopedHubView } from '../hub/sender-scoped-hub-view.js';
6
+ import { SERVICE_TUNNEL_PROTOCOL, tunnelTopic } from './tunnel-topics.js';
7
+ /**
8
+ * Dial side of the SERVICE tunnel — the same directed hub-tunnel machinery as
9
+ * {@link HubTunnelSyncProvider}, riding a distinct lane so a service session to
10
+ * a peer never collides with a sync session's topics or ratchet generation.
11
+ */ export class HubTunnelServiceProvider {
12
+ #hub;
13
+ #registry;
14
+ #groupID;
15
+ #localDID;
16
+ #peerDID;
17
+ #runtime;
18
+ #idleTimeoutMs;
19
+ #logger;
20
+ constructor(params){
21
+ this.#hub = params.hub;
22
+ this.#registry = params.registry;
23
+ this.#groupID = params.groupID;
24
+ this.#localDID = params.localDID;
25
+ this.#peerDID = params.peerDID;
26
+ this.#runtime = params.runtime;
27
+ this.#idleTimeoutMs = params.idleTimeoutMs;
28
+ this.#logger = params.logger;
29
+ }
30
+ /**
31
+ * Build a fresh client transport for one tunnel service session.
32
+ *
33
+ * Topics are group- and role-scoped, derived from the current MLS epoch
34
+ * secret on the service lane: outbound frames publish to the peer's
35
+ * responder topic, and the transport subscribes to this device's own dialer
36
+ * topic. Topics rotate with the epoch, so callers create a new transport per
37
+ * session.
38
+ */ async createServiceTransport(signal) {
39
+ const sessionID = this.#runtime.getRandomID();
40
+ const encryptor = new MLSEncryptor({
41
+ registry: this.#registry,
42
+ groupID: this.#groupID
43
+ });
44
+ // The seed epoch is unread here: this port never classifies commit frames,
45
+ // and `exportSecret()` below records the live epoch before `epoch()` is
46
+ // asked for it. Only a peer that opens a receive drain needs a real seed.
47
+ const crypto = createGroupCrypto({
48
+ registry: this.#registry,
49
+ groupID: this.#groupID,
50
+ initialEpoch: 0,
51
+ runtime: this.#runtime
52
+ });
53
+ const secret = await crypto.exportSecret(APP_TOPIC_LABEL);
54
+ const epoch = crypto.epoch();
55
+ // SERVICE lane — the only substantive difference from the sync provider.
56
+ const sendTopicID = tunnelTopic(secret, epoch, 'responder', this.#peerDID, SERVICE_TUNNEL_PROTOCOL);
57
+ const receiveTopicID = tunnelTopic(secret, epoch, 'dialer', this.#localDID, SERVICE_TUNNEL_PROTOCOL);
58
+ // Bound once and carried by every line this session logs — the transport's
59
+ // own idle timeout raises a bare `TimeoutInterruption` with none of this
60
+ // context.
61
+ const sessionLogger = this.#logger?.with({
62
+ role: 'dialer',
63
+ lane: 'service',
64
+ groupID: this.#groupID,
65
+ peerDID: this.#peerDID,
66
+ sessionID,
67
+ epoch,
68
+ receiveTopicID
69
+ });
70
+ sessionLogger?.debug('tunnel session opening');
71
+ const transport = createEncryptedHubTunnelTransport({
72
+ // The peer-scoped view, never the device hub: two sessions open at once
73
+ // share the same inbox topic, and MLS consumes a ratchet generation per
74
+ // open, destroying the frame for whichever session loses the race.
75
+ // The idle timeout doubles as the publish bound so "no receive" and "no
76
+ // send" give up on the same promise rather than drifting apart.
77
+ hub: createSenderScopedHubView({
78
+ hub: this.#hub,
79
+ peerDID: this.#peerDID,
80
+ ...this.#idleTimeoutMs == null ? {} : {
81
+ publishTimeoutMs: this.#idleTimeoutMs
82
+ }
83
+ }),
84
+ encryptor,
85
+ groupID: this.#groupID,
86
+ sessionID,
87
+ localDID: this.#localDID,
88
+ sendTopicID,
89
+ receiveTopicID,
90
+ signal,
91
+ idleTimeoutMs: this.#idleTimeoutMs
92
+ });
93
+ // Close is as load-bearing as open: an unreachable peer looks like a
94
+ // session ending without having received anything.
95
+ transport.events.on('disposed', ()=>{
96
+ sessionLogger?.debug('tunnel session closed');
97
+ });
98
+ return transport;
99
+ }
100
+ }
@@ -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,
@@ -0,0 +1,41 @@
1
+ import type { DIDCache, OwnIdentity } from '@kokuin/token';
2
+ import type { StoreProvider } from '@kubun/db';
3
+ import type { Logger } from '@kubun/logger';
4
+ import type { ServiceConfig } from '@kubun/plugin-service-api';
5
+ import type { MailboxHub } from '@kumiai/hub-tunnel';
6
+ import type { Runtime } from '@sozai/runtime';
7
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
8
+ import { type ServeService } from './hub-tunnel-service-listener.js';
9
+ import type { TunnelListeners } from './tunnel-listeners.js';
10
+ export type ServiceTunnelListenersParams = {
11
+ stores: StoreProvider;
12
+ registry: GroupHandleRegistry;
13
+ identity: OwnIdentity;
14
+ localDID: string;
15
+ runtime: Runtime;
16
+ /** Injected `plugin-service-server` `serve()` — see {@link ServeService}. */
17
+ serve: ServeService;
18
+ /** Which services to serve, forwarded verbatim to `serve` on every spawn. */
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;
26
+ /** The group's device-wide drain, or `undefined` when no hub is bound. */
27
+ tunnelHub: (groupID: string) => MailboxHub | undefined;
28
+ logger?: Logger;
29
+ idleTimeoutMs?: number;
30
+ };
31
+ /**
32
+ * The answering half of the service tunnel — one `HubTunnelServiceListener` per
33
+ * co-member per group, so a device dialing on the service lane finds something
34
+ * draining the mailbox and handing the session to the injected `serve()`.
35
+ *
36
+ * Structurally identical to {@link createTunnelListeners} (the sync lane's
37
+ * answering half): same groupID→peerDID map, same epoch/hub-change teardown,
38
+ * same per-group serialized reconciles — only the listener class and what's
39
+ * threaded into it differ, so the two lanes never share an instance or ratchet.
40
+ */
41
+ export declare function createServiceTunnelListeners(params: ServiceTunnelListenersParams): TunnelListeners;