y-reticulum 0.1.3 → 0.2.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/CHANGELOG.md +9 -0
- package/README.md +44 -0
- package/package.json +1 -1
- package/src/provider.js +16 -0
- package/src/room.js +115 -2
- package/test/acl.test.js +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Optional `linkPolicy` provider option: an application-supplied callback that decides whether a peer link may carry room traffic, evaluated on both the initiator and responder sides once the remote identity is cryptographically proven (via the signed announce on the initiator side, via the signed `LINKIDENTIFY` handshake on the responder side)
|
|
15
|
+
- Optional `identifyTimeoutMs` provider option: how long the responder waits for the initiator's identify handshake before refusing the link (default 10 s)
|
|
16
|
+
- `refused` provider event, fired when a peer link was refused by the link policy, so apps can surface access requests ("peer X wants to join")
|
|
17
|
+
- Browser demo build (`tsdown.config.js`, `demo-src/`)
|
|
18
|
+
|
|
10
19
|
## [0.1.3] - 2026-09-21
|
|
11
20
|
|
|
12
21
|
## [0.1.2] - 2026-09-21
|
package/README.md
CHANGED
|
@@ -90,6 +90,15 @@ new ReticulumProvider(roomName, ydoc[, opts])
|
|
|
90
90
|
// to the 60s floor from the Reticulum spec (sub-minute intervals trigger
|
|
91
91
|
// ingress rate limiting).
|
|
92
92
|
announceIntervalMs: 60_000,
|
|
93
|
+
// Optional access control. When set, peer links must prove their identity
|
|
94
|
+
// (the initiator runs the signed identify handshake over the link) and pass
|
|
95
|
+
// the policy before any room traffic flows. Refused links are torn down and
|
|
96
|
+
// reported via the `refused` event. See "Access control" below.
|
|
97
|
+
linkPolicy: ({ remoteIdentityHash, remoteDestinationHash, initiator }) =>
|
|
98
|
+
allowed.has(remoteIdentityHash),
|
|
99
|
+
// How long the responder waits for the initiator's identify handshake
|
|
100
|
+
// before refusing the link. Only relevant with a `linkPolicy`.
|
|
101
|
+
identifyTimeoutMs: 10_000,
|
|
93
102
|
}
|
|
94
103
|
```
|
|
95
104
|
|
|
@@ -100,6 +109,41 @@ The provider extends `ObservableV2` and emits:
|
|
|
100
109
|
| `status` | `{ connected: boolean }` | the provider (dis)connects from the mesh |
|
|
101
110
|
| `synced` | `{ synced: boolean }` | sync state with the peer mesh changes |
|
|
102
111
|
| `peers` | `{ added: string[], removed: string[] }` | peers are discovered or drop off |
|
|
112
|
+
| `refused` | `{ refusals: Array<{ destinationHash: string \| null, identityHash: string \| null, initiator: boolean }> }` | a peer link was refused by the link policy |
|
|
113
|
+
|
|
114
|
+
## Access control
|
|
115
|
+
|
|
116
|
+
Pass a `linkPolicy` to gate which peers may sync with your room. The policy is
|
|
117
|
+
a (possibly async) callback that receives the remote peer's
|
|
118
|
+
`remoteIdentityHash` (hex truncated hash of their long-term Reticulum
|
|
119
|
+
identity), their room `remoteDestinationHash` when known (initiator side;
|
|
120
|
+
`null` on the responder side, where it is only learnt after identify), and
|
|
121
|
+
`initiator` telling which side of the link you are. Return `true` to allow the
|
|
122
|
+
link, `false` to refuse it: refused links are torn down before any room traffic flows, and reported on the `refused` event.
|
|
123
|
+
|
|
124
|
+
The identity hash is cryptographically bound on both sides: on the initiator
|
|
125
|
+
side it comes from the peer's signed announce, on the responder side from the
|
|
126
|
+
signed identify handshake over the link. Peers that never identify (e.g. older
|
|
127
|
+
versions without ACL support) are refused after `identifyTimeoutMs` and
|
|
128
|
+
reported with a `null` identityHash.
|
|
129
|
+
|
|
130
|
+
Refusals make natural access requests: collect them and, when a user grants
|
|
131
|
+
access, add the peer's identity hash to your allow-list. The next announce
|
|
132
|
+
cycle connects the peers.
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
const granted = new Set([myIdentityHash])
|
|
136
|
+
const provider = new ReticulumProvider("your-room-name", ydoc, {
|
|
137
|
+
reticulum: rns,
|
|
138
|
+
identity,
|
|
139
|
+
linkPolicy: ({ remoteIdentityHash }) => granted.has(remoteIdentityHash),
|
|
140
|
+
})
|
|
141
|
+
provider.on("refused", ({ refusals }) => {
|
|
142
|
+
for (const { identityHash } of refusals) {
|
|
143
|
+
console.log("access request from", identityHash) // surface in your UI
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
```
|
|
103
147
|
|
|
104
148
|
## License
|
|
105
149
|
|
package/package.json
CHANGED
package/src/provider.js
CHANGED
|
@@ -37,6 +37,14 @@ import { Room } from "./room.js";
|
|
|
37
37
|
* Cadence (ms) at which the room destination is re-announced for discovery.
|
|
38
38
|
* Forwarded to `Destination.startAnnouncing`, which clamps it to the
|
|
39
39
|
* §9.7 60 s floor (sub-minute intervals trigger ingress rate limiting).
|
|
40
|
+
* @property {import("./room.js").LinkPolicy} [linkPolicy]
|
|
41
|
+
* When set, peer links must prove their identity (the initiator runs the
|
|
42
|
+
* signed identify handshake over the link) and pass the policy before any
|
|
43
|
+
* room traffic flows. Refused links are torn down and reported via the
|
|
44
|
+
* `refused` event, which apps can use to surface access requests.
|
|
45
|
+
* @property {number} [identifyTimeoutMs]
|
|
46
|
+
* How long the responder waits for the initiator's identify handshake
|
|
47
|
+
* before refusing the link.
|
|
40
48
|
*/
|
|
41
49
|
|
|
42
50
|
/**
|
|
@@ -50,6 +58,8 @@ import { Room } from "./room.js";
|
|
|
50
58
|
* Fired when sync state with the peer mesh changes. (Phase 3.)
|
|
51
59
|
* @property {(event: { added: Array<string>, removed: Array<string> }) => void} peers
|
|
52
60
|
* Fired when peers are discovered or drop off.
|
|
61
|
+
* @property {(event: { refusals: Array<{ destinationHash: string | null, identityHash: string | null, initiator: boolean }> }) => void} refused
|
|
62
|
+
* Fired when a peer link was refused by the link policy.
|
|
53
63
|
*/
|
|
54
64
|
|
|
55
65
|
/**
|
|
@@ -75,6 +85,8 @@ export class ReticulumProvider extends ObservableV2 {
|
|
|
75
85
|
this.awareness = opts.awareness ?? new awarenessProtocol.Awareness(doc);
|
|
76
86
|
this.maxConns = opts.maxConns ?? 20;
|
|
77
87
|
this.announceIntervalMs = opts.announceIntervalMs ?? 60_000;
|
|
88
|
+
this.linkPolicy = opts.linkPolicy ?? null;
|
|
89
|
+
this.identifyTimeoutMs = opts.identifyTimeoutMs ?? 10_000;
|
|
78
90
|
|
|
79
91
|
/** Resolved with the room destination's identity on connect(). */
|
|
80
92
|
this.identityPromise = opts.identity
|
|
@@ -113,6 +125,8 @@ export class ReticulumProvider extends ObservableV2 {
|
|
|
113
125
|
appName,
|
|
114
126
|
maxConns: this.maxConns,
|
|
115
127
|
announceIntervalMs: this.announceIntervalMs,
|
|
128
|
+
linkPolicy: this.linkPolicy,
|
|
129
|
+
identifyTimeoutMs: this.identifyTimeoutMs,
|
|
116
130
|
callbacks: {
|
|
117
131
|
onPeers: (
|
|
118
132
|
/** @type {string[]} */ added,
|
|
@@ -120,6 +134,8 @@ export class ReticulumProvider extends ObservableV2 {
|
|
|
120
134
|
) => this.emit("peers", [{ added, removed }]),
|
|
121
135
|
onSynced: (/** @type {boolean} */ synced) =>
|
|
122
136
|
this.emit("synced", [{ synced }]),
|
|
137
|
+
onRefused: (/** @type {any[]} */ refusals) =>
|
|
138
|
+
this.emit("refused", [{ refusals }]),
|
|
123
139
|
},
|
|
124
140
|
});
|
|
125
141
|
await this.room.connect();
|
package/src/room.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* lexicographically smaller. The other simply accepts.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { Destination, DestType, toHex } from "@reticulum/core";
|
|
16
|
+
import { Destination, DestType, Identity, toHex } from "@reticulum/core";
|
|
17
17
|
import * as encoding from "lib0/encoding";
|
|
18
18
|
import * as awarenessProtocol from "y-protocols/awareness";
|
|
19
19
|
import * as syncProtocol from "y-protocols/sync";
|
|
@@ -38,12 +38,36 @@ function bytesEqual(/** @type {Uint8Array} */ a, /** @type {Uint8Array} */ b) {
|
|
|
38
38
|
return diff === 0;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Context handed to a room's {@link LinkPolicy} for every inbound and
|
|
43
|
+
* outbound peer link.
|
|
44
|
+
*
|
|
45
|
+
* @typedef {Object} LinkPolicyContext
|
|
46
|
+
* @property {string} remoteIdentityHash Hex truncated hash of the remote
|
|
47
|
+
* peer's long-term identity. Cryptographically bound: on the initiator
|
|
48
|
+
* side it comes from the peer's announce, on the responder side from the
|
|
49
|
+
* signed identify handshake over the link.
|
|
50
|
+
* @property {string|null} remoteDestinationHash Hex destination hash of the
|
|
51
|
+
* remote room destination, when known (initiator side).
|
|
52
|
+
* @property {boolean} initiator Whether this side initiated the link.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decides whether a peer link may carry room traffic. Called on both the
|
|
57
|
+
* initiator and responder sides once the remote identity is proven.
|
|
58
|
+
*
|
|
59
|
+
* @typedef {(context: LinkPolicyContext) => boolean | Promise<boolean>} LinkPolicy
|
|
60
|
+
*/
|
|
61
|
+
|
|
41
62
|
/**
|
|
42
63
|
* @typedef {Object} RoomCallbacks
|
|
43
64
|
* @property {(added: string[], removed: string[]) => void} onPeers
|
|
44
65
|
* Fired whenever peers are discovered or drop off. Ids are hex link_ids.
|
|
45
66
|
* @property {(synced: boolean) => void} onSynced
|
|
46
67
|
* Fired when the room's overall sync state changes.
|
|
68
|
+
* @property {(refusals: Array<{ destinationHash: string | null, identityHash: string | null, initiator: boolean }>) => void} [onRefused]
|
|
69
|
+
* Fired when a peer link was refused by the link policy. Apps can use this
|
|
70
|
+
* to surface access requests (e.g. "peer X wants to join").
|
|
47
71
|
*/
|
|
48
72
|
|
|
49
73
|
/**
|
|
@@ -61,6 +85,11 @@ export class Room {
|
|
|
61
85
|
* @param {string} options.appName - Deterministic destination app-name for the room.
|
|
62
86
|
* @param {number} options.maxConns
|
|
63
87
|
* @param {number} options.announceIntervalMs
|
|
88
|
+
* @param {LinkPolicy | null} [options.linkPolicy] When set, peer links must prove
|
|
89
|
+
* their identity (initiator runs the identify handshake) and pass the
|
|
90
|
+
* policy before any room traffic flows; refused links are torn down.
|
|
91
|
+
* @param {number} [options.identifyTimeoutMs] How long the responder waits
|
|
92
|
+
* for the initiator's identify handshake before refusing.
|
|
64
93
|
* @param {RoomCallbacks} options.callbacks
|
|
65
94
|
*/
|
|
66
95
|
constructor({
|
|
@@ -71,6 +100,8 @@ export class Room {
|
|
|
71
100
|
appName,
|
|
72
101
|
maxConns,
|
|
73
102
|
announceIntervalMs,
|
|
103
|
+
linkPolicy,
|
|
104
|
+
identifyTimeoutMs = 10_000,
|
|
74
105
|
callbacks,
|
|
75
106
|
}) {
|
|
76
107
|
this.doc = doc;
|
|
@@ -80,6 +111,8 @@ export class Room {
|
|
|
80
111
|
this.appName = appName;
|
|
81
112
|
this.maxConns = maxConns;
|
|
82
113
|
this.announceIntervalMs = announceIntervalMs;
|
|
114
|
+
this.linkPolicy = linkPolicy ?? null;
|
|
115
|
+
this.identifyTimeoutMs = identifyTimeoutMs;
|
|
83
116
|
this.callbacks = callbacks;
|
|
84
117
|
|
|
85
118
|
/** @type {import("@reticulum/core").Destination|null} */
|
|
@@ -202,7 +235,34 @@ export class Room {
|
|
|
202
235
|
// Glare avoidance: only the lexicographically smaller destination initiates.
|
|
203
236
|
if (this.myHex > remoteHex) return;
|
|
204
237
|
|
|
238
|
+
// De-bounce before the policy runs: announces repeat, and a slow (e.g.
|
|
239
|
+
// interactive) policy must not let concurrent announces each open a Link.
|
|
205
240
|
this.pendingInitiates.add(remoteHex);
|
|
241
|
+
|
|
242
|
+
// Link policy (initiator side): the announce cryptographically binds the
|
|
243
|
+
// remote identity, so the policy can run before any link is opened.
|
|
244
|
+
if (this.linkPolicy) {
|
|
245
|
+
const initiatorIdentityHash = toHex(
|
|
246
|
+
await Identity.truncatedHash(detail.identity.publicKey),
|
|
247
|
+
);
|
|
248
|
+
const allowed = await this.linkPolicy({
|
|
249
|
+
remoteIdentityHash: initiatorIdentityHash,
|
|
250
|
+
remoteDestinationHash: remoteHex,
|
|
251
|
+
initiator: true,
|
|
252
|
+
});
|
|
253
|
+
if (!allowed) {
|
|
254
|
+
this.pendingInitiates.delete(remoteHex);
|
|
255
|
+
this.callbacks.onRefused?.([
|
|
256
|
+
{
|
|
257
|
+
destinationHash: remoteHex,
|
|
258
|
+
identityHash: initiatorIdentityHash,
|
|
259
|
+
initiator: true,
|
|
260
|
+
},
|
|
261
|
+
]);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
206
266
|
try {
|
|
207
267
|
const out = await Destination.OUT(
|
|
208
268
|
this.appName,
|
|
@@ -215,6 +275,11 @@ export class Room {
|
|
|
215
275
|
await link.teardown();
|
|
216
276
|
return;
|
|
217
277
|
}
|
|
278
|
+
// With a policy, prove our identity to the responder before any room
|
|
279
|
+
// traffic: their policy cannot evaluate us until we do
|
|
280
|
+
if (this.linkPolicy) {
|
|
281
|
+
await link.identify(this.identity);
|
|
282
|
+
}
|
|
218
283
|
this.linkedDestHexes.add(remoteHex);
|
|
219
284
|
this._registerPeer(link, detail.destinationHash);
|
|
220
285
|
} catch {
|
|
@@ -226,7 +291,9 @@ export class Room {
|
|
|
226
291
|
}
|
|
227
292
|
|
|
228
293
|
/**
|
|
229
|
-
* Responder path: a peer is opening a Link to us. Accept it.
|
|
294
|
+
* Responder path: a peer is opening a Link to us. Accept it. With a link
|
|
295
|
+
* policy, the peer must prove its identity over the link (signed identify
|
|
296
|
+
* handshake) before the policy decides and any room traffic flows.
|
|
230
297
|
* @param {Event} event
|
|
231
298
|
*/
|
|
232
299
|
async _onLinkRequest(event) {
|
|
@@ -239,12 +306,58 @@ export class Room {
|
|
|
239
306
|
await link.teardown();
|
|
240
307
|
return;
|
|
241
308
|
}
|
|
309
|
+
if (this.linkPolicy) {
|
|
310
|
+
const identityHash = await this._awaitIdentify(link);
|
|
311
|
+
if (!identityHash) {
|
|
312
|
+
// The peer never proved who they are: refuse without ceremony
|
|
313
|
+
await link.teardown();
|
|
314
|
+
this.callbacks.onRefused?.([
|
|
315
|
+
{ destinationHash: null, identityHash: null, initiator: false },
|
|
316
|
+
]);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const allowed = await this.linkPolicy({
|
|
320
|
+
remoteIdentityHash: identityHash,
|
|
321
|
+
remoteDestinationHash: null,
|
|
322
|
+
initiator: false,
|
|
323
|
+
});
|
|
324
|
+
if (!allowed) {
|
|
325
|
+
await link.teardown();
|
|
326
|
+
this.callbacks.onRefused?.([
|
|
327
|
+
{ destinationHash: null, identityHash, initiator: false },
|
|
328
|
+
]);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
242
332
|
this._registerPeer(link, null);
|
|
243
333
|
} catch {
|
|
244
334
|
// Handshake failed; nothing to clean up.
|
|
245
335
|
}
|
|
246
336
|
}
|
|
247
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Waits for the initiator's signed identify handshake on this link.
|
|
340
|
+
*
|
|
341
|
+
* @param {import("@reticulum/core").Link} link
|
|
342
|
+
* @returns {Promise<string|null>} Hex remote identity hash, or null when
|
|
343
|
+
* the peer did not identify within the timeout.
|
|
344
|
+
*/
|
|
345
|
+
_awaitIdentify(link) {
|
|
346
|
+
return new Promise((resolve) => {
|
|
347
|
+
const timer = setTimeout(() => {
|
|
348
|
+
link.removeEventListener("identify", onIdentify);
|
|
349
|
+
resolve(null);
|
|
350
|
+
}, this.identifyTimeoutMs);
|
|
351
|
+
const onIdentify = (/** @type {Event} */ event) => {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
const detail = /** @type {any} */ (event).detail;
|
|
354
|
+
const identity = detail?.identity;
|
|
355
|
+
resolve(identity ? toHex(identity.getSalt()) : null);
|
|
356
|
+
};
|
|
357
|
+
link.addEventListener("identify", onIdentify, { once: true });
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
248
361
|
/**
|
|
249
362
|
* Registers a newly active peer and kicks off the Yjs sync handshake
|
|
250
363
|
* (syncStep1 + local awareness), mirroring y-webrtc's peer-on-connect path.
|
package/test/acl.test.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { Identity, toHex } from "@reticulum/core";
|
|
5
|
+
import * as Y from "yjs";
|
|
6
|
+
import { ReticulumProvider } from "../src/index.js";
|
|
7
|
+
import { nudgeAnnounce } from "./loopback.js";
|
|
8
|
+
|
|
9
|
+
const ROOM = "y-reticulum-acl-smoke";
|
|
10
|
+
|
|
11
|
+
/** Polls `cond()` every 50ms until true, rejecting after `timeoutMs`. */
|
|
12
|
+
function waitFor(cond, timeoutMs) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
const tick = () => {
|
|
16
|
+
if (cond()) return resolve(undefined);
|
|
17
|
+
if (Date.now() >= deadline) return reject(new Error("waitFor timed out"));
|
|
18
|
+
setTimeout(tick, 50);
|
|
19
|
+
};
|
|
20
|
+
tick();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("link policy gates sync: granted peers sync, refused peers do not", {
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
}, async () => {
|
|
27
|
+
const { makeLoopback } = await import("./loopback.js");
|
|
28
|
+
const { rnsA, rnsB, close } = await makeLoopback();
|
|
29
|
+
const docA = new Y.Doc();
|
|
30
|
+
const docB = new Y.Doc();
|
|
31
|
+
const idA = await Identity.generate();
|
|
32
|
+
const idB = await Identity.generate();
|
|
33
|
+
const hashA = toHex(await Identity.truncatedHash(await idA.getPublicKey()));
|
|
34
|
+
const hashB = toHex(await Identity.truncatedHash(await idB.getPublicKey()));
|
|
35
|
+
|
|
36
|
+
// A grants itself (owner) and nobody else: B must be refused
|
|
37
|
+
const granted = new Set([hashA]);
|
|
38
|
+
/** @type {any[]} */
|
|
39
|
+
const refusals = [];
|
|
40
|
+
const providerA = new ReticulumProvider(ROOM, docA, {
|
|
41
|
+
reticulum: rnsA,
|
|
42
|
+
identity: idA,
|
|
43
|
+
linkPolicy: ({ remoteIdentityHash }) => granted.has(remoteIdentityHash),
|
|
44
|
+
});
|
|
45
|
+
providerA.on("refused", (/** @type {any} */ e) => {
|
|
46
|
+
refusals.push(...e.refusals);
|
|
47
|
+
});
|
|
48
|
+
const providerB = new ReticulumProvider(ROOM, docB, {
|
|
49
|
+
reticulum: rnsB,
|
|
50
|
+
identity: idB,
|
|
51
|
+
linkPolicy: () => true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await providerA.connect();
|
|
55
|
+
await providerB.connect();
|
|
56
|
+
await nudgeAnnounce(providerA, providerB);
|
|
57
|
+
|
|
58
|
+
// B's edit must NOT reach A while B is ungranted (either direction of the
|
|
59
|
+
// link was refused)
|
|
60
|
+
docB.getMap("doc").set("intruder", "yes");
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
62
|
+
assert.equal(
|
|
63
|
+
docA.getMap("doc").get("intruder"),
|
|
64
|
+
undefined,
|
|
65
|
+
"ungranted peer's data never arrives",
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// The owner sees the refusal with B's identity hash: a join request
|
|
69
|
+
await waitFor(() => refusals.some((r) => r.identityHash === hashB), 10000);
|
|
70
|
+
|
|
71
|
+
// Owner grants B; the next announce cycle connects them
|
|
72
|
+
granted.add(hashB);
|
|
73
|
+
await nudgeAnnounce(providerA, providerB);
|
|
74
|
+
await waitFor(() => docA.getMap("doc").get("intruder") === "yes", 15000);
|
|
75
|
+
assert.equal(
|
|
76
|
+
docA.getMap("doc").get("intruder"),
|
|
77
|
+
"yes",
|
|
78
|
+
"granted peer's data now syncs",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await providerA.destroy().catch(() => {});
|
|
82
|
+
await providerB.destroy().catch(() => {});
|
|
83
|
+
await close();
|
|
84
|
+
});
|