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