@vrtmrz/livesync-commonlib 0.1.24 → 0.1.25

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 (34) hide show
  1. package/dist/API/processSetting.d.ts +1 -1
  2. package/dist/API/processSetting.js +10 -6
  3. package/dist/common/ConnectionString.js +15 -8
  4. package/dist/common/models/setting.const.qr.js +5 -0
  5. package/dist/common/models/setting.lifecycle.js +5 -3
  6. package/dist/common/models/setting.p2p.d.ts +10 -0
  7. package/dist/common/models/setting.p2p.js +13 -0
  8. package/dist/common/models/setting.type.d.ts +10 -0
  9. package/dist/common/models/tweak.definition.d.ts +5 -0
  10. package/dist/common/settingConstants.d.ts +5 -0
  11. package/dist/common/types.d.ts +3 -3
  12. package/dist/common/types.js +6 -0
  13. package/dist/common/utils.js +4 -1
  14. package/dist/p2p/P2PService.d.ts +7 -1
  15. package/dist/p2p/P2PService.js +13 -7
  16. package/dist/p2p/index.d.ts +3 -1
  17. package/dist/p2p/index.js +6 -0
  18. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +22 -1
  19. package/dist/replication/trystero/P2PRoomSessionOwner.js +193 -13
  20. package/dist/replication/trystero/TrysteroReplicator.js +9 -3
  21. package/dist/replication/trystero/TrysteroReplicatorP2PServer.d.ts +1 -0
  22. package/dist/replication/trystero/TrysteroReplicatorP2PServer.js +10 -3
  23. package/dist/replication/trystero/p2pReplicatorConfigurationIdentity.js +6 -3
  24. package/dist/replication/trystero/useP2PReplicatorFeature.d.ts +6 -1
  25. package/dist/replication/trystero/useP2PReplicatorFeature.js +7 -4
  26. package/dist/rpc/transports/trysteroUtils.d.ts +2 -2
  27. package/dist/rpc/transports/trysteroUtils.js +32 -2
  28. package/dist/serviceFeatures/remoteConfig.js +3 -0
  29. package/dist/services/base/SettingService.d.ts +1 -0
  30. package/dist/services/base/SettingService.js +37 -9
  31. package/docs/development.md +3 -3
  32. package/docs/p2p-transport-lifecycle.md +86 -4
  33. package/docs/proven-in-use.md +30 -0
  34. package/package.json +1 -1
@@ -2,8 +2,12 @@ import { Logger } from "../../common/logger.js";
2
2
  import {
3
3
  LOG_LEVEL_NOTICE,
4
4
  LOG_LEVEL_VERBOSE,
5
- SETTING_KEY_P2P_DEVICE_NAME
5
+ SETTING_KEY_P2P_DEVICE_NAME,
6
+ hasManagedP2PTurnConfiguration,
7
+ omitP2PRuntimeSettings
6
8
  } from "../../common/types.js";
9
+ import { P2PConnectionPaths } from "../../common/models/setting.const.js";
10
+ import { normaliseP2PConnectionPath } from "../../common/models/setting.p2p.js";
7
11
  import { P2PRoomSession } from "./P2PRoomSession.js";
8
12
  import { P2PAutomationCoordinator } from "./P2PAutomationCoordinator.js";
9
13
  import { PEER_REPLICATION_READINESS } from "../ReplicatorProvider.js";
@@ -12,19 +16,28 @@ import {
12
16
  ACTIVE_P2P_RELAY_BINDING_CONFLICT,
13
17
  activeP2PRelayBindingCovers
14
18
  } from "./P2PConnectionProbeAdmission.js";
19
+ class StaleRoomReconciliationError extends Error {
20
+ }
21
+ const P2P_ROOM_OPEN_TIMEOUT_MS = 3e4;
22
+ const P2P_SETTINGS_PREPARATION_TIMEOUT_MS = 3e4;
23
+ const P2P_ICE_SERVER_EXPIRY_MARGIN_MS = 3e4;
15
24
  class P2PRoomSessionOwner {
16
- constructor(env, createSession = (sessionEnv) => new P2PRoomSession(sessionEnv)) {
25
+ constructor(env, createSession = (sessionEnv) => new P2PRoomSession(sessionEnv), options = {}) {
17
26
  this.env = env;
18
27
  this.createSession = createSession;
28
+ this.options = options;
19
29
  }
20
30
  env;
21
31
  createSession;
32
+ options;
22
33
  current;
23
34
  lifecycleOperation = Promise.resolve();
24
35
  persistentDemands = /* @__PURE__ */ new Set();
25
36
  finiteDemands = /* @__PURE__ */ new Set();
26
37
  automationCoordinator = new P2PAutomationCoordinator();
27
38
  activeBinding;
39
+ pendingPreparation;
40
+ runtimeGeneration = 0;
28
41
  get currentSession() {
29
42
  return this.current;
30
43
  }
@@ -52,6 +65,7 @@ class P2PRoomSessionOwner {
52
65
  if (active && !enabled) {
53
66
  Logger(this.env.services.context.translate("P2P.NotEnabled"), LOG_LEVEL_NOTICE);
54
67
  }
68
+ this.fencePreparationForCurrentSettings(enabled);
55
69
  await this.reconcileTransport();
56
70
  }
57
71
  /**
@@ -66,6 +80,7 @@ class P2PRoomSessionOwner {
66
80
  }
67
81
  const demand = /* @__PURE__ */ Symbol("p2p-finite-room-demand");
68
82
  this.finiteDemands.add(demand);
83
+ this.fencePreparationForCurrentSettings(true);
69
84
  try {
70
85
  await this.reconcileTransport();
71
86
  if (!this.finiteDemands.has(demand)) {
@@ -116,7 +131,7 @@ class P2PRoomSessionOwner {
116
131
  return;
117
132
  }
118
133
  const binding = this.getEffectiveBinding();
119
- if (this.current?.host.isServing && this.bindingsMatch(this.activeBinding, binding)) {
134
+ if (this.current?.host.isServing && this.bindingsMatch(this.activeBinding, binding) && this.connectionSettingsRemainUsable(this.activeBinding?.settings)) {
120
135
  this.reconcileCurrentSessionPolicy(this.current);
121
136
  Logger("P2P replicator is already open.");
122
137
  return;
@@ -125,34 +140,55 @@ class P2PRoomSessionOwner {
125
140
  await this.closeTransport();
126
141
  }
127
142
  let candidate;
143
+ const operationGeneration = this.runtimeGeneration;
128
144
  try {
129
- candidate = this.createSession(this.buildSessionEnv(binding));
130
- await candidate.open();
145
+ const connectionSettings = await this.prepareConnectionSettings(binding, operationGeneration);
146
+ this.assertReconciliationCurrent(binding, operationGeneration, connectionSettings);
147
+ const connectionBinding = { ...binding, settings: connectionSettings };
148
+ candidate = this.createSession(this.buildSessionEnv(connectionBinding));
149
+ if (hasManagedP2PTurnConfiguration(binding.settings)) {
150
+ await this.withRoomOpenDeadline(candidate.open());
151
+ } else {
152
+ await candidate.open();
153
+ }
131
154
  if (!candidate.host.isServing) {
132
155
  throw new Error("The P2P room did not start serving.");
133
156
  }
134
157
  const currentBinding = this.getEffectiveBinding();
135
- if (!currentBinding.enabled || !this.hasRoomDemand() || !this.bindingsMatch(binding, currentBinding)) {
158
+ if (!currentBinding.enabled || !this.hasRoomDemand() || !this.bindingsMatch(binding, currentBinding) || operationGeneration !== this.runtimeGeneration || !this.connectionSettingsRemainUsable(connectionSettings)) {
136
159
  await candidate.retire();
137
160
  return;
138
161
  }
139
162
  this.current = candidate;
140
- this.activeBinding = binding;
163
+ this.activeBinding = connectionBinding;
141
164
  this.reconcileCurrentSessionPolicy(candidate);
142
165
  } catch (error) {
143
166
  await candidate?.retire(error).catch((retirementError) => {
144
- Logger(retirementError, LOG_LEVEL_VERBOSE);
167
+ if (hasManagedP2PTurnConfiguration(binding.settings)) {
168
+ Logger("The managed P2P room could not be retired cleanly.", LOG_LEVEL_VERBOSE);
169
+ } else {
170
+ Logger(retirementError, LOG_LEVEL_VERBOSE);
171
+ }
145
172
  });
146
- Logger(error instanceof Error ? error.message : "Error while opening P2P connection", LOG_LEVEL_NOTICE);
147
- Logger(error, LOG_LEVEL_VERBOSE);
148
173
  this.current = void 0;
149
174
  this.activeBinding = void 0;
175
+ if (error instanceof StaleRoomReconciliationError || operationGeneration !== this.runtimeGeneration) {
176
+ return;
177
+ }
178
+ if (hasManagedP2PTurnConfiguration(binding.settings)) {
179
+ Logger("The managed P2P room could not be prepared or opened.", LOG_LEVEL_NOTICE);
180
+ Logger("Managed P2P room failure details were omitted.", LOG_LEVEL_VERBOSE);
181
+ throw new Error("The managed P2P room could not be prepared or opened.");
182
+ }
183
+ Logger(error instanceof Error ? error.message : "Error while opening P2P connection", LOG_LEVEL_NOTICE);
184
+ Logger(error, LOG_LEVEL_VERBOSE);
150
185
  }
151
186
  });
152
187
  }
153
188
  async close() {
154
189
  this.persistentDemands.clear();
155
190
  this.finiteDemands.clear();
191
+ this.invalidatePreparation();
156
192
  await this.enqueueLifecycleOperation(async () => {
157
193
  await this.closeTransport();
158
194
  });
@@ -179,11 +215,153 @@ class P2PRoomSessionOwner {
179
215
  bindingsMatch(current, candidate) {
180
216
  return current?.database === candidate.database && current.signature === candidate.signature;
181
217
  }
218
+ connectionSettingsRemainUsable(settings) {
219
+ if (!settings) return false;
220
+ const managed = hasManagedP2PTurnConfiguration(settings);
221
+ const iceServers = settings.P2P_iceServers;
222
+ if (iceServers === void 0) return !managed;
223
+ if (!this.hasUsableIceServer(iceServers)) return false;
224
+ const hasTurnServer = this.hasUsableTurnServer(iceServers);
225
+ if (managed && !hasTurnServer) return false;
226
+ if (normaliseP2PConnectionPath(settings.P2P_connectionPath) === P2PConnectionPaths.Relay && !hasTurnServer) {
227
+ return false;
228
+ }
229
+ const expiresAt = settings.P2P_iceServersExpiresAt;
230
+ if (expiresAt === void 0) return !managed;
231
+ return Number.isFinite(expiresAt) && expiresAt > Date.now() + P2P_ICE_SERVER_EXPIRY_MARGIN_MS;
232
+ }
233
+ fencePreparationForCurrentSettings(enabled) {
234
+ if (!enabled) {
235
+ this.invalidatePreparation();
236
+ return;
237
+ }
238
+ const pendingChanged = this.pendingPreparation !== void 0 && this.pendingPreparation.signature !== this.getEffectiveBinding().signature;
239
+ if (pendingChanged) this.invalidatePreparation();
240
+ }
241
+ invalidatePreparation() {
242
+ this.runtimeGeneration += 1;
243
+ const pending = this.pendingPreparation;
244
+ this.pendingPreparation = void 0;
245
+ if (pending && !pending.controller.signal.aborted) {
246
+ pending.controller.abort(new Error("The P2P settings preparation was cancelled."));
247
+ }
248
+ }
249
+ async prepareConnectionSettings(binding, operationGeneration) {
250
+ const managed = hasManagedP2PTurnConfiguration(binding.settings);
251
+ const prepare = this.options.prepareP2PSettings;
252
+ if (!prepare) {
253
+ if (managed) throw new Error("The selected managed TURN configuration is not supported by this host.");
254
+ return binding.settings;
255
+ }
256
+ const controller = new AbortController();
257
+ const pending = {
258
+ signature: binding.signature,
259
+ controller
260
+ };
261
+ this.pendingPreparation = pending;
262
+ try {
263
+ const prepared = await this.prepareWithDeadline(prepare, binding.settings, controller);
264
+ if (prepared.P2P_iceServers === void 0 && prepared.P2P_iceServersExpiresAt === void 0) {
265
+ if (managed) throw new Error("The prepared managed TURN settings do not include ICE servers.");
266
+ return binding.settings;
267
+ }
268
+ const iceServers = this.copyPreparedIceServers(prepared.P2P_iceServers);
269
+ const expiresAt = prepared.P2P_iceServersExpiresAt;
270
+ const connectionSettings = {
271
+ ...binding.settings,
272
+ P2P_iceServers: iceServers,
273
+ P2P_iceServersExpiresAt: expiresAt
274
+ };
275
+ if (!this.connectionSettingsRemainUsable(connectionSettings)) {
276
+ throw new Error("The prepared managed TURN settings are missing usable ICE servers or expiry.");
277
+ }
278
+ this.assertReconciliationCurrent(binding, operationGeneration, connectionSettings);
279
+ return connectionSettings;
280
+ } finally {
281
+ if (this.pendingPreparation === pending) {
282
+ this.pendingPreparation = void 0;
283
+ }
284
+ }
285
+ }
286
+ prepareWithDeadline(prepare, settings, controller) {
287
+ const preparation = Promise.resolve().then(() => prepare(Object.freeze({ ...settings }), controller.signal));
288
+ return new Promise((resolve, reject) => {
289
+ let settled = false;
290
+ const finish = (operation) => {
291
+ if (settled) return;
292
+ settled = true;
293
+ globalThis.clearTimeout(timeout);
294
+ controller.signal.removeEventListener("abort", onAbort);
295
+ operation();
296
+ };
297
+ const onAbort = () => finish(
298
+ () => reject(
299
+ controller.signal.reason instanceof Error ? controller.signal.reason : new Error("The P2P settings preparation was cancelled.")
300
+ )
301
+ );
302
+ const timeout = globalThis.setTimeout(() => {
303
+ controller.abort(new Error("The P2P settings preparation timed out."));
304
+ }, P2P_SETTINGS_PREPARATION_TIMEOUT_MS);
305
+ controller.signal.addEventListener("abort", onAbort, { once: true });
306
+ if (controller.signal.aborted) onAbort();
307
+ preparation.then(
308
+ (prepared) => finish(() => resolve(prepared)),
309
+ () => finish(() => reject(new Error("The P2P settings preparation failed.")))
310
+ );
311
+ });
312
+ }
313
+ copyPreparedIceServers(iceServers) {
314
+ if (!Array.isArray(iceServers) || iceServers.length === 0) return [];
315
+ return iceServers.map((server) => ({
316
+ urls: typeof server.urls === "string" ? server.urls : [...server.urls],
317
+ ...server.username === void 0 ? {} : { username: server.username },
318
+ ...server.credential === void 0 ? {} : { credential: server.credential }
319
+ }));
320
+ }
321
+ hasUsableIceServer(iceServers) {
322
+ return Array.isArray(iceServers) && iceServers.some((server) => {
323
+ const urls = typeof server?.urls === "string" ? [server.urls] : server?.urls;
324
+ return Array.isArray(urls) && urls.some((url) => typeof url === "string" && url.trim().length > 0);
325
+ });
326
+ }
327
+ hasUsableTurnServer(iceServers) {
328
+ return Array.isArray(iceServers) && iceServers.some((server) => {
329
+ const urls = typeof server?.urls === "string" ? [server.urls] : server?.urls;
330
+ return Array.isArray(urls) && urls.some((url) => typeof url === "string" && /^turns?:/iu.test(url));
331
+ });
332
+ }
333
+ async withRoomOpenDeadline(opening) {
334
+ let timeout;
335
+ try {
336
+ await Promise.race([
337
+ opening,
338
+ new Promise((_resolve, reject) => {
339
+ timeout = globalThis.setTimeout(
340
+ () => reject(new Error("The P2P room opening timed out.")),
341
+ P2P_ROOM_OPEN_TIMEOUT_MS
342
+ );
343
+ })
344
+ ]);
345
+ } finally {
346
+ if (timeout !== void 0) globalThis.clearTimeout(timeout);
347
+ }
348
+ }
349
+ assertReconciliationCurrent(binding, operationGeneration, connectionSettings) {
350
+ if (operationGeneration !== this.runtimeGeneration || !this.hasRoomDemand()) {
351
+ throw new StaleRoomReconciliationError();
352
+ }
353
+ const currentBinding = this.getEffectiveBinding();
354
+ if (!currentBinding.enabled) throw new StaleRoomReconciliationError();
355
+ if (binding && !this.bindingsMatch(binding, currentBinding)) throw new StaleRoomReconciliationError();
356
+ if (connectionSettings && !this.connectionSettingsRemainUsable(connectionSettings)) {
357
+ throw new StaleRoomReconciliationError();
358
+ }
359
+ }
182
360
  /** Capture immutable session inputs while keeping policy live. */
183
361
  getEffectiveBinding() {
184
- const settings = {
362
+ const settings = omitP2PRuntimeSettings({
185
363
  ...this.env.services.setting.currentSettings()
186
- };
364
+ });
187
365
  const deviceName = this.env.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.env.services.vault.getVaultName();
188
366
  const database = this.env.services.database.localDatabase.localDatabase;
189
367
  this.automationCoordinator.reconcileIdentity(
@@ -245,5 +423,7 @@ class P2PRoomSessionOwner {
245
423
  }
246
424
  }
247
425
  export {
248
- P2PRoomSessionOwner
426
+ P2PRoomSessionOwner,
427
+ P2P_ICE_SERVER_EXPIRY_MARGIN_MS,
428
+ P2P_SETTINGS_PREPARATION_TIMEOUT_MS
249
429
  };
@@ -1,4 +1,7 @@
1
- import { TweakValuesShouldMatchedTemplate } from "../../common/types.js";
1
+ import {
2
+ TweakValuesShouldMatchedTemplate,
3
+ omitP2PRuntimeSettings
4
+ } from "../../common/types.js";
2
5
  import { assessTweakCompatibility } from "../../common/models/tweak.compatibility.js";
3
6
  import {
4
7
  LOG_LEVEL_INFO,
@@ -253,12 +256,15 @@ class TrysteroReplicator {
253
256
  );
254
257
  });
255
258
  const setting = {
256
- ...this.settings,
259
+ ...omitP2PRuntimeSettings(this.settings),
257
260
  configPassphraseStore: "",
258
261
  encryptedCouchDBConnection: "",
259
262
  encryptedPassphrase: "",
260
263
  pluginSyncExtendedSetting: {}
261
264
  };
265
+ delete setting.P2P_managedType;
266
+ delete setting.P2P_managedId;
267
+ delete setting.P2P_managedToken;
262
268
  if (!passphrase || passphrase.trim() == "") {
263
269
  Logger(
264
270
  "Passphrase is required to transfer the configuration. The peer cannot be decrypt the config\nIf you repeatedly receive unintended configuration-sharing requests, change the RPC channel immediately. It allows you to leave the connection and disappear, while they are trying brute force attack for the decoy on their local.",
@@ -700,7 +706,7 @@ ${info.lastSeq} / ${info.maxSeqInBatch})`,
700
706
  const decryptedConfig = JSON.parse(
701
707
  await decrypt(encryptedConfig, passphrase)
702
708
  );
703
- return decryptedConfig;
709
+ return omitP2PRuntimeSettings(decryptedConfig);
704
710
  } catch (e) {
705
711
  Logger("Error while decrypting the configuration", LOG_LEVEL_NOTICE);
706
712
  Logger(e, LOG_LEVEL_VERBOSE);
@@ -121,6 +121,7 @@ export declare class TrysteroReplicatorP2PServer {
121
121
  onAfterJoinRoom(): void;
122
122
  startService(bindings?: BindableObject[], beforeAdvertisement?: () => void): Promise<void>;
123
123
  start(bindings?: BindableObject[], beforeAdvertisement?: () => void): Promise<void>;
124
+ private logTransportError;
124
125
  /**
125
126
  * @deprecated Use serveFunction or serveObject instead. This is only for backward compatibility and may be removed in the future.
126
127
  * @param type
@@ -79,7 +79,7 @@ class TrysteroReplicatorP2PServer {
79
79
  `Some error has been occurred while leaving the room, but possibly can be ignored`,
80
80
  LOG_LEVEL_VERBOSE
81
81
  );
82
- Logger(ex, LOG_LEVEL_VERBOSE);
82
+ this.logTransportError(ex);
83
83
  }
84
84
  this._room = void 0;
85
85
  this._env.events.emitEvent(EVENT_P2P_DISCONNECTED);
@@ -97,7 +97,7 @@ class TrysteroReplicatorP2PServer {
97
97
  this._rpcRoom = void 0;
98
98
  } catch (ex) {
99
99
  Logger(`Some error has been occurred while shutting down the server`, LOG_LEVEL_INFO);
100
- Logger(ex, LOG_LEVEL_VERBOSE);
100
+ this.logTransportError(ex);
101
101
  }
102
102
  }
103
103
  /** Release subscriptions whose lifetime is the host object rather than one transport join. */
@@ -459,7 +459,7 @@ You can chose as follows:
459
459
  handshakeTimeoutMs: 3e4,
460
460
  onJoinError: (error) => {
461
461
  Logger("Some peer Failed to join Trystero room");
462
- Logger(error, LOG_LEVEL_VERBOSE);
462
+ this.logTransportError(error);
463
463
  }
464
464
  });
465
465
  await this.setRoom(room);
@@ -468,6 +468,13 @@ You can chose as follows:
468
468
  void this.dispatchConnectionStatus();
469
469
  await this.startService(bindings, beforeAdvertisement);
470
470
  }
471
+ logTransportError(error) {
472
+ if (this.settings.P2P_iceServers !== void 0) {
473
+ Logger("Managed P2P transport error details were omitted.", LOG_LEVEL_VERBOSE);
474
+ return;
475
+ }
476
+ Logger(error, LOG_LEVEL_VERBOSE);
477
+ }
471
478
  /**
472
479
  * @deprecated Use serveFunction or serveObject instead. This is only for backward compatibility and may be removed in the future.
473
480
  * @param type
@@ -1,25 +1,28 @@
1
1
  import { P2PConnectionPaths } from "../../common/models/setting.const.js";
2
2
  import {
3
3
  hasValidP2PTurnServerUrl,
4
+ hasManagedP2PTurnConfiguration,
4
5
  normaliseP2PConnectionPath,
5
6
  normaliseP2PMaxWirePayloadBytes,
6
7
  splitP2PRelayUrls,
7
8
  splitP2PTurnServerUrls
8
9
  } from "../../common/models/setting.p2p.js";
9
10
  function getP2PReplicatorConfigurationIdentity(settings) {
11
+ const managedTurnSelected = hasManagedP2PTurnConfiguration(settings);
10
12
  const turnServerValue = settings.P2P_turnServers ?? "";
11
13
  const turnServers = splitP2PTurnServerUrls(turnServerValue);
12
14
  const configuredPath = normaliseP2PConnectionPath(settings.P2P_connectionPath);
13
- const effectivePath = configuredPath === P2PConnectionPaths.Relay && hasValidP2PTurnServerUrl(turnServerValue) ? P2PConnectionPaths.Relay : P2PConnectionPaths.Automatic;
14
- const turnAuthentication = turnServers.length > 0 ? [settings.P2P_turnUsername, settings.P2P_turnCredential] : void 0;
15
+ const effectivePath = configuredPath === P2PConnectionPaths.Relay && (managedTurnSelected || hasValidP2PTurnServerUrl(turnServerValue)) ? P2PConnectionPaths.Relay : P2PConnectionPaths.Automatic;
16
+ const turnAuthentication = !managedTurnSelected && turnServers.length > 0 ? [settings.P2P_turnUsername, settings.P2P_turnCredential] : void 0;
15
17
  return JSON.stringify([
16
18
  "p2p",
17
19
  settings.P2P_AppID || "self-hosted-livesync",
18
20
  settings.P2P_roomID,
19
21
  settings.P2P_passphrase,
20
22
  splitP2PRelayUrls(settings.P2P_relays),
21
- turnServers,
23
+ managedTurnSelected ? void 0 : turnServers,
22
24
  turnAuthentication,
25
+ managedTurnSelected ? [settings.P2P_managedType, settings.P2P_managedId, settings.P2P_managedToken] : void 0,
23
26
  normaliseP2PMaxWirePayloadBytes(settings.P2P_maxWirePayloadBytes),
24
27
  effectivePath,
25
28
  settings.P2P_useDiagRTC ?? false
@@ -2,6 +2,7 @@ import type { NecessaryServices } from "../../interfaces/ServiceModule.js";
2
2
  import type { LiveSyncTrysteroReplicator } from "./LiveSyncTrysteroReplicator.js";
3
3
  import { type UseP2PReplicatorResult } from "./UseP2PReplicatorResult.js";
4
4
  import { type P2PServiceViews } from "../../p2p/P2PService.js";
5
+ import type { PrepareP2PSettings } from "../../p2p/P2PService.js";
5
6
  /**
6
7
  * Factory type: given the compatibility Replicator and the stable service
7
8
  * views, returns the openReplicationUI callback for that instance.
@@ -14,6 +15,10 @@ import { type P2PServiceViews } from "../../p2p/P2PService.js";
14
15
  export type OpenReplicationUIFactory = (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean) => Promise<boolean | void>;
15
16
  /** Same shape as OpenReplicationUIFactory, used for the rebuild/replicateAllFromServer flow. */
16
17
  export type OpenRebuildUIFactory = OpenReplicationUIFactory;
18
+ /** Optional host integrations for the P2P room lifecycle. */
19
+ export interface P2PReplicatorFeatureOptions {
20
+ readonly prepareP2PSettings?: PrepareP2PSettings;
21
+ }
17
22
  /**
18
23
  * Compose one private P2P service context and register non-owning active
19
24
  * provider adapters over it.
@@ -23,4 +28,4 @@ export type OpenRebuildUIFactory = OpenReplicationUIFactory;
23
28
  * migrated to those views.
24
29
  * @param host
25
30
  */
26
- export declare function useP2PReplicatorFeature(host: NecessaryServices<"API" | "appLifecycle" | "setting" | "vault" | "database" | "databaseEvents" | "keyValueDB" | "replication" | "config" | "UI" | "replicator" | "remote", never>, openReplicationUIFactory?: OpenReplicationUIFactory, openRebuildUIFactory?: OpenRebuildUIFactory): UseP2PReplicatorResult;
31
+ export declare function useP2PReplicatorFeature(host: NecessaryServices<"API" | "appLifecycle" | "setting" | "vault" | "database" | "databaseEvents" | "keyValueDB" | "replication" | "config" | "UI" | "replicator" | "remote", never>, openReplicationUIFactory?: OpenReplicationUIFactory, openRebuildUIFactory?: OpenRebuildUIFactory, options?: P2PReplicatorFeatureOptions): UseP2PReplicatorResult;
@@ -15,10 +15,13 @@ import {
15
15
  supportedStopActiveTransfer
16
16
  } from "../index.js";
17
17
  import { getP2PReplicatorConfigurationIdentity } from "./p2pReplicatorConfigurationIdentity.js";
18
- function useP2PReplicatorFeature(host, openReplicationUIFactory, openRebuildUIFactory) {
19
- const service = createP2PService({
20
- services: host.services
21
- });
18
+ function useP2PReplicatorFeature(host, openReplicationUIFactory, openRebuildUIFactory, options = {}) {
19
+ const service = createP2PService(
20
+ {
21
+ services: host.services
22
+ },
23
+ options
24
+ );
22
25
  const replicator = service.compatibilityReplicator;
23
26
  const { views, lifecycle } = service;
24
27
  const configureReplicator = (instance) => {
@@ -1,3 +1,3 @@
1
1
  import type { BaseRoomConfig } from "@trystero-p2p/nostr";
2
- import type { P2PConnectionInfo } from "../../common/models/setting.type.js";
3
- export declare function generateJoinRoomOptions(settings: P2PConnectionInfo): BaseRoomConfig;
2
+ import type { P2PConnectionInfo, P2PSyncSetting } from "../../common/models/setting.type.js";
3
+ export declare function generateJoinRoomOptions(settings: P2PConnectionInfo & Partial<Pick<P2PSyncSetting, "P2P_iceServers" | "P2P_iceServersExpiresAt">>): BaseRoomConfig;
@@ -1,5 +1,6 @@
1
1
  import { P2PConnectionPaths } from "../../common/models/setting.const.js";
2
2
  import {
3
+ hasManagedP2PTurnConfiguration,
3
4
  hasValidP2PTurnServerUrl,
4
5
  normaliseP2PConnectionPath,
5
6
  splitP2PRelayUrls,
@@ -8,11 +9,38 @@ import {
8
9
  import { mixedHash } from "octagonal-wheels/hash/purejs";
9
10
  import { compatGlobal } from "../../common/coreEnvFunctions.js";
10
11
  import { createDiagRTCPeerConnectionConstructor } from "./DiagRTCPeerConnections.js";
12
+ function containsTurnUrl(iceServers) {
13
+ return iceServers.some((server) => {
14
+ const urls = typeof server.urls === "string" ? [server.urls] : server.urls;
15
+ return urls.some((url) => /^turns?:/iu.test(url));
16
+ });
17
+ }
18
+ function copyIceServers(iceServers) {
19
+ return iceServers.map((server) => ({
20
+ urls: typeof server.urls === "string" ? server.urls : [...server.urls],
21
+ ...server.username === void 0 ? {} : { username: server.username },
22
+ ...server.credential === void 0 ? {} : { credential: server.credential }
23
+ }));
24
+ }
11
25
  function generateJoinRoomOptions(settings) {
12
26
  const passphraseNumbers = mixedHash(settings.P2P_passphrase, 0);
13
27
  const passphrase = passphraseNumbers[0].toString(36) + passphraseNumbers[1].toString(36);
14
28
  const relays = splitP2PRelayUrls(settings.P2P_relays);
15
29
  const turnServers = splitP2PTurnServerUrls(settings.P2P_turnServers);
30
+ const managedTurnSelected = hasManagedP2PTurnConfiguration(settings);
31
+ const preparedIceServers = settings.P2P_iceServers;
32
+ if (preparedIceServers !== void 0 && preparedIceServers.length === 0) {
33
+ throw new Error("Prepared ICE servers must not be empty.");
34
+ }
35
+ if (managedTurnSelected && preparedIceServers === void 0) {
36
+ throw new Error("The selected managed TURN configuration requires prepared ICE servers.");
37
+ }
38
+ if (managedTurnSelected && preparedIceServers && !containsTurnUrl(preparedIceServers)) {
39
+ throw new Error("The prepared ICE servers do not contain a TURN route.");
40
+ }
41
+ if (preparedIceServers !== void 0 && normaliseP2PConnectionPath(settings.P2P_connectionPath) === P2PConnectionPaths.Relay && !containsTurnUrl(preparedIceServers)) {
42
+ throw new Error("Relay-only P2P requires a prepared TURN route.");
43
+ }
16
44
  const relayConfig = {
17
45
  manualReconnection: true,
18
46
  urls: relays
@@ -28,7 +56,9 @@ function generateJoinRoomOptions(settings) {
28
56
  } else if (typeof compatGlobal.RTCPeerConnection !== "undefined") {
29
57
  options.rtcPolyfill = compatGlobal.RTCPeerConnection;
30
58
  }
31
- if (turnServers.length > 0) {
59
+ if (preparedIceServers !== void 0) {
60
+ options.turnConfig = copyIceServers(preparedIceServers);
61
+ } else if (!managedTurnSelected && turnServers.length > 0) {
32
62
  options.turnConfig = [
33
63
  {
34
64
  urls: turnServers,
@@ -37,7 +67,7 @@ function generateJoinRoomOptions(settings) {
37
67
  }
38
68
  ];
39
69
  }
40
- if (normaliseP2PConnectionPath(settings.P2P_connectionPath) === P2PConnectionPaths.Relay && hasValidP2PTurnServerUrl(settings.P2P_turnServers)) {
70
+ if (normaliseP2PConnectionPath(settings.P2P_connectionPath) === P2PConnectionPaths.Relay && (preparedIceServers !== void 0 ? containsTurnUrl(preparedIceServers) : !managedTurnSelected && hasValidP2PTurnServerUrl(settings.P2P_turnServers))) {
41
71
  options.rtcConfig = {
42
72
  iceTransportPolicy: "relay"
43
73
  };
@@ -46,6 +46,9 @@ function migrateLegacyRemoteConfigurationsInPlace(settings, log) {
46
46
  }
47
47
  const preferredId = settings.remoteType === REMOTE_MINIO ? "legacy-s3" : settings.remoteType === REMOTE_P2P ? "legacy-p2p" : "legacy-couchdb";
48
48
  settings.activeConfigurationId = settings.remoteConfigurations[preferredId] ? preferredId : createdIds[0];
49
+ if (settings.remoteConfigurations["legacy-p2p"]) {
50
+ settings.P2P_ActiveRemoteConfigurationId = "legacy-p2p";
51
+ }
49
52
  return true;
50
53
  }
51
54
  function createRemoteConfigurationId() {
@@ -63,6 +63,7 @@ export declare abstract class SettingService<T extends ServiceContext = ServiceC
63
63
  */
64
64
  saveSettingData(): Promise<void>;
65
65
  private encryptRemoteConfigurationUris;
66
+ private hasManagedP2PProfileURI;
66
67
  private decryptRemoteConfigurationUris;
67
68
  /**
68
69
  * Event triggered before realising the settings.
@@ -5,6 +5,7 @@ import {
5
5
  LOG_LEVEL_VERBOSE,
6
6
  SALT_OF_PASSPHRASE,
7
7
  SETTING_KEY_P2P_DEVICE_NAME,
8
+ omitP2PRuntimeSettings,
8
9
  prepareSettingsForLoad
9
10
  } from "../../common/types.js";
10
11
  import { handlers } from "../lib/HandlerUtils.js";
@@ -143,12 +144,7 @@ class SettingService extends ServiceBase {
143
144
  async saveSettingData() {
144
145
  this.saveDeviceAndVaultName();
145
146
  const previousSettings = this._lastPersistedSettings ?? this.cloneSettings(this.settings);
146
- const settings = {
147
- ...this.settings,
148
- remoteConfigurations: Object.fromEntries(
149
- Object.entries(this.settings.remoteConfigurations || {}).map(([id, config]) => [id, { ...config }])
150
- )
151
- };
147
+ const settings = this.cloneSettings(this.settings);
152
148
  const hookResults = await this.onBeforeSaveSettingData(settings, previousSettings);
153
149
  for (const patch of hookResults) {
154
150
  if (patch instanceof Error || !patch) continue;
@@ -161,7 +157,15 @@ class SettingService extends ServiceBase {
161
157
  this.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, settings.P2P_DevicePeerName.trim());
162
158
  settings.P2P_DevicePeerName = "";
163
159
  }
160
+ delete settings.P2P_managedType;
161
+ delete settings.P2P_managedId;
162
+ delete settings.P2P_managedToken;
164
163
  if (this.usedPassphrase == "" && !await this.getPassphrase(settings)) {
164
+ if (Object.values(settings.remoteConfigurations).some((config) => this.hasManagedP2PProfileURI(config.uri))) {
165
+ const message = "Failed to retrieve a passphrase for managed P2P source data. Settings were not saved.";
166
+ this._log(message, LOG_LEVEL_URGENT);
167
+ throw new Error(message);
168
+ }
165
169
  this._log("Failed to retrieve passphrase. data.json contains unencrypted items!", LOG_LEVEL_NOTICE);
166
170
  } else {
167
171
  if (settings.couchDB_PASSWORD != "" || settings.couchDB_URI != "" || settings.couchDB_USER != "" || settings.couchDB_DBNAME) {
@@ -218,8 +222,24 @@ class SettingService extends ServiceBase {
218
222
  if (config.isEncrypted || config.uri.trim() === "") {
219
223
  continue;
220
224
  }
221
- const encryptedURI = await this.encryptConfigurationItem(config.uri, settings);
225
+ const managedP2PProfile = this.hasManagedP2PProfileURI(config.uri);
226
+ let encryptedURI;
227
+ try {
228
+ encryptedURI = await this.encryptConfigurationItem(config.uri, settings);
229
+ } catch (error) {
230
+ if (managedP2PProfile) {
231
+ const message = `Failed to encrypt managed P2P remote configuration '${id}'. Settings were not saved.`;
232
+ this._log(message, LOG_LEVEL_URGENT);
233
+ throw new Error(message);
234
+ }
235
+ throw error;
236
+ }
222
237
  if (encryptedURI === "") {
238
+ if (managedP2PProfile) {
239
+ const message = `Failed to encrypt managed P2P remote configuration '${id}'. Settings were not saved.`;
240
+ this._log(message, LOG_LEVEL_URGENT);
241
+ throw new Error(message);
242
+ }
223
243
  this._log(
224
244
  `Failed to encrypt remote configuration '${id}'. This entry will be saved in plain text.`,
225
245
  LOG_LEVEL_URGENT
@@ -233,6 +253,14 @@ class SettingService extends ServiceBase {
233
253
  };
234
254
  }
235
255
  }
256
+ hasManagedP2PProfileURI(uri) {
257
+ const trimmed = uri.trim();
258
+ if (!trimmed.startsWith("sls+p2p://")) return false;
259
+ const queryStart = trimmed.indexOf("?");
260
+ if (queryStart < 0) return false;
261
+ const query = trimmed.slice(queryStart + 1).split("#", 1)[0];
262
+ return (new URLSearchParams(query).get("managedType") ?? "").trim().length > 0;
263
+ }
236
264
  async decryptRemoteConfigurationUris(settings, passphrase) {
237
265
  const configs = settings.remoteConfigurations || {};
238
266
  for (const [id, config] of Object.entries(configs)) {
@@ -321,7 +349,7 @@ class SettingService extends ServiceBase {
321
349
  try {
322
350
  this.settings = await this.adjustSettings({
323
351
  ...this.settings,
324
- ...partial
352
+ ...omitP2PRuntimeSettings(partial)
325
353
  });
326
354
  } catch (ex) {
327
355
  this._log("Error in applying external settings: ", LOG_LEVEL_URGENT);
@@ -550,7 +578,7 @@ class SettingService extends ServiceBase {
550
578
  }
551
579
  cloneSettings(settings) {
552
580
  return {
553
- ...settings,
581
+ ...omitP2PRuntimeSettings(settings),
554
582
  remoteConfigurations: Object.fromEntries(
555
583
  Object.entries(settings.remoteConfigurations || {}).map(([id, config]) => [id, { ...config }])
556
584
  )
@@ -134,15 +134,15 @@ Keep [the maintained-host evidence](proven-in-use.md) aligned with the downstrea
134
134
 
135
135
  ## Integration tests
136
136
 
137
- The integration suite owns Commonlib's direct CouchDB and S3-compatible storage checks. Run it with managed, disposable CouchDB and MinIO containers:
137
+ The integration suite owns Commonlib's direct CouchDB and S3-compatible storage checks. Run it with managed, disposable CouchDB and RustFS containers:
138
138
 
139
139
  ```bash
140
140
  npm run test:integration:managed
141
141
  ```
142
142
 
143
- This command requires Docker Compose, creates only test credentials and data, and removes the containers and their volumes after the run. It is the same entry point used by package CI.
143
+ This command requires Docker Compose, creates only test credentials and data, and removes the containers and their volumes after the run. It is the same entry point used by package CI. The RustFS server and client images are pinned by digest; the Compose services retain the names `minio` and `minio-init`.
144
144
 
145
- To test services that are already running, use `npm run test:integration`. The runner accepts `hostname`, `username`, and `password` for CouchDB, and `minioEndpoint`, `accessKey`, `secretKey`, and `bucketName` for S3-compatible storage. Without overrides, it uses the endpoints and test credentials from the managed Compose environment. The selected MinIO bucket must already exist when services are not managed by the runner.
145
+ To test services that are already running, use `npm run test:integration`. The runner accepts `hostname`, `username`, and `password` for CouchDB, and `minioEndpoint`, `accessKey`, `secretKey`, and `bucketName` for S3-compatible storage. Without overrides, it uses the endpoints and test credentials from the managed Compose environment. The selected S3-compatible bucket must already exist when services are not managed by the runner.
146
146
 
147
147
  ## Messages and translation
148
148