@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.
- package/lib/context/peer.js +250 -3
- package/lib/context/sync.js +134 -25
- package/lib/context/types.d.ts +7 -0
- package/lib/groups/broadcast-message.d.ts +29 -0
- package/lib/groups/broadcast.d.ts +11 -1
- package/lib/groups/broadcast.js +44 -2
- package/lib/groups/credential-apply.d.ts +64 -2
- package/lib/groups/credential-apply.js +215 -30
- package/lib/groups/credential-grant.d.ts +22 -0
- package/lib/groups/credential-grant.js +76 -2
- package/lib/groups/credential-manifest-token.d.ts +31 -0
- package/lib/groups/credential-manifest-token.js +49 -0
- package/lib/groups/credential-readiness.d.ts +69 -0
- package/lib/groups/credential-readiness.js +172 -0
- package/lib/groups/credential-wrapping-deps.d.ts +23 -0
- package/lib/groups/credential-wrapping-deps.js +25 -0
- package/lib/groups/did-cache-seed.d.ts +26 -0
- package/lib/groups/did-cache-seed.js +38 -0
- package/lib/groups/grantor-authority.d.ts +65 -0
- package/lib/groups/grantor-authority.js +107 -0
- package/lib/groups/group-handlers.js +7 -0
- package/lib/groups/group-peer-manager.d.ts +32 -1
- package/lib/groups/group-peer-manager.js +77 -0
- package/lib/groups/group-protocols.d.ts +47 -0
- package/lib/groups/group-protocols.js +28 -0
- package/lib/hub/http-client.js +6 -1
- package/lib/hub/hub-like.js +5 -2
- package/lib/hub/peer-scoped-hub-view.d.ts +6 -0
- package/lib/hub/peer-scoped-hub-view.js +11 -1
- package/lib/hub/wiring.d.ts +31 -1
- package/lib/hub/wiring.js +20 -1
- package/lib/index.d.ts +14 -0
- package/lib/index.js +72 -4
- package/lib/protocol.d.ts +30 -0
- package/lib/protocol.js +36 -0
- package/lib/schema.js +78 -1
- package/lib/sync/handlers.js +21 -2
- package/lib/sync/held-delegations.d.ts +14 -0
- package/lib/sync/held-delegations.js +34 -0
- package/lib/sync/hub-tunnel-service-listener.d.ts +84 -0
- package/lib/sync/hub-tunnel-service-listener.js +294 -0
- package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
- package/lib/sync/hub-tunnel-service-provider.js +100 -0
- package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -1
- package/lib/sync/hub-tunnel-sync-listener.js +6 -1
- package/lib/sync/service-tunnel-listeners.d.ts +41 -0
- package/lib/sync/service-tunnel-listeners.js +183 -0
- package/lib/sync/sync-manager.d.ts +7 -0
- package/lib/sync/sync-manager.js +4 -1
- package/lib/sync/tunnel-listeners.d.ts +7 -1
- package/lib/sync/tunnel-listeners.js +18 -0
- package/lib/sync/tunnel-topics.d.ts +19 -1
- package/lib/sync/tunnel-topics.js +7 -3
- package/lib/types.d.ts +172 -0
- 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;
|