@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.
- 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/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 +25 -0
- package/lib/groups/group-peer-manager.js +71 -0
- package/lib/groups/group-protocols.d.ts +47 -0
- package/lib/groups/group-protocols.js +28 -0
- package/lib/hub/wiring.d.ts +24 -0
- package/lib/hub/wiring.js +17 -1
- package/lib/index.d.ts +14 -0
- package/lib/index.js +57 -1
- 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 +75 -0
- package/lib/sync/hub-tunnel-service-listener.js +289 -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/service-tunnel-listeners.d.ts +35 -0
- package/lib/sync/service-tunnel-listeners.js +165 -0
- package/lib/sync/sync-manager.d.ts +7 -0
- package/lib/sync/sync-manager.js +4 -1
- 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 +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,
|
package/lib/sync/handlers.js
CHANGED
|
@@ -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
|
+
}
|