From db9f7af4f15db764b52a837d6629de5f5453b8dc Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 13 Oct 2026 04:18:57 +0000 Subject: [PATCH 1/4] Preserve configuration encryption edits when applying settings --- docs/settings.md | 6 +- docs/troubleshooting.md | 6 + package-lock.json | 93 ++--- package.json | 5 +- .../ObsidianLiveSyncSettingTab.ts | 76 +++- .../ObsidianLiveSyncSettingTab.unit.spec.ts | 142 ++++++- .../main/ModuleLiveSyncMain.unit.spec.ts | 57 +++ test/e2e-obsidian/README.md | 3 + .../scripts/configuration-encryption.ts | 395 ++++++++++++++++++ test/e2e-obsidian/scripts/run-focused.ts | 1 + updates.md | 7 + 11 files changed, 720 insertions(+), 71 deletions(-) create mode 100644 test/e2e-obsidian/scripts/configuration-encryption.ts diff --git a/docs/settings.md b/docs/settings.md index 0f96a07d..0abe75d2 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -988,10 +988,14 @@ If this option is enabled, PouchDB will hold the connection open for 60 seconds, Setting key: configPassphraseStore +Choose how the passphrase for sensitive configuration items is provided. **Default** uses the existing default key. **Use a custom passphrase** uses the value of `configPassphrase` and saves that key on this device. **Ask an passphrase at every launch** requests a masked passphrase at start-up and caches a successfully entered key in memory for that launch. + +Apply this setting and its passphrase together. Applying them re-encrypts the saved sensitive values with the selected passphrase and changes how LiveSync obtains that passphrase in one operation. If Apply fails, the edited values stay available for another attempt. Re-encryption preserves the plaintext values: it does not change the E2EE passphrase, the ID key, or Vault contents. + #### Passphrase of sensitive configuration items Setting key: configPassphrase -This passphrase will not be copied to another device. It will be set to `Default` until you configure it again. +This passphrase is not copied to another device. With **Use a custom passphrase**, the selected mode is stored in the configuration but the key remains on this device. If protected settings need to be decrypted and another device does not have the key, LiveSync asks for the passphrase during start-up. After the protected settings validate it, the key is saved on that device. ### 4. Developer diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 17407675..388c55a3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -117,6 +117,12 @@ When a notice identifies an unknown feature, update this device and every other ## Setup and settings questions +### LiveSync asks for a configuration encryption passphrase at start-up + +When **Use a custom passphrase** is selected and this device has no usable saved key, enter the passphrase which protects the sensitive configuration. LiveSync validates it against the saved settings, then saves the validated key on this device before continuing start-up. **Ask an passphrase at every launch** requests a masked passphrase at start-up and caches a successfully entered key in memory for that launch. + +For the custom-key recovery prompt used by **Use a custom passphrase**, cancelling or closing the prompt stops this start-up attempt and preserves the encrypted configuration. Start Obsidian again to retry. A headless command-line process cannot complete that interactive recovery prompt, so it declines to load protected settings when recovery input is required. + ### Share a configuration with another device Generate an encrypted Setup URI from a working device. This preserves the intended remote profiles and selections while allowing the additional device to keep its own device-specific name. Store the URI and its passphrase separately. diff --git a/package-lock.json b/package-lock.json index 26be2de9..74b5f7b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.1.36", + "@vrtmrz/livesync-commonlib": "0.1.38", "@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", @@ -31,7 +31,7 @@ "markdown-it": "^14.3.2", "minimatch": "^10.2.5", "obsidian": "^1.13.1", - "octagonal-wheels": "^0.1.54", + "octagonal-wheels": "^0.1.55", "qrcode-generator": "^1.4.4", "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" }, @@ -4320,7 +4320,7 @@ "version": "8.69.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4567,9 +4567,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.36", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.36.tgz", - "integrity": "sha512-YEVEM3Uq0YnqMP70hOFhmfkyS2X3Ptp/t43EH/6yc/NirQhpTYfYV6hLEuXok5LEw2gkWV7G6wMiYGlEjFCTig==", + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.38.tgz", + "integrity": "sha512-dXE1P6DbjxWwhX2tKnUPLgw6CTyln922GJAQjkA8kcK+sfj8q/PfWC4pgo3/SP6Obor+1y/S7nfVo2ofbUPDjw==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", @@ -4585,7 +4585,7 @@ "idb": "^8.0.3", "markdown-it": "^14.2.0", "minimatch": "^10.2.5", - "octagonal-wheels": "^0.1.53", + "octagonal-wheels": "^0.1.55", "pouchdb-adapter-http": "^9.0.0", "pouchdb-adapter-idb": "^9.0.0", "pouchdb-adapter-indexeddb": "^9.0.0", @@ -9629,9 +9629,9 @@ "license": "MIT" }, "node_modules/octagonal-wheels": { - "version": "0.1.54", - "resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.54.tgz", - "integrity": "sha512-Je3ancYhjKX7UY2K19T/qTjG8C9nK8YVrACr5naIf78mN4bbjQkYyWmlj+ooifV/moWVsQrp4fEWz/7mv6It3A==", + "version": "0.1.55", + "resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.55.tgz", + "integrity": "sha512-mHsQv/h4Tw4ZGJCmTxXFlgeRvPuL9s47C0arD8Njqld+DKr3ppaWjVteEBP+9bWFSOpv/a1u2yY3LYNFGmuexg==", "license": "MIT", "dependencies": { "idb": "^8.0.3" @@ -11107,6 +11107,35 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -11205,35 +11234,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -11408,21 +11408,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz", diff --git a/package.json b/package.json index ea74620e..71ccfdfe 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts", "test:e2e:obsidian:document-history-restore": "tsx test/e2e-obsidian/scripts/document-history-restore.ts", "test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts", + "test:e2e:obsidian:configuration-encryption": "tsx test/e2e-obsidian/scripts/configuration-encryption.ts", "test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts", "test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts", "test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts", @@ -192,7 +193,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.1.36", + "@vrtmrz/livesync-commonlib": "0.1.38", "@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", @@ -200,7 +201,7 @@ "markdown-it": "^14.3.2", "minimatch": "^10.2.5", "obsidian": "^1.13.1", - "octagonal-wheels": "^0.1.54", + "octagonal-wheels": "^0.1.55", "qrcode-generator": "^1.4.4", "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" }, diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts index 5bf463d1..02caffa8 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts @@ -133,7 +133,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { applySetting(keys: AllSettingItemKey[]) { for (const k of keys) { if (!this.isDirty(k)) continue; - if (k in OnDialogSettingsDefault) { + if (this.isConfigurationEncryptionSetting(k) || k in OnDialogSettingsDefault) { // //@ts-ignore // this.initialSettings[k] = this.editingSettings[k]; continue; @@ -141,21 +141,21 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { this.copySettingValue(this.core.settings, this.editingSettings, k); this.copySettingValue(this.initialSettings, this.core.settings, k); } - keys.forEach((e) => this.refreshSetting(e)); + keys.forEach((e) => { + if (this.isConfigurationEncryptionSetting(e) && this.isDirty(e)) return; + this.refreshSetting(e); + }); } - applyAllSettings() { + async applyAllSettings() { const changedKeys = (Object.keys(this.editingSettings ?? {}) as AllSettingItemKey[]).filter((e) => this.isDirty(e) ); + await this.saveConfigurationEncryptionIfDirty(changedKeys); this.applySetting(changedKeys); this.reloadAllSettings(); } async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) { - if (key == "configPassphrase") { - compatGlobal.localStorage.setItem("ls-setting-passphrase", this.editingSettings?.[key] ?? ""); - return await Promise.resolve(); - } if (key == "deviceAndVaultName") { this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? ""); this.services.setting.saveDeviceAndVaultName(); @@ -168,10 +168,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { */ async saveSettings(keys: AllSettingItemKey[]) { let hasChanged = false; - const appliedKeys = [] as AllSettingItemKey[]; - for (const k of keys) { - if (!this.isDirty(k)) continue; - appliedKeys.push(k); + const appliedKeys = keys.filter((k) => this.isDirty(k)); + await this.saveConfigurationEncryptionIfDirty(appliedKeys); + for (const k of appliedKeys) { + if (this.isConfigurationEncryptionSetting(k)) continue; if (k in OnDialogSettingsDefault) { await this.saveLocalSetting(k as keyof OnDialogSettings); this.copySettingValue(this.initialSettings, this.editingSettings, k); @@ -192,7 +192,32 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { .map((e) => Promise.resolve(e.handler(this.editingSettings[e.key]))); await Promise.all(handlers); // } - keys.forEach((e) => this.refreshSetting(e)); + keys.forEach((e) => { + if (this.isConfigurationEncryptionSetting(e) && this.isDirty(e)) return; + this.refreshSetting(e); + }); + } + + private isConfigurationEncryptionSetting(key: AllSettingItemKey): boolean { + return key === "configPassphraseStore" || key === "configPassphrase"; + } + + private async saveConfigurationEncryptionIfDirty(keys: AllSettingItemKey[]): Promise { + if (!keys.some((key) => this.isConfigurationEncryptionSetting(key) && this.isDirty(key))) { + return; + } + + const appliedConfigurationEncryption = { + configPassphraseStore: this.editingSettings.configPassphraseStore, + configPassphrase: this.editingSettings.configPassphrase, + }; + await this.services.setting.changeConfigurationEncryption( + appliedConfigurationEncryption.configPassphraseStore, + appliedConfigurationEncryption.configPassphrase + ); + + this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore"); + this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase"); } /** @@ -272,10 +297,35 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { * Reread all settings and request invalidate */ reloadAllSettings(skipUpdate: boolean = false) { + const previousSettings = this._editingSettings; + const previousSnapshot = this.initialSettings; + const pendingConfigurationEncryption = + previousSettings && previousSnapshot + ? { + configPassphraseStore: isObjectDifferent( + previousSettings.configPassphraseStore, + previousSnapshot.configPassphraseStore + ) + ? { value: previousSettings.configPassphraseStore } + : undefined, + configPassphrase: isObjectDifferent( + previousSettings.configPassphrase, + previousSnapshot.configPassphrase + ) + ? { value: previousSettings.configPassphrase } + : undefined, + } + : undefined; const localSetting = this.reloadAllLocalSettings(); this._editingSettings = { ...this.core.settings, ...localSetting }; this._editingSettings = { ...this.editingSettings, ...this.computeAllLocalSettings() }; this.initialSettings = { ...this.editingSettings }; + if (pendingConfigurationEncryption?.configPassphraseStore) { + this._editingSettings.configPassphraseStore = pendingConfigurationEncryption.configPassphraseStore.value; + } + if (pendingConfigurationEncryption?.configPassphrase) { + this._editingSettings.configPassphrase = pendingConfigurationEncryption.configPassphrase.value; + } if (!skipUpdate) this.requestUpdate(); } @@ -1020,7 +1070,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { if (!this.editingSettings.encrypt) { this.editingSettings.passphrase = ""; } - this.applyAllSettings(); + await this.applyAllSettings(); await this.services.setting.suspendAllSync(); await this.services.setting.suspendExtraSync(); this.reloadAllSettings(); diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts index 3f39d081..04332958 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; const settingsInitialisationMocks = vi.hoisted(() => ({ applySettingsWithInitialisationChoice: vi.fn(), @@ -75,6 +76,8 @@ beforeEach(() => { settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset(); loggerMocks.Logger.mockClear(); vi.mocked($msg).mockClear(); + vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null); + vi.mocked(compatGlobal.localStorage.setItem).mockReset(); }); describe("ObsidianLiveSyncSettingTab passphrase verification", () => { @@ -260,6 +263,9 @@ describe("ObsidianLiveSyncSettingTab Fresh Start Wipe", () => { describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => { function createSettingsTab() { const saveSettingData = vi.fn(async () => undefined); + const suspendAllSync = vi.fn(async () => undefined); + const suspendExtraSync = vi.fn(async () => undefined); + const performRebuildDB = vi.fn(async () => undefined); const confirmWithMessage = vi.fn(); const plugin = { app: {}, @@ -276,8 +282,13 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => { setting: { saveSettingData, getDeviceAndVaultName: vi.fn(() => ""), + suspendAllSync, + suspendExtraSync, }, }, + rebuilder: { + $performRebuildDB: performRebuildDB, + }, }, }; const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never); @@ -294,7 +305,15 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => { vi.spyOn(tab, "isPassphraseValid").mockResolvedValue(true); vi.spyOn(tab, "checkWorkingPassphrase").mockResolvedValue(true); const closeSetting = vi.spyOn(tab, "closeSetting").mockImplementation(() => undefined); - return { tab, saveSettingData, confirmWithMessage, closeSetting }; + return { + tab, + saveSettingData, + suspendAllSync, + suspendExtraSync, + performRebuildDB, + confirmWithMessage, + closeSetting, + }; } it("keeps pending settings in the editing buffer when initialisation and the fallback are cancelled", async () => { @@ -365,6 +384,49 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => { expect(tab.core.settings.handleFilenameCaseSensitive).toBe(false); expect(closeSetting).not.toHaveBeenCalled(); }); + + it("does not apply unrelated settings or start a rebuild when configuration encryption fails", async () => { + const { tab, saveSettingData, suspendAllSync, suspendExtraSync, performRebuildDB, closeSetting } = + createSettingsTab(); + const failure = new Error("configuration encryption could not be saved"); + const changeConfigurationEncryption = vi.fn(async () => { + throw failure; + }); + Object.assign(tab.core.services.setting, { changeConfigurationEncryption }); + const savedStore = "LOCALSTORAGE"; + const editedStore = "ASK_AT_LAUNCH"; + tab.core.settings.encrypt = false; + tab.core.settings.configPassphraseStore = savedStore; + tab.initialSettings!.configPassphraseStore = savedStore; + tab.editingSettings.configPassphraseStore = editedStore; + tab.initialSettings!.configPassphrase = "old custom passphrase"; + tab.editingSettings.configPassphrase = "new custom passphrase"; + tab.editingSettings.handleFilenameCaseSensitive = true; + const applySetting = vi.spyOn(tab, "applySetting"); + const reloadAllSettings = vi.spyOn(tab, "reloadAllSettings"); + const saveAllDirtySettings = vi.spyOn(tab, "saveAllDirtySettings"); + + await expect(tab.rebuildDB("localOnly")).rejects.toBe(failure); + + expect(changeConfigurationEncryption).toHaveBeenCalledOnce(); + expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase"); + expect(applySetting).not.toHaveBeenCalled(); + expect(tab.core.settings.handleFilenameCaseSensitive).toBe(false); + expect(tab.initialSettings!.handleFilenameCaseSensitive).toBe(false); + expect(tab.editingSettings.handleFilenameCaseSensitive).toBe(true); + expect(tab.core.settings.configPassphraseStore).toBe(savedStore); + expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore); + expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase"); + expect(tab.editingSettings.configPassphraseStore).toBe(editedStore); + expect(tab.editingSettings.configPassphrase).toBe("new custom passphrase"); + expect(suspendAllSync).not.toHaveBeenCalled(); + expect(suspendExtraSync).not.toHaveBeenCalled(); + expect(reloadAllSettings).not.toHaveBeenCalled(); + expect(saveAllDirtySettings).not.toHaveBeenCalled(); + expect(saveSettingData).not.toHaveBeenCalled(); + expect(closeSetting).not.toHaveBeenCalled(); + expect(performRebuildDB).not.toHaveBeenCalled(); + }); }); describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => { @@ -463,4 +525,82 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => { expect(first).not.toHaveBeenCalled(); expect(replacement).toHaveBeenCalledOnce(); }); + + it("saves a custom configuration passphrase edit through the setting service", async () => { + const { tab } = createSettingsTab(); + const changeConfigurationEncryption = vi.fn(async () => { + vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("new custom passphrase"); + }); + Object.assign(tab.core.services.setting, { changeConfigurationEncryption }); + const store = "LOCALSTORAGE"; + tab.editingSettings.configPassphraseStore = store; + tab.initialSettings!.configPassphraseStore = store; + tab.editingSettings.configPassphrase = "new custom passphrase"; + tab.initialSettings!.configPassphrase = "old custom passphrase"; + vi.mocked(compatGlobal.localStorage.setItem).mockClear(); + + await tab.saveSettings(["configPassphrase"]); + + expect(changeConfigurationEncryption).toHaveBeenCalledOnce(); + expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase"); + expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled(); + expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase"); + }); + + it("keeps a later passphrase edit dirty while an earlier Apply is pending", async () => { + const { tab } = createSettingsTab(); + let resolveApply!: () => void; + const changeConfigurationEncryption = vi.fn( + () => + new Promise((resolve) => { + resolveApply = resolve; + }) + ); + Object.assign(tab.core.services.setting, { changeConfigurationEncryption }); + tab.editingSettings.configPassphraseStore = "LOCALSTORAGE"; + tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE"; + tab.initialSettings!.configPassphrase = "previous passphrase"; + tab.editingSettings.configPassphrase = "passphrase A"; + vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A"); + + const apply = tab.saveSettings(["configPassphrase"]); + expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A"); + + tab.editingSettings.configPassphrase = "passphrase B"; + resolveApply(); + await apply; + tab.reloadAllSettings(); + + expect(tab.initialSettings!.configPassphrase).toBe("passphrase A"); + expect(tab.editingSettings.configPassphrase).toBe("passphrase B"); + expect(tab.isDirty("configPassphrase")).toBe(true); + }); + + it("keeps both configuration encryption edits pending when applying them fails", async () => { + const { tab } = createSettingsTab(); + const failure = new Error("configuration encryption could not be saved"); + const changeConfigurationEncryption = vi.fn(async () => { + throw failure; + }); + Object.assign(tab.core.services.setting, { changeConfigurationEncryption }); + const savedStore = "LOCALSTORAGE"; + const editedStore = "ASK_AT_LAUNCH"; + tab.core.settings.configPassphraseStore = savedStore; + tab.initialSettings!.configPassphraseStore = savedStore; + tab.editingSettings.configPassphraseStore = editedStore; + tab.initialSettings!.configPassphrase = "old custom passphrase"; + tab.editingSettings.configPassphrase = "new custom passphrase"; + vi.mocked(compatGlobal.localStorage.setItem).mockClear(); + + await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure); + + expect(changeConfigurationEncryption).toHaveBeenCalledOnce(); + expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase"); + expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled(); + expect(tab.core.settings.configPassphraseStore).toBe(savedStore); + expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore); + expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase"); + expect(tab.editingSettings.configPassphraseStore).toBe(editedStore); + expect(tab.editingSettings.configPassphrase).toBe("new custom passphrase"); + }); }); diff --git a/src/modules/main/ModuleLiveSyncMain.unit.spec.ts b/src/modules/main/ModuleLiveSyncMain.unit.spec.ts index 1fc7a139..bbb744ce 100644 --- a/src/modules/main/ModuleLiveSyncMain.unit.spec.ts +++ b/src/modules/main/ModuleLiveSyncMain.unit.spec.ts @@ -17,9 +17,66 @@ vi.mock("@/common/translation", () => ({ setLang: vi.fn(), })); +vi.mock("@vrtmrz/livesync-commonlib/compat/worker/bgWorker", () => ({ + initialiseWorkerModule: vi.fn(), +})); + import { ModuleLiveSyncMain } from "./ModuleLiveSyncMain.ts"; describe("ModuleLiveSyncMain", () => { + it.each(["unlock", "cancel"] as const)( + "keeps database and settings lifecycle behind a pending configuration load: %s", + async (outcome) => { + let unlock!: () => void; + let cancel!: (error: Error) => void; + const loadSettings = vi.fn( + () => + new Promise((resolve, reject) => { + unlock = resolve; + cancel = reject; + }) + ); + const appLifecycle = { + onWireUpEvents: vi.fn(async () => true), + onInitialise: vi.fn(async () => true), + onSettingLoaded: vi.fn(async () => true), + onLoaded: vi.fn(async () => true), + }; + const openDatabase = vi.fn(async () => undefined); + const services = { + context: { events: {} }, + setting: { loadSettings }, + appLifecycle, + database: { openDatabase }, + databaseEvents: {}, + replicator: {}, + }; + const host = { services, core: { services, addOns: [] }, _log: vi.fn() }; + const pending = ModuleLiveSyncMain.prototype._onLiveSyncLoad.call(host as never); + await vi.waitFor(() => expect(loadSettings).toHaveBeenCalledOnce()); + expect(appLifecycle.onSettingLoaded).not.toHaveBeenCalled(); + expect(openDatabase).not.toHaveBeenCalled(); + expect(appLifecycle.onLoaded).not.toHaveBeenCalled(); + + if (outcome === "cancel") { + const rejected = expect(pending).rejects.toThrow("synthetic cancelled configuration load"); + cancel(new Error("synthetic cancelled configuration load")); + await rejected; + expect(appLifecycle.onSettingLoaded).not.toHaveBeenCalled(); + expect(openDatabase).not.toHaveBeenCalled(); + expect(appLifecycle.onLoaded).not.toHaveBeenCalled(); + } else { + unlock(); + await expect(pending).resolves.toBe(true); + expect(appLifecycle.onSettingLoaded).toHaveBeenCalledOnce(); + expect(openDatabase).toHaveBeenCalledOnce(); + expect(appLifecycle.onSettingLoaded.mock.invocationCallOrder[0]).toBeLessThan( + openDatabase.mock.invocationCallOrder[0] + ); + } + } + ); + it("reports a database preparation failure at the application boundary", async () => { const initialiseDatabase = vi.fn(async () => false); const log = vi.fn(); diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index daf382c5..6d14b597 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -74,6 +74,7 @@ After changing plug-in source, use the focused wrapper rather than invoking a sc ```bash npm run test:e2e:obsidian:focused -- settings-ui +npm run test:e2e:obsidian:focused -- configuration-encryption npm run test:e2e:obsidian:focused -- two-vault-sync npm run test:e2e:obsidian:focused -- stale-file-restart npm run test:e2e:obsidian:focused -- folder-batch @@ -111,6 +112,8 @@ The underlying `test:e2e:obsidian:` scripts remain available for an im `test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from Quick Setup in either settings interface. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. +`configuration-encryption` needs no remote service. It applies a custom configuration key through the settings interface, restarts the same isolated Vault, and applies a change to only the custom string. It then copies the encrypted settings into a fresh profile without that key and checks masked input, wrong-input retry, cancellation without a settings write or database initialisation, re-enabling for another attempt, access to the settings tab, and restoration after a further restart. Connection, E2EE, profile, and independent ID values must remain unchanged. These checks establish ordinary Apply and restart behaviour, without simulating process termination between the device-key and settings writes. + `test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database. `test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour. diff --git a/test/e2e-obsidian/scripts/configuration-encryption.ts b/test/e2e-obsidian/scripts/configuration-encryption.ts new file mode 100644 index 00000000..2db9d6b5 --- /dev/null +++ b/test/e2e-obsidian/scripts/configuration-encryption.ts @@ -0,0 +1,395 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Locator, Page } from "playwright"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { openLiveSyncSettings, waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const coreExpression = "app.plugins.plugins['obsidian-livesync'].core"; +const firstKey = "synthetic-first-configuration-key"; +const secondKey = "synthetic-second-configuration-key"; +const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; +const screenshots: string[] = []; +const saveFailureMessage = + "Configuration encryption could not be saved. Verify the saved settings and device key before retrying."; +const secrets = { + couchDB_URI: "https://synthetic.invalid", + couchDB_PASSWORD: "synthetic-connection-password", + passphrase: "%synthetic-e2ee-passphrase", + idDerivationKey: "ab".repeat(32), + profile: "sls+http://synthetic-user:synthetic-password@synthetic.invalid/?db=synthetic-database", +}; + +async function readSecrets(cli: string, session: ObsidianLiveSyncSession): Promise { + return await evalObsidianJson( + cli, + `(()=>{ + const settings=${coreExpression}.services.setting.currentSettings(); + return JSON.stringify({couchDB_URI:settings.couchDB_URI,couchDB_PASSWORD:settings.couchDB_PASSWORD, + passphrase:settings.passphrase,idDerivationKey:settings.idDerivationKey, + profile:settings.remoteConfigurations.inactive.uri}); + })()`, + session.cliEnv + ); +} + +async function capture(locator: Locator, filename: string): Promise { + await mkdir(diagnosticsDirectory, { recursive: true }); + const path = join(diagnosticsDirectory, filename); + await locator.screenshot({ path, animations: "disabled", caret: "hide" }); + screenshots.push(path); +} + +async function assertPendingLoad( + cli: string, + env: NodeJS.ProcessEnv, + expectedKey: string | null = null +): Promise { + const state = await evalObsidianJson( + cli, + `(()=>{ + const core=${coreExpression}; + return JSON.stringify({databaseReady:core.services.database.isDatabaseReady(), + tabRegistered:core.modules.some(module=>!!module.settingTab), + key:localStorage.getItem('ls-setting-passphrase')}); + })()`, + env + ); + assert.deepEqual(state, { databaseReady: false, tabRegistered: false, key: expectedKey }); +} + +async function applyCustomKey(session: ObsidianLiveSyncSession, key: string, changeMode: boolean): Promise { + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + try { + const navigator = await openLiveSyncSettings(page); + const root = await navigator.openPage("Power users"); + if (changeMode) { + await root + .locator(".setting-item") + .filter({ hasText: "Encrypting sensitive configuration items" }) + .getByRole("combobox") + .selectOption("LOCALSTORAGE"); + } + await root.locator('input[type="password"]:visible').fill(key); + await capture( + navigator.dialogue, + changeMode ? "02-custom-before-apply.png" : "04-custom-key-only-edit.png" + ); + await root.getByRole("button", { name: "Apply", exact: true }).click(); + await page.waitForFunction(`(()=>{ + const core=${coreExpression}; + const tab=core.modules.find(module=>module.settingTab)?.settingTab; + return tab && !tab.isDirty('configPassphrase') && !tab.isDirty('configPassphraseStore') && + core.settings.configPassphraseStore==='LOCALSTORAGE' && + localStorage.getItem('ls-setting-passphrase')===${JSON.stringify(key)}; + })()`); + await capture(navigator.dialogue, changeMode ? "03-custom-applied.png" : "05-custom-key-only-applied.png"); + await navigator.close(); + } catch (error) { + await mkdir(diagnosticsDirectory, { recursive: true }); + await page.screenshot({ + path: join(diagnosticsDirectory, "configuration-apply.failure.png"), + fullPage: true, + }); + throw error; + } + }); +} + +async function assertDefaultSettings(session: ObsidianLiveSyncSession): Promise { + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + assert.equal(await page.locator('.modal-container input[type="password"]').count(), 0); + const navigator = await openLiveSyncSettings(page); + const root = await navigator.openPage("Power users"); + const store = root + .locator(".setting-item") + .filter({ hasText: "Encrypting sensitive configuration items" }) + .getByRole("combobox"); + assert.equal(await store.inputValue(), ""); + assert.equal(await root.locator('input[type="password"]:visible').isDisabled(), true); + await capture(navigator.dialogue, "01-default-after-restart.png"); + await navigator.close(); + }); +} + +async function assertRejectedEmptyKey(cli: string, session: ObsidianLiveSyncSession, dataPath: string): Promise { + const before = await readFile(dataPath, "utf8"); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const navigator = await openLiveSyncSettings(page); + const root = await navigator.openPage("Power users"); + await root.locator('input[type="password"]:visible').fill(""); + await root.getByRole("button", { name: "Apply", exact: true }).click(); + let notice: Locator | undefined; + const deadline = Date.now() + 5000; + while (Date.now() < deadline && !notice) { + for (const candidate of page.context().pages()) { + const matching = candidate.locator(".notice").filter({ hasText: saveFailureMessage }).last(); + if (await matching.isVisible()) { + notice = matching; + break; + } + } + if (!notice) await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.ok(notice, "The rejected Apply did not display its failure notice"); + await capture(notice, "06-apply-failure-notice.png"); + const state = await evalObsidianJson( + cli, + `(()=>{ + const core=${coreExpression}; + const tab=core.modules.find(module=>module.settingTab)?.settingTab; + return JSON.stringify({key:localStorage.getItem('ls-setting-passphrase'), + store:core.settings.configPassphraseStore,dirty:tab.isDirty('configPassphrase')}); + })()`, + session.cliEnv + ); + assert.deepEqual(state, { key: secondKey, store: "LOCALSTORAGE", dirty: true }); + assert.equal(await readFile(dataPath, "utf8"), before); + await capture(navigator.dialogue, "07-apply-failure-edits-retained.png"); + await root.locator('input[type="password"]:visible').fill(secondKey); + await navigator.close(); + }); +} + +async function submitKey(page: Page, title: string, key: string): Promise { + const dialogue = await waitForVisibleObsidianDialogue(page, title); + await dialogue.locator('input[type="password"]').fill(key); + await dialogue.getByRole("button", { name: "OK", exact: true }).click(); +} + +async function applyAskMode(session: ObsidianLiveSyncSession): Promise { + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const navigator = await openLiveSyncSettings(page); + const root = await navigator.openPage("Power users"); + await root + .locator(".setting-item") + .filter({ hasText: "Encrypting sensitive configuration items" }) + .getByRole("combobox") + .selectOption("ASK_AT_LAUNCH"); + await root.getByRole("button", { name: "Apply", exact: true }).click(); + await submitKey(page, "Configuration passphrase", secondKey); + await page.waitForFunction(`(()=>{ + const core=${coreExpression}; + const tab=core.modules.find(module=>module.settingTab)?.settingTab; + return tab && !tab.isDirty('configPassphraseStore') && core.settings.configPassphraseStore==='ASK_AT_LAUNCH'; + })()`); + await navigator.close(); + }); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked: ${cli.checked.join(", ")}`); + const cliBinary = cli.binary; + const firstVault = await createTemporaryVault("obsidian-livesync-configuration-source-"); + let secondVault: Awaited> | undefined; + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault: firstVault, + pluginData: { + doctorProcessedVersion: "1.0.35", + isConfigured: true, + remoteType: "", + usePowerUserMode: true, + liveSync: false, + periodicReplication: false, + syncOnStart: false, + syncOnSave: false, + syncOnFileOpen: false, + syncOnEditorSave: false, + syncAfterMerge: false, + displayLanguage: "", + notifyThresholdOfRemoteStorageSize: -1, + }, + localStorageEntries: createE2eObsidianDeviceLocalState(firstVault.name), + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await evalObsidianJson( + cliBinary, + `(async()=>{ + const core=${coreExpression}; + await core.services.setting.applyPartial({encrypt:true,idDerivationVersion:1, + idDerivationKey:${JSON.stringify(secrets.idDerivationKey)},passphrase:${JSON.stringify(secrets.passphrase)}, + couchDB_URI:${JSON.stringify(secrets.couchDB_URI)},couchDB_DBNAME:'synthetic-database', + couchDB_PASSWORD:${JSON.stringify(secrets.couchDB_PASSWORD)},activeConfigurationId:'', + remoteConfigurations:{inactive:{id:'inactive',name:'Synthetic profile', + uri:${JSON.stringify(secrets.profile)},isEncrypted:false}}},true); + return JSON.stringify(true); + })()`, + session.cliEnv + ); + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: firstVault }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await evalObsidianJson( + cliBinary, + `(async()=>{ + await ${coreExpression}.services.setting.saveSettingData(); + return JSON.stringify(true); + })()`, + session.cliEnv + ); + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: firstVault }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await assertDefaultSettings(session); + console.log( + "Configuration encryption: Default save and restart preserved all protected values without a key prompt." + ); + await applyCustomKey(session, firstKey, true); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + console.log("Configuration encryption: mode and custom key applied through the settings interface."); + await session.app.stop(); + session = undefined; + + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: firstVault }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await applyCustomKey(session, secondKey, false); + console.log("Configuration encryption: restart and custom-key-only Apply preserved all protected values."); + const sourceDataPath = join(firstVault.path, ".obsidian/plugins/obsidian-livesync/data.json"); + const encryptedData = await readFile(sourceDataPath, "utf8"); + const saved = JSON.parse(encryptedData) as Record; + assert.equal(saved.configPassphraseStore, "LOCALSTORAGE"); + assert.equal(saved.idDerivationKey, ""); + assert.equal(saved.passphrase, ""); + assert.equal(saved.couchDB_PASSWORD, ""); + for (const value of Object.values(secrets)) assert.equal(encryptedData.includes(value), false); + await assertRejectedEmptyKey(cliBinary, session, sourceDataPath); + console.log( + "Configuration encryption: rejected empty-key Apply displayed a notice and retained the device key, saved data, and pending edit." + ); + await session.app.stop(); + session = undefined; + + secondVault = await createTemporaryVault("obsidian-livesync-configuration-copy-"); + const copyDataPath = join(secondVault.path, ".obsidian/plugins/obsidian-livesync/data.json"); + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault: secondVault, + pluginData: saved, + localStorageEntries: createE2eObsidianDeviceLocalState(secondVault.name), + lifecycle: { + afterPluginLoad: async ({ remoteDebuggingPort, cliEnv }) => { + await withObsidianPage(remoteDebuggingPort, async (page) => { + try { + const missingKey = await waitForVisibleObsidianDialogue(page, "Configuration passphrase"); + const missingInput = missingKey.locator('input[type="password"]'); + assert.equal(await missingInput.getAttribute("type"), "password"); + await missingInput.fill("synthetic-wrong-key"); + await capture(missingKey.locator(".modal"), "08-missing-custom-key.png"); + await assertPendingLoad(cliBinary, cliEnv); + const beforeInput = await readFile(copyDataPath, "utf8"); + await submitKey(page, "Configuration passphrase", "synthetic-wrong-key"); + const retry = await waitForVisibleObsidianDialogue( + page, + "Configuration could not be decrypted" + ); + await retry.locator('input[type="password"]').fill(secondKey); + await capture(retry.locator(".modal"), "09-wrong-key-retry.png"); + await assertPendingLoad(cliBinary, cliEnv); + assert.equal(await readFile(copyDataPath, "utf8"), beforeInput); + await retry.getByRole("button", { name: "Cancel", exact: true }).click(); + await retry.waitFor({ state: "hidden" }); + await assertPendingLoad(cliBinary, cliEnv); + assert.equal(await readFile(copyDataPath, "utf8"), beforeInput); + + await evalObsidianJson( + cliBinary, + `(async()=>{ + await app.plugins.disablePlugin('obsidian-livesync'); + await app.plugins.enablePlugin('obsidian-livesync'); + return JSON.stringify(true); + })()`, + cliEnv + ); + await submitKey(page, "Configuration passphrase", secondKey); + } catch (error) { + await mkdir(diagnosticsDirectory, { recursive: true }); + await page.screenshot({ + path: join(diagnosticsDirectory, "configuration-unlock.failure.png"), + fullPage: true, + }); + throw error; + } + }); + }, + }, + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const navigator = await openLiveSyncSettings(page); + await navigator.openPage("Power users"); + await capture(navigator.dialogue, "10-unlocked-settings.png"); + await navigator.close(); + }); + await session.app.stop(); + session = undefined; + + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: secondVault }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + console.log( + "Configuration encryption: Apply and custom-key-only Apply survive restart; copied settings unlock after wrong input, cancellation, and retry; the validated key survives another restart." + ); + await applyAskMode(session); + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault: secondVault, + lifecycle: { + afterPluginLoad: async ({ remoteDebuggingPort, cliEnv }) => { + await withObsidianPage(remoteDebuggingPort, async (page) => { + const dialogue = await waitForVisibleObsidianDialogue(page, "Configuration passphrase"); + const input = dialogue.locator('input[type="password"]'); + assert.equal(await input.getAttribute("type"), "password"); + await input.fill(secondKey); + await assertPendingLoad(cliBinary, cliEnv, secondKey); + await capture(dialogue.locator(".modal"), "11-ask-at-launch-masked.png"); + await dialogue.getByRole("button", { name: "OK", exact: true }).click(); + }); + }, + }, + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await evalObsidianJson( + cliBinary, + `(async()=>{ + await ${coreExpression}.services.setting.saveSettingData(); + return JSON.stringify(true); + })()`, + session.cliEnv + ); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + console.log( + "Configuration encryption: ASK input was masked and the validated key was reused for an ordinary save in this launch." + ); + console.log(`Configuration encryption screenshots: ${JSON.stringify(screenshots)}`); + } finally { + if (session) await session.app.stop(); + await firstVault.dispose(); + if (secondVault) await secondVault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exitCode = 1; +}); diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index 84e3b733..090e50be 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -13,6 +13,7 @@ const focusedScenarios = new Set([ "document-history-nav", "document-history-restore", "settings-ui", + "configuration-encryption", "review-harness", "p2p-pane", "vault-reflection", diff --git a/updates.md b/updates.md index f94c2092..d2145a78 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Configuration Encryption + +#### Fixed + +- We now apply the settings for protecting sensitive configuration together, so a successful Apply keeps the saved settings under the selected passphrase. If Apply fails, the edits remain available to retry. (#1238) +- We can now enter a missing or unusable custom configuration passphrase at start-up to unlock protected settings. Cancelling the custom-key recovery prompt preserves the encrypted configuration and stops that start-up attempt; headless command-line start-up cannot complete that prompt when recovery input is required. (#1239) + ## 1.0.35 5th October, 2026 From b4d00d3ddac93ff488b29ab557a1503609270a96 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 13 Oct 2026 04:37:54 +0000 Subject: [PATCH 2/4] Restore npm 10 clean-install compatibility --- package-lock.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 74b5f7b3..7275cec8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4320,7 +4320,7 @@ "version": "8.69.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11408,6 +11408,21 @@ } } }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/svelte-eslint-parser": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz", From aa69212db92e4af050b0265d728edae4345b7721 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 13 Oct 2026 08:04:51 +0000 Subject: [PATCH 3/4] Remove vulnerable development tooling and update Commonlib --- eslint.config.common.mjs | 7 +- eslint.config.mjs | 7 +- package-lock.json | 951 +++------------------------- package.json | 5 +- updates.md | 5 + utils/copy-build-files.ts | 42 ++ utils/copy-build-files.unit.spec.ts | 107 ++++ utils/import-alias.mjs | 62 ++ utils/import-alias.unit.spec.ts | 104 +++ vite.config.ts | 11 +- 10 files changed, 404 insertions(+), 897 deletions(-) create mode 100644 utils/copy-build-files.ts create mode 100644 utils/copy-build-files.unit.spec.ts create mode 100644 utils/import-alias.mjs create mode 100644 utils/import-alias.unit.spec.ts diff --git a/eslint.config.common.mjs b/eslint.config.common.mjs index bdecfb05..bc4d1017 100644 --- a/eslint.config.common.mjs +++ b/eslint.config.common.mjs @@ -106,13 +106,10 @@ export const obsidianRules = { * @type {(base:string) => import("eslint").Linter.RulesRecord} */ export const ImportAliasRules = (base) => ({ - "@dword-design/import-alias/prefer-alias": [ + "livesync-imports/prefer-source-alias": [ "error", { - aliasForSubpaths: true, - alias: { - "@": `${base}/src`, - }, + sourceRoot: `${base}/src`, }, ], }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 84cbbfbd..bcbd8688 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,7 +4,7 @@ import globals from "globals"; import { defineConfig, globalIgnores } from "eslint/config"; import * as sveltePlugin from "eslint-plugin-svelte"; import svelteParser from "svelte-eslint-parser"; -import importAlias from "@dword-design/eslint-plugin-import-alias"; +import importAlias from "./utils/import-alias.mjs"; import { baseRules, ImportAliasRules, obsidianRules } from "./eslint.config.common.mjs"; const warnWhileDev = "off"; // Change to "warn" to enable warnings for rules that are currently disabled. const lintProjects = [ @@ -56,7 +56,10 @@ export default defineConfig([ ]), ...sveltePlugin.configs["flat/base"], ...obsidianmd.configs.recommended, - importAlias.configs.recommended, + { + plugins: { "livesync-imports": importAlias }, + rules: { "livesync-imports/prefer-source-alias": "error" }, + }, { files: ["**/*.ts"], languageOptions: { diff --git a/package-lock.json b/package-lock.json index 7275cec8..6a4bdc03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.1.38", + "@vrtmrz/livesync-commonlib": "0.1.39", "@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", @@ -36,7 +36,6 @@ "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" }, "devDependencies": { - "@dword-design/eslint-plugin-import-alias": "^8.1.8", "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@eslint/js": "^9.39.3", @@ -45,7 +44,6 @@ "@types/deno": "^2.5.0", "@types/diff-match-patch": "^1.0.36", "@types/markdown-it": "^14.1.2", - "@types/micromatch": "^4.0.10", "@types/node": "^24.10.13", "@types/pouchdb": "^6.4.2", "@types/pouchdb-adapter-http": "^6.1.6", @@ -81,7 +79,6 @@ "pouchdb-replication": "^9.0.0", "pouchdb-utils": "^9.0.0", "prettier": "3.8.1", - "rollup-plugin-copy": "^3.5.0", "svelte": "5.56.3", "svelte-check": "^4.6.0", "svelte-eslint-parser": "^1.8.0", @@ -954,6 +951,8 @@ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", @@ -968,7 +967,9 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@babel/compat-data": { "version": "7.29.7", @@ -976,6 +977,8 @@ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6.9.0" } @@ -986,6 +989,8 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1017,6 +1022,8 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -1030,6 +1037,8 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -1040,6 +1049,8 @@ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", @@ -1057,6 +1068,8 @@ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", @@ -1074,6 +1087,8 @@ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -1084,6 +1099,8 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -1094,6 +1111,8 @@ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1104,6 +1123,8 @@ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" @@ -1118,6 +1139,8 @@ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", @@ -1156,6 +1179,8 @@ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1166,6 +1191,8 @@ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" @@ -1196,6 +1223,8 @@ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", @@ -1211,6 +1240,8 @@ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1271,70 +1302,6 @@ "w3c-keyname": "^2.2.4" } }, - "node_modules/@dword-design/defaults": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@dword-design/defaults/-/defaults-1.1.8.tgz", - "integrity": "sha512-66Ubh9RjKyb/0/myYNFqzXzkowM0zWy5ImTp/wR3u4KZf8ymI5BBOO/9+QyDz1uBLV1dZGskUVMxSDYgQRmAkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-obj": "^4.1.0", - "lodash-es": "^4.17.21", - "type-fest": "^5.6.0" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sponsors/dword-design" - } - }, - "node_modules/@dword-design/defaults/node_modules/type-fest": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", - "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dword-design/eslint-plugin-import-alias": { - "version": "8.1.8", - "resolved": "https://registry.npmjs.org/@dword-design/eslint-plugin-import-alias/-/eslint-plugin-import-alias-8.1.8.tgz", - "integrity": "sha512-hqAg9ys+YD2+rIheqeu6DACbuqsUpEbrNur05m/n2AsWL37s5l8qzjdnoYFCr47nsDMP7dIQJiP0YBqToAc/Ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.0", - "@dword-design/defaults": "^1.1.2", - "@types/babel__core": "^7.20.5", - "@types/lodash-es": "^4.17.12", - "@typescript-eslint/utils": "^8.57.0", - "babel-plugin-module-resolver": "^5.0.2", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sponsors/dword-design" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -2317,44 +2284,6 @@ ], "license": "MIT" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -3701,58 +3630,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/braces": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", - "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3836,27 +3713,6 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, - "node_modules/@types/fs-extra": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", - "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3878,23 +3734,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", @@ -3913,23 +3752,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/micromatch": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.10.tgz", - "integrity": "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/braces": "*" - } - }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -4567,9 +4389,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.38", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.38.tgz", - "integrity": "sha512-dXE1P6DbjxWwhX2tKnUPLgw6CTyln922GJAQjkA8kcK+sfj8q/PfWC4pgo3/SP6Obor+1y/S7nfVo2ofbUPDjw==", + "version": "0.1.39", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.39.tgz", + "integrity": "sha512-AUjQI1fgR+4KOYpuwZfFjVJeSt9JDNSLMAlLoLuh/553CzMnqTfwqopVItKP3b3SrdVQbyGbJLQeGEhuDMoJ9Q==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", @@ -4583,8 +4405,8 @@ "events": "^3.3.0", "fflate": "^0.8.2", "idb": "^8.0.3", - "markdown-it": "^14.2.0", - "minimatch": "^10.2.5", + "markdown-it": "^14.3.2", + "minimatch": "^10.2.6", "octagonal-wheels": "^0.1.55", "pouchdb-adapter-http": "^9.0.0", "pouchdb-adapter-idb": "^9.0.0", @@ -4790,16 +4612,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -4992,117 +4804,6 @@ "node": ">= 0.4" } }, - "node_modules/babel-plugin-module-resolver": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/babel-plugin-module-resolver/-/babel-plugin-module-resolver-5.0.3.tgz", - "integrity": "sha512-h8h6H71ZvdLJZxZrYkaeR30BojTaV7O9GfqacY14SNj5CNB8ocL9tydNzTC0JrnNN7vY3eJhwCmkDj7tuEUaqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-babel-config": "^2.1.1", - "glob": "^9.3.3", - "pkg-up": "^3.1.0", - "reselect": "^4.1.7", - "resolve": "^1.22.8" - } - }, - "node_modules/babel-plugin-module-resolver/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/babel-plugin-module-resolver/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/babel-plugin-module-resolver/node_modules/glob": { - "version": "9.3.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", - "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "minimatch": "^8.0.2", - "minipass": "^4.2.4", - "path-scurry": "^1.6.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-plugin-module-resolver/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/babel-plugin-module-resolver/node_modules/minimatch": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", - "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-plugin-module-resolver/node_modules/minipass": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", - "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-module-resolver/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-plugin-module-resolver/node_modules/path-scurry/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5138,6 +4839,8 @@ "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "bin": { "baseline-browser-mapping": "dist/cli.cjs" }, @@ -5163,19 +4866,6 @@ "node": "20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -5196,6 +4886,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -5320,7 +5012,9 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0" + "license": "CC-BY-4.0", + "optional": true, + "peer": true }, "node_modules/catering": { "version": "2.1.1", @@ -5403,13 +5097,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -5641,19 +5328,6 @@ "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", "license": "Apache-2.0" }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -5763,7 +5437,9 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.419.tgz", "integrity": "sha512-nHMPn8x4yCxCI0iSnL+LlHL5sUoUfjLXkcRIagZ4GBdrfFLFaiLNvzJWbJqZhFT9IAhw5tUSNlhggWN+otvp/A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/empathic": { "version": "2.0.1", @@ -6107,6 +5783,8 @@ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -7042,36 +6720,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -7140,16 +6788,6 @@ "fxparser": "src/cli/cli.js" } }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fetch-cookie": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", @@ -7179,42 +6817,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-babel-config": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/find-babel-config/-/find-babel-config-2.1.2.tgz", - "integrity": "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "json5": "^2.2.3" - } - }, - "node_modules/find-babel-config/node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -7287,28 +6889,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -7396,6 +6976,8 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6.9.0" } @@ -7513,79 +7095,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.1.tgz", - "integrity": "sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/globby/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/globby/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/globby/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globby/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7776,18 +7285,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -8052,16 +7549,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -8079,29 +7566,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-object": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz", - "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -8395,6 +7859,8 @@ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "jsesc": "bin/jsesc" }, @@ -8507,16 +7973,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -9110,13 +8566,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9284,37 +8733,13 @@ "xtend": "~4.0.0" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -9476,6 +8901,8 @@ "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -9637,16 +9064,6 @@ "idb": "^8.0.3" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9763,16 +9180,6 @@ "node": ">=14.0.0" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -9790,16 +9197,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -9814,19 +9211,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -9896,85 +9280,6 @@ "node": ">=8" } }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-up/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-up/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/playwright": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", @@ -10101,9 +9406,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", - "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -10635,13 +9940,6 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, - "node_modules/reselect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", - "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -10684,17 +9982,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -10729,47 +10016,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, - "node_modules/rollup-plugin-copy": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-copy/-/rollup-plugin-copy-3.5.0.tgz", - "integrity": "sha512-wI8D5dvYovRMx/YYKtUNt3Yxaw4ORC9xo6Gt9t22kveWz1enG9QrhVlagzwrxSC455xD1dHMKhIJkbsQ7d48BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/fs-extra": "^8.0.1", - "colorette": "^1.1.0", - "fs-extra": "^8.1.0", - "globby": "10.0.1", - "is-plain-object": "^3.0.0" - }, - "engines": { - "node": ">=8.3" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -11032,16 +10278,6 @@ "dev": true, "license": "ISC" }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11053,9 +10289,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -11540,19 +10776,6 @@ "url": "https://opencollective.com/unts" } }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -11677,19 +10900,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toml-eslint-parser": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.9.3.tgz", @@ -12009,16 +11219,6 @@ "dev": true, "license": "MIT" }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/update-browserslist-db": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", @@ -12039,6 +11239,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -12551,13 +11753,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, "node_modules/write-stream": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/write-stream/-/write-stream-0.4.3.tgz", @@ -12608,7 +11803,9 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/yaml": { "version": "2.9.0", diff --git a/package.json b/package.json index 71ccfdfe..eaea3122 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,6 @@ "author": "vorotamoroz", "license": "MIT", "devDependencies": { - "@dword-design/eslint-plugin-import-alias": "^8.1.8", "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@eslint/js": "^9.39.3", @@ -132,7 +131,6 @@ "@types/deno": "^2.5.0", "@types/diff-match-patch": "^1.0.36", "@types/markdown-it": "^14.1.2", - "@types/micromatch": "^4.0.10", "@types/node": "^24.10.13", "@types/pouchdb": "^6.4.2", "@types/pouchdb-adapter-http": "^6.1.6", @@ -168,7 +166,6 @@ "pouchdb-replication": "^9.0.0", "pouchdb-utils": "^9.0.0", "prettier": "3.8.1", - "rollup-plugin-copy": "^3.5.0", "svelte": "5.56.3", "svelte-check": "^4.6.0", "svelte-eslint-parser": "^1.8.0", @@ -193,7 +190,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.1.38", + "@vrtmrz/livesync-commonlib": "0.1.39", "@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", diff --git a/updates.md b/updates.md index d2145a78..a9484565 100644 --- a/updates.md +++ b/updates.md @@ -19,6 +19,11 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi - We now apply the settings for protecting sensitive configuration together, so a successful Apply keeps the saved settings under the selected passphrase. If Apply fails, the edits remain available to retry. (#1238) - We can now enter a missing or unusable custom configuration passphrase at start-up to unlock protected settings. Cancelling the custom-key recovery prompt preserves the encrypted configuration and stops that start-up attempt; headless command-line start-up cannot complete that prompt when recovery input is required. (#1239) +### Maintenance + +- Build copying and import alias checks now use repository-owned helpers, removing the vulnerable `braces` dependency from development tooling. +- Updated compatible development dependencies with available security fixes. + ## 1.0.35 5th October, 2026 diff --git a/utils/copy-build-files.ts b/utils/copy-build-files.ts new file mode 100644 index 00000000..b10784a7 --- /dev/null +++ b/utils/copy-build-files.ts @@ -0,0 +1,42 @@ +import { access, copyFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import type { Plugin } from "vite"; + +const BUILD_FILES = ["manifest.json", "main.js", "styles.css"] as const; + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + +export function copyBuildFiles(destinations: readonly string[], sourceDirectory = process.cwd()): Plugin { + return { + name: "copy-build-files", + async writeBundle() { + let copied = false; + for (const destination of destinations) { + let destinationCreated = false; + for (const file of BUILD_FILES) { + const source = join(sourceDirectory, file); + try { + await access(source); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) continue; + throw error; + } + + if (!destinationCreated) { + await mkdir(destination, { recursive: true }); + destinationCreated = true; + } + + const target = join(destination, file); + await copyFile(source, target); + if (!copied) console.log("copied:"); + copied = true; + console.log(` ${source} → ${target}`); + } + } + if (!copied) console.log("no items to copy"); + }, + }; +} diff --git a/utils/copy-build-files.unit.spec.ts b/utils/copy-build-files.unit.spec.ts new file mode 100644 index 00000000..cf25184d --- /dev/null +++ b/utils/copy-build-files.unit.spec.ts @@ -0,0 +1,107 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Plugin } from "vite"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { copyBuildFiles } from "./copy-build-files"; + +const BUILD_FILES = ["manifest.json", "main.js", "styles.css"] as const; + +let temporaryDirectory: string; + +function createCopyPlugin(sourceDirectory: string, destinations: string[]): Plugin { + return copyBuildFiles(destinations, sourceDirectory); +} + +async function runWriteBundle(plugin: Plugin): Promise { + const hook = plugin.writeBundle as + | ((this: Plugin) => unknown) + | { handler?: (this: Plugin) => unknown } + | undefined; + const handler = typeof hook === "function" ? hook : hook?.handler; + if (!handler) { + throw new Error("The copy plug-in does not define a writeBundle hook"); + } + await handler.call(plugin); +} + +describe("build file copying", () => { + beforeEach(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "livesync-copy-build-files-")); + }); + + afterEach(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + it("does nothing when no destinations are configured", async () => { + const plugin = createCopyPlugin(join(temporaryDirectory, "missing-source"), []); + + await expect(runWriteBundle(plugin)).resolves.toBeUndefined(); + }); + + it("copies the fixed build files to nested destinations and overwrites them on repeated builds", async () => { + const sourceDirectory = join(temporaryDirectory, "build"); + const destinations = [ + join(temporaryDirectory, "vault-one", ".obsidian", "plugins", "livesync"), + join(temporaryDirectory, "vault-two", "nested", ".obsidian", "plugins", "livesync"), + ]; + await mkdir(sourceDirectory, { recursive: true }); + await Promise.all(BUILD_FILES.map((file) => writeFile(join(sourceDirectory, file), `first ${file}`))); + await mkdir(destinations[0], { recursive: true }); + await writeFile(join(destinations[0], "manifest.json"), "stale manifest"); + + const plugin = createCopyPlugin(sourceDirectory, destinations); + expect(plugin.writeBundle).toBeDefined(); + await runWriteBundle(plugin); + + for (const destination of destinations) { + for (const file of BUILD_FILES) { + await expect(readFile(join(destination, file), "utf8")).resolves.toBe(`first ${file}`); + } + } + + await writeFile(join(sourceDirectory, "main.js"), "second main.js"); + await runWriteBundle(plugin); + + for (const destination of destinations) { + await expect(readFile(join(destination, "main.js"), "utf8")).resolves.toBe("second main.js"); + } + }); + + it("skips missing source files", async () => { + const sourceDirectory = join(temporaryDirectory, "partial-build"); + const destination = join(temporaryDirectory, "vault", ".obsidian", "plugins", "livesync"); + await mkdir(sourceDirectory, { recursive: true }); + await writeFile(join(sourceDirectory, "manifest.json"), "manifest"); + + await runWriteBundle(createCopyPlugin(sourceDirectory, [destination])); + + await expect(readFile(join(destination, "manifest.json"), "utf8")).resolves.toBe("manifest"); + await expect(readFile(join(destination, "main.js"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(destination, "styles.css"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("propagates source path errors", async () => { + const invalidSourceDirectory = join(temporaryDirectory, "source-is-a-file"); + await writeFile(invalidSourceDirectory, "not a directory"); + + await expect( + runWriteBundle(createCopyPlugin(invalidSourceDirectory, [join(temporaryDirectory, "vault")])) + ).rejects.toMatchObject({ code: "ENOTDIR" }); + }); + + it("propagates destination errors", async () => { + const sourceDirectory = join(temporaryDirectory, "build"); + const destinationParent = join(temporaryDirectory, "destination-is-a-file"); + await mkdir(sourceDirectory, { recursive: true }); + await writeFile(join(sourceDirectory, "manifest.json"), "manifest"); + await writeFile(destinationParent, "not a directory"); + + await expect( + runWriteBundle(createCopyPlugin(sourceDirectory, [join(destinationParent, "nested")])) + ).rejects.toMatchObject({ code: "ENOTDIR" }); + }); +}); diff --git a/utils/import-alias.mjs b/utils/import-alias.mjs new file mode 100644 index 00000000..2a297470 --- /dev/null +++ b/utils/import-alias.mjs @@ -0,0 +1,62 @@ +import path from "node:path"; + +/** + * @param {string} sourcePath + * @param {string} filename + * @param {string} cwd + * @param {string} sourceRoot + * @returns {string | null} + */ +export function resolveSourceAlias(sourcePath, filename, cwd, sourceRoot = "./src") { + if (!/^(?:\.\/)?\.\.(?:\/|$)/u.test(sourcePath)) return null; + const paths = /^[A-Za-z]:[\\/]|^\\\\/u.test(filename) ? path.win32 : path; + const root = paths.resolve(cwd, sourceRoot); + const importedPath = paths.resolve(paths.dirname(filename), sourcePath); + const relative = paths.relative(root, importedPath); + if (relative === ".." || relative.startsWith(`..${paths.sep}`) || paths.isAbsolute(relative)) return null; + return relative ? `@/${relative.split(paths.sep).join("/")}` : "@"; +} + +/** @type {import("eslint").Rule.RuleModule} */ +const preferSourceAlias = { + meta: { + type: "suggestion", + fixable: "code", + docs: { description: "Prefer the source alias for relative parent imports within this repository" }, + schema: [ + { + type: "object", + properties: { sourceRoot: { type: "string" } }, + additionalProperties: false, + }, + ], + messages: { + parentImport: "Unexpected parent import '{{sourcePath}}'. Use '{{rewrittenImport}}' instead", + }, + }, + create(context) { + if (context.filename === "" || context.filename === "") return {}; + return { + ImportDeclaration(node) { + const sourcePath = node.source.value; + if (typeof sourcePath !== "string") return; + const rewrittenImport = resolveSourceAlias( + sourcePath, + context.filename, + context.cwd, + context.options[0]?.sourceRoot + ); + if (rewrittenImport === null) return; + context.report({ + node, + messageId: "parentImport", + data: { sourcePath, rewrittenImport }, + fix: (fixer) => + fixer.replaceTextRange([node.source.range[0] + 1, node.source.range[1] - 1], rewrittenImport), + }); + }, + }; + }, +}; + +export default { rules: { "prefer-source-alias": preferSourceAlias } }; diff --git a/utils/import-alias.unit.spec.ts b/utils/import-alias.unit.spec.ts new file mode 100644 index 00000000..5923b0cc --- /dev/null +++ b/utils/import-alias.unit.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { Linter } from "eslint"; +import tsParser from "@typescript-eslint/parser"; +import svelteParser from "svelte-eslint-parser"; +import importAlias, { resolveSourceAlias } from "./import-alias.mjs"; +import { resolve } from "node:path"; + +const cwd = process.cwd(); +const rule = importAlias.rules["prefer-source-alias"]; + +function lint(code: string, filename = "src/modules/example.ts") { + const linter = new Linter({ cwd }); + return linter.verifyAndFix( + code, + [ + { + files: ["**/*.ts", "**/*.svelte"], + languageOptions: { + parser: filename.endsWith(".svelte") ? svelteParser : tsParser, + parserOptions: { parser: tsParser }, + }, + plugins: { local: { rules: { "prefer-source-alias": rule } } }, + rules: { + "local/prefer-source-alias": ["error", { sourceRoot: "./src" }], + }, + }, + ], + { filename: resolve(cwd, filename) } + ); +} + +describe("source import aliases", () => { + it.each([ + ["src/modules/example.ts", "../common/types", "@/common/types"], + ["src/modules/example.ts", "./../common/types", "@/common/types"], + ["src/modules/example.ts", "..", "@"], + ["src/modules/example.ts", "../common/types.js", "@/common/types.js"], + ["src/apps/browser/main.ts", "../../common/types", "@/common/types"], + ["src/apps/cli/main.ts", "../../common/types", "@/common/types"], + ["src/apps/webapp/main.ts", "../../common/types", "@/common/types"], + ["src/apps/webpeer/src/main.ts", "../../../common/types", "@/common/types"], + ])("rewrites %s parent import %s to %s", (filename, sourcePath, expected) => { + const result = lint(`import type { Value } from '${sourcePath}';`, filename); + expect(result.messages).toEqual([]); + expect(result.fixed).toBe(true); + expect(result.output).toBe(`import type { Value } from '${expected}';`); + }); + + it.each([ + "./helper", + "@/common/types", + "@vrtmrz/livesync-commonlib/common/types", + "node:path", + "../../test/helper", + ])("preserves import %s", (sourcePath) => { + const code = `import value from "${sourcePath}";`; + const result = lint(code); + expect(result.messages).toEqual([]); + expect(result.fixed).toBe(false); + expect(result.output).toBe(code); + }); + + it("does not treat a sibling directory whose name begins with src as source-owned", () => { + const code = 'import value from "../../src-other/helper";'; + const result = lint(code); + expect(result.messages).toEqual([]); + expect(result.fixed).toBe(false); + expect(result.output).toBe(code); + }); + + it("rewrites imports inside Svelte scripts", () => { + const result = lint( + '', + "src/components/dialog/Example.svelte" + ); + expect(result.messages).toEqual([]); + expect(result.fixed).toBe(true); + expect(result.output).toBe(''); + }); + + it.each([ + ["C:\\live\\src\\modules\\file.ts", "../common/types", "C:\\live", "@/common/types"], + ["C:\\live\\src\\apps\\webpeer\\src\\main.ts", "../../../common/types", "C:\\live", "@/common/types"], + ["C:\\live\\src\\modules\\file.ts", "../../src-other/helper", "C:\\live", null], + [ + "\\\\server\\share\\live\\src\\modules\\file.ts", + "../common/types", + "\\\\server\\share\\live", + "@/common/types", + ], + ])("handles Windows source paths in %s", (filename, sourcePath, root, expected) => { + expect(resolveSourceAlias(sourcePath, filename, root)).toBe(expected); + }); + + it("uses the configured source root", () => { + const result = resolveSourceAlias( + "../common/types", + resolve(cwd, "nested/src/modules/file.ts"), + cwd, + "nested/src" + ); + expect(result).toBe("@/common/types"); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 8b009f0a..e4195f74 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,11 +2,11 @@ import { defineConfig } from "vitest/config"; import { svelte } from "@sveltejs/vite-plugin-svelte"; import { sveltePreprocess } from "svelte-preprocess"; import inlineWorkerPlugin from "esbuild-plugin-inline-worker"; -import copy from "rollup-plugin-copy"; import path from "path"; import { fileURLToPath } from "node:url"; import fs from "node:fs"; import { platform } from "node:process"; +import { copyBuildFiles } from "./utils/copy-build-files"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); function readJson(filePath: string) { @@ -117,14 +117,7 @@ export default defineConfig(({ mode }) => { compilerOptions: { css: "injected", preserveComments: false }, }), - copy({ - targets: ["manifest.json", "main.js", "styles.css"] - .map((file) => PATH_TEST_INSTALL.map((dest) => ({ src: file, dest: dest }))) - .flat(), - // Copy after the build is complete - hook: "writeBundle", - verbose: true, - }), + copyBuildFiles(PATH_TEST_INSTALL), ], resolve: { From 51808d95edd8429dbc1338441999d5485caeaa87 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 13 Oct 2026 09:56:04 +0000 Subject: [PATCH 4/4] Expand configuration encryption E2E coverage --- package.json | 1 + test/e2e-obsidian/README.md | 7 +- .../scripts/configuration-encryption-sync.ts | 332 ++++++++++++++++++ .../scripts/configuration-encryption.ts | 182 +++++++++- test/e2e-obsidian/scripts/local-suite.ts | 5 + test/e2e-obsidian/scripts/run-focused.ts | 1 + 6 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 test/e2e-obsidian/scripts/configuration-encryption-sync.ts diff --git a/package.json b/package.json index eaea3122..c81ba53c 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "test:e2e:obsidian:document-history-restore": "tsx test/e2e-obsidian/scripts/document-history-restore.ts", "test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts", "test:e2e:obsidian:configuration-encryption": "tsx test/e2e-obsidian/scripts/configuration-encryption.ts", + "test:e2e:obsidian:configuration-encryption-sync": "tsx test/e2e-obsidian/scripts/configuration-encryption-sync.ts", "test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts", "test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts", "test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts", diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 6d14b597..4030b4f8 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -75,6 +75,7 @@ After changing plug-in source, use the focused wrapper rather than invoking a sc ```bash npm run test:e2e:obsidian:focused -- settings-ui npm run test:e2e:obsidian:focused -- configuration-encryption +npm run test:e2e:obsidian:focused -- configuration-encryption-sync npm run test:e2e:obsidian:focused -- two-vault-sync npm run test:e2e:obsidian:focused -- stale-file-restart npm run test:e2e:obsidian:focused -- folder-batch @@ -112,7 +113,7 @@ The underlying `test:e2e:obsidian:` scripts remain available for an im `test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from Quick Setup in either settings interface. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. -`configuration-encryption` needs no remote service. It applies a custom configuration key through the settings interface, restarts the same isolated Vault, and applies a change to only the custom string. It then copies the encrypted settings into a fresh profile without that key and checks masked input, wrong-input retry, cancellation without a settings write or database initialisation, re-enabling for another attempt, access to the settings tab, and restoration after a further restart. Connection, E2EE, profile, and independent ID values must remain unchanged. These checks establish ordinary Apply and restart behaviour, without simulating process termination between the device-key and settings writes. +`configuration-encryption` needs no remote service. It applies a custom configuration key through the settings interface, restarts the same isolated Vault, and applies a change to only the custom string. It then copies the encrypted settings into a fresh profile without that key and checks masked input, wrong-input retry, cancellation without a settings write or database initialisation, re-enabling for another attempt, access to the settings tab, and restoration after a further restart. Connection, E2EE, profile, and independent ID values must remain unchanged. These checks establish ordinary Apply and restart behaviour, without simulating process termination between the device-key and settings writes. It also checks changes from custom and ASK modes to Default through the settings interface, and recovery from a wrong saved device key, including cancellation, the validated replacement key, and another restart. `test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database. @@ -137,7 +138,9 @@ The Harness also measures ID generation with fixed in-memory data on desktop and `test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions. -`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI and QR round trips, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, internal Metadata Doctor, and setting Markdown export in sequence. Start the local CouchDB, RustFS, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. +`test:e2e:obsidian:configuration-encryption-sync` requires only the CouchDB fixture configured through `.test.env` or the process environment. It changes the configuration protection key through the real settings interface, restarts the source Vault, and sends a note with E2EE enabled. A fresh Vault receives the encrypted settings without a saved device key, cancels and retries recovery, and restarts with the validated key. Both Vaults then exchange exact note bytes through CouchDB. The scenario checks the restored connection credentials, E2EE passphrase, ID key, remote Metadata, and Chunks, and removes its temporary Vaults, profiles, and unique remote database. + +`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, configuration encryption and its CouchDB synchronisation workflow, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI and QR round trips, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, internal Metadata Doctor, and setting Markdown export in sequence. Start the local CouchDB, RustFS, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. `test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies that the marker is initialised without a compatibility dialogue, reminder, or runtime pause. It then creates a note in real Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the Metadata document and its Chunks. diff --git a/test/e2e-obsidian/scripts/configuration-encryption-sync.ts b/test/e2e-obsidian/scripts/configuration-encryption-sync.ts new file mode 100644 index 00000000..9b921c9d --- /dev/null +++ b/test/e2e-obsidian/scripts/configuration-encryption-sync.ts @@ -0,0 +1,332 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + assertCouchDbReachable, + createCouchDbDatabase, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, + waitForCouchDbDocs, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + configureCouchDb, + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { openLiveSyncSettings, waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const coreExpression = "app.plugins.plugins['obsidian-livesync'].core"; +const firstKey = "synthetic-sync-configuration-key-a"; +const secondKey = "synthetic-sync-configuration-key-b"; +const dataPassphrase = "synthetic-note-encryption-passphrase"; +const idKey = "cd".repeat(32); +const sourcePath = "E2E/configuration-source.md"; +const replyPath = "E2E/configuration-reply.md"; +const sourceContent = "# Configuration key rotation\n日本語とEnglishの内容を保持します。\n".repeat(200); +const replyContent = "# Configuration key recovery reply\n復旧した端末から送信します。\n".repeat(200); +const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; + +async function applyKey(session: ObsidianLiveSyncSession, key: string, changeMode: boolean): Promise { + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const navigator = await openLiveSyncSettings(page); + const root = await navigator.openPage("Power users"); + if (changeMode) { + await root + .locator(".setting-item") + .filter({ hasText: "Encrypting sensitive configuration items" }) + .getByRole("combobox") + .selectOption("LOCALSTORAGE"); + } + await root.locator('input[type="password"]:visible').fill(key); + await root.getByRole("button", { name: "Apply", exact: true }).click(); + await page.waitForFunction(`(()=>{ + const core=${coreExpression}; + const tab=core.modules.find(module=>module.settingTab)?.settingTab; + return tab && !tab.isDirty('configPassphrase') && !tab.isDirty('configPassphraseStore') && + core.settings.configPassphraseStore==='LOCALSTORAGE' && + localStorage.getItem('ls-setting-passphrase')===${JSON.stringify(key)}; + })()`); + await navigator.close(); + }); +} + +async function assertConnection( + cli: string, + session: ObsidianLiveSyncSession, + expected: Record +): Promise { + const settings = await evalObsidianJson( + cli, + `(()=>{ + const s=${coreExpression}.services.setting.currentSettings(); + return JSON.stringify({uri:s.couchDB_URI,user:s.couchDB_USER,password:s.couchDB_PASSWORD, + database:s.couchDB_DBNAME,encrypt:s.encrypt,passphrase:s.passphrase,idKey:s.idDerivationKey, + store:s.configPassphraseStore,key:localStorage.getItem('ls-setting-passphrase')}); + })()`, + session.cliEnv + ); + assert.deepEqual(settings, expected); +} + +async function createNote(cli: string, session: ObsidianLiveSyncSession, path: string, content: string): Promise { + await evalObsidianJson( + cli, + `(async()=>{ + if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E'); + await app.vault.create(${JSON.stringify(path)},${JSON.stringify(content)}); + return JSON.stringify(true); + })()`, + session.cliEnv + ); + await waitForLocalDatabaseEntry(cli, session.cliEnv, path); +} + +async function assertNote( + cli: string, + session: ObsidianLiveSyncSession, + vaultPath: string, + path: string, + content: string +): Promise { + // Return only a boolean through the CLI; compare the complete Vault file below. + const matches = await evalObsidianJson( + cli, + `(async()=>{ + const path=${JSON.stringify(path)}; + const expected=${JSON.stringify(content)}; + const deadline=Date.now()+20000; + while(Date.now()null); + if(actual===expected) return JSON.stringify(true); + await new Promise(resolve=>setTimeout(resolve,250)); + } + throw new Error('Timed out waiting for the received note'); + })()`, + session.cliEnv + ); + assert.equal(matches, true); + assert.equal(await readFile(join(vaultPath, path), "utf8"), content); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked: ${cli.checked.join(", ")}`); + const cliBinary = cli.binary; + const couchDb = await loadCouchDbConfig(); + const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "configuration-encryption"); + const sourceVault = await createTemporaryVault("obsidian-configuration-sync-source-"); + let receiverVault: Awaited> | undefined; + let session: ObsidianLiveSyncSession | undefined; + let remoteCreated = false; + let stage = "source-startup"; + const connection = { + uri: couchDb.uri, + user: couchDb.username, + password: couchDb.password, + database: dbName, + encrypt: true, + passphrase: dataPassphrase, + idKey, + store: "LOCALSTORAGE", + key: secondKey, + }; + try { + await assertCouchDbReachable(couchDb); + await createCouchDbDatabase(couchDb, dbName); + remoteCreated = true; + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault: sourceVault, + pluginData: createE2eCouchDbPluginData( + { ...couchDb, dbName }, + { + doctorProcessedVersion: "1.0.35", + usePowerUserMode: true, + idDerivationVersion: 0, + } + ), + localStorageEntries: createE2eObsidianDeviceLocalState(sourceVault.name), + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + stage = "source-configuration"; + await configureCouchDb( + cliBinary, + session.cliEnv, + { ...couchDb, dbName }, + { + doctorProcessedVersion: "1.0.35", + usePowerUserMode: true, + encrypt: true, + passphrase: dataPassphrase, + idDerivationVersion: 1, + idDerivationKey: idKey, + } + ); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await prepareRemote(cliBinary, session.cliEnv); + stage = "source-key-rotation"; + await applyKey(session, firstKey, true); + await applyKey(session, secondKey, false); + const dataPath = join(sourceVault.path, ".obsidian/plugins/obsidian-livesync/data.json"); + const encryptedData = await readFile(dataPath, "utf8"); + assert.equal(encryptedData.includes(dataPassphrase), false); + assert.equal(encryptedData.includes(idKey), false); + assert.equal(encryptedData.includes(couchDb.password), false); + const saved = JSON.parse(encryptedData) as Record; + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: sourceVault }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await assertConnection(cliBinary, session, connection); + stage = "source-upload-after-restart"; + await createNote(cliBinary, session, sourcePath, sourceContent); + await assertNote(cliBinary, session, sourceVault.path, sourcePath, sourceContent); + const sourceEntry = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, sourcePath); + await pushLocalChanges(cliBinary, session.cliEnv); + const docs = await waitForCouchDbDocs(couchDb, dbName, (docs) => { + const ids = new Set(docs.map((doc) => doc._id)); + return ids.has(sourceEntry.id) && sourceEntry.children.every((id) => ids.has(id)); + }); + assert.ok(sourceEntry.children.length > 0); + assert.equal( + docs.some((doc) => typeof doc.data === "string" && doc.data.includes("Configuration key rotation")), + false + ); + console.log( + "Configuration encryption sync: key rotation and restart retained credentials, E2EE, and the ID key; Metadata and encrypted Chunks reached CouchDB." + ); + await session.app.stop(); + session = undefined; + + stage = "receiver-key-recovery"; + receiverVault = await createTemporaryVault("obsidian-configuration-sync-receiver-"); + const receiverDataPath = join(receiverVault.path, ".obsidian/plugins/obsidian-livesync/data.json"); + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault: receiverVault, + pluginData: saved, + localStorageEntries: createE2eObsidianDeviceLocalState(receiverVault.name), + lifecycle: { + afterPluginLoad: async ({ remoteDebuggingPort, cliEnv }) => { + await withObsidianPage(remoteDebuggingPort, async (page) => { + const dialogue = await waitForVisibleObsidianDialogue(page, "Configuration passphrase"); + assert.equal(await dialogue.locator('input[type="password"]').getAttribute("type"), "password"); + const before = await readFile(receiverDataPath, "utf8"); + await dialogue.getByRole("button", { name: "Cancel", exact: true }).click(); + await dialogue.waitFor({ state: "hidden" }); + const pending = await evalObsidianJson( + cliBinary, + `(()=>{ + const core=${coreExpression}; + return JSON.stringify({databaseReady:core.services.database.isDatabaseReady(), + tabRegistered:core.modules.some(module=>!!module.settingTab)}); + })()`, + cliEnv + ); + assert.deepEqual(pending, { databaseReady: false, tabRegistered: false }); + assert.equal(await readFile(receiverDataPath, "utf8"), before); + await evalObsidianJson( + cliBinary, + `(async()=>{ + await app.plugins.disablePlugin('obsidian-livesync'); + await app.plugins.enablePlugin('obsidian-livesync'); + return JSON.stringify(true); + })()`, + cliEnv + ); + const retry = await waitForVisibleObsidianDialogue(page, "Configuration passphrase"); + await retry.locator('input[type="password"]').fill(secondKey); + await mkdir(diagnosticsDirectory, { recursive: true }); + await retry.locator(".modal").screenshot({ + path: join(diagnosticsDirectory, "configuration-sync-unlock.png"), + animations: "disabled", + caret: "hide", + }); + await retry.getByRole("button", { name: "OK", exact: true }).click(); + }); + }, + }, + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await assertConnection(cliBinary, session, connection); + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: receiverVault }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await assertConnection(cliBinary, session, connection); + await pushLocalChanges(cliBinary, session.cliEnv); + stage = "receiver-transfer-after-restart"; + await assertNote(cliBinary, session, receiverVault.path, sourcePath, sourceContent); + await createNote(cliBinary, session, replyPath, replyContent); + await assertNote(cliBinary, session, receiverVault.path, replyPath, replyContent); + await pushLocalChanges(cliBinary, session.cliEnv); + const replyEntry = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, replyPath); + await waitForCouchDbDocs(couchDb, dbName, (docs) => docs.some((doc) => doc._id === replyEntry.id)); + await session.app.stop(); + session = undefined; + + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: sourceVault }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await assertConnection(cliBinary, session, connection); + await pushLocalChanges(cliBinary, session.cliEnv); + stage = "source-receive-reply"; + await assertNote(cliBinary, session, sourceVault.path, replyPath, replyContent); + console.log( + "Configuration encryption sync: copied settings survived cancellation, key recovery, and restart; both real Obsidian Vaults exchanged exact note bytes through CouchDB." + ); + } catch (error) { + await mkdir(diagnosticsDirectory, { recursive: true }); + if (session) { + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await page.screenshot({ + path: join(diagnosticsDirectory, "configuration-sync.failure.png"), + fullPage: true, + }); + }).catch(() => undefined); + const state = await evalObsidianJson( + cliBinary, + `(()=>{ + const core=${coreExpression}; + return JSON.stringify({databaseReady:core?.services.database.isDatabaseReady(), + appReady:core?.services.appLifecycle.isReady(), + mode:core?.settings?.configPassphraseStore}); + })()`, + session.cliEnv + ).catch(() => ({ stateReadFailed: true })); + await writeFile( + join(diagnosticsDirectory, "configuration-sync.failure.json"), + JSON.stringify({ stage, state }, null, 2) + "\n" + ); + } + throw error; + } finally { + try { + if (session) await session.app.stop(); + } finally { + try { + await sourceVault.dispose(); + if (receiverVault) await receiverVault.dispose(); + } finally { + if (remoteCreated) await deleteCouchDbDatabase(couchDb, dbName); + } + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exitCode = 1; +}); diff --git a/test/e2e-obsidian/scripts/configuration-encryption.ts b/test/e2e-obsidian/scripts/configuration-encryption.ts index 2db9d6b5..d18b57b0 100644 --- a/test/e2e-obsidian/scripts/configuration-encryption.ts +++ b/test/e2e-obsidian/scripts/configuration-encryption.ts @@ -12,6 +12,7 @@ import { createTemporaryVault } from "../runner/vault.ts"; const coreExpression = "app.plugins.plugins['obsidian-livesync'].core"; const firstKey = "synthetic-first-configuration-key"; const secondKey = "synthetic-second-configuration-key"; +const wrongSavedKey = "synthetic-wrong-saved-configuration-key"; const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; const screenshots: string[] = []; const saveFailureMessage = @@ -100,7 +101,10 @@ async function applyCustomKey(session: ObsidianLiveSyncSession, key: string, cha }); } -async function assertDefaultSettings(session: ObsidianLiveSyncSession): Promise { +async function assertDefaultSettings( + session: ObsidianLiveSyncSession, + screenshot = "01-default-after-restart.png" +): Promise { await withObsidianPage(session.remoteDebuggingPort, async (page) => { assert.equal(await page.locator('.modal-container input[type="password"]').count(), 0); const navigator = await openLiveSyncSettings(page); @@ -111,7 +115,7 @@ async function assertDefaultSettings(session: ObsidianLiveSyncSession): Promise< .getByRole("combobox"); assert.equal(await store.inputValue(), ""); assert.equal(await root.locator('input[type="password"]:visible').isDisabled(), true); - await capture(navigator.dialogue, "01-default-after-restart.png"); + await capture(navigator.dialogue, screenshot); await navigator.close(); }); } @@ -181,6 +185,58 @@ async function applyAskMode(session: ObsidianLiveSyncSession): Promise { }); } +async function applyDefaultMode( + session: ObsidianLiveSyncSession, + screenshot = "12-ask-to-default-before-restart.png" +): Promise { + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const navigator = await openLiveSyncSettings(page); + const root = await navigator.openPage("Power users"); + await root + .locator(".setting-item") + .filter({ hasText: "Encrypting sensitive configuration items" }) + .getByRole("combobox") + .selectOption(""); + await root.getByRole("button", { name: "Apply", exact: true }).click(); + await page.waitForFunction(`(()=>{ + const core=${coreExpression}; + const tab=core.modules.find(module=>module.settingTab)?.settingTab; + return tab && !tab.isDirty('configPassphraseStore') && core.settings.configPassphraseStore===''; + })()`); + await capture(navigator.dialogue, screenshot); + await navigator.close(); + }); +} + +async function assertSavedConfigurationKey(cli: string, env: NodeJS.ProcessEnv, expectedKey: string): Promise { + const key = await evalObsidianJson(cli, `JSON.stringify(localStorage.getItem('ls-setting-passphrase'))`, env); + assert.equal(key, expectedKey); +} + +async function assertLoadedLocalStorageConfiguration( + cli: string, + env: NodeJS.ProcessEnv, + expectedKey: string +): Promise { + const state = await evalObsidianJson( + cli, + `(()=>{ + const core=${coreExpression}; + return JSON.stringify({databaseReady:core.services.database.isDatabaseReady(), + tabRegistered:core.modules.some(module=>!!module.settingTab), + store:core.settings.configPassphraseStore, + key:localStorage.getItem('ls-setting-passphrase')}); + })()`, + env + ); + assert.deepEqual(state, { + databaseReady: true, + tabRegistered: true, + store: "LOCALSTORAGE", + key: expectedKey, + }); +} + async function main(): Promise { const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); @@ -188,6 +244,7 @@ async function main(): Promise { const cliBinary = cli.binary; const firstVault = await createTemporaryVault("obsidian-livesync-configuration-source-"); let secondVault: Awaited> | undefined; + let retryVault: Awaited> | undefined; let session: ObsidianLiveSyncSession | undefined; try { session = await startObsidianLiveSyncSession({ @@ -271,6 +328,19 @@ async function main(): Promise { console.log( "Configuration encryption: rejected empty-key Apply displayed a notice and retained the device key, saved data, and pending edit." ); + await applyDefaultMode(session, "18-custom-to-default-before-restart.png"); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey); + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: firstVault }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey); + await assertDefaultSettings(session, "19-custom-to-default-after-restart.png"); + console.log( + "Configuration encryption: changing the custom key mode to Default preserved protected values without a prompt and retained the device key after restart." + ); await session.app.stop(); session = undefined; @@ -381,11 +451,119 @@ async function main(): Promise { console.log( "Configuration encryption: ASK input was masked and the validated key was reused for an ordinary save in this launch." ); + + await applyDefaultMode(session); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey); + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: secondVault }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey); + await assertDefaultSettings(session, "13-ask-to-default-after-restart.png"); + console.log( + "Configuration encryption: changing ASK_AT_LAUNCH to Default through settings restored protected values without a prompt and retained the device key after restart." + ); + await session.app.stop(); + session = undefined; + + retryVault = await createTemporaryVault("obsidian-livesync-configuration-wrong-saved-key-"); + const retryDataPath = join(retryVault.path, ".obsidian/plugins/obsidian-livesync/data.json"); + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault: retryVault, + pluginData: saved, + localStorageEntries: { + ...createE2eObsidianDeviceLocalState(retryVault.name), + "ls-setting-passphrase": wrongSavedKey, + }, + lifecycle: { + afterPluginLoad: async ({ remoteDebuggingPort, cliEnv }) => { + await withObsidianPage(remoteDebuggingPort, async (page) => { + try { + const retry = await waitForVisibleObsidianDialogue( + page, + "Configuration could not be decrypted" + ); + const input = retry.locator('input[type="password"]'); + assert.equal(await input.getAttribute("type"), "password"); + await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey); + const beforeCancellation = await readFile(retryDataPath, "utf8"); + await capture(retry.locator(".modal"), "14-saved-wrong-key-retry-prompt.png"); + await retry.getByRole("button", { name: "Cancel", exact: true }).click(); + await retry.waitFor({ state: "hidden" }); + await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey); + assert.equal(await readFile(retryDataPath, "utf8"), beforeCancellation); + + await evalObsidianJson( + cliBinary, + `(async()=>{ + await app.plugins.disablePlugin('obsidian-livesync'); + await app.plugins.enablePlugin('obsidian-livesync'); + return JSON.stringify(true); + })()`, + cliEnv + ); + const retryAfterCancellation = await waitForVisibleObsidianDialogue( + page, + "Configuration could not be decrypted" + ); + const retryInput = retryAfterCancellation.locator('input[type="password"]'); + assert.equal(await retryInput.getAttribute("type"), "password"); + await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey); + await retryInput.fill(secondKey); + await capture( + retryAfterCancellation.locator(".modal"), + "15-saved-wrong-key-correct-retry.png" + ); + await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey); + await retryAfterCancellation.getByRole("button", { name: "OK", exact: true }).click(); + } catch (error) { + await mkdir(diagnosticsDirectory, { recursive: true }); + await page.screenshot({ + path: join(diagnosticsDirectory, "configuration-saved-key-retry.failure.png"), + fullPage: true, + }); + throw error; + } + }); + }, + }, + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await assertLoadedLocalStorageConfiguration(cliBinary, session.cliEnv, secondKey); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + assert.equal(await page.locator('.modal-container input[type="password"]').count(), 0); + const navigator = await openLiveSyncSettings(page); + await navigator.openPage("Power users"); + await capture(navigator.dialogue, "16-saved-wrong-key-unlocked-settings.png"); + await navigator.close(); + }); + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: retryVault }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + assert.deepEqual(await readSecrets(cliBinary, session), secrets); + await assertLoadedLocalStorageConfiguration(cliBinary, session.cliEnv, secondKey); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + assert.equal(await page.locator('.modal-container input[type="password"]').count(), 0); + const navigator = await openLiveSyncSettings(page); + await navigator.openPage("Power users"); + await capture(navigator.dialogue, "17-validated-key-after-restart.png"); + await navigator.close(); + }); + console.log( + "Configuration encryption: a saved wrong key remained intact after cancellation; the correct retry restored protected values, persisted the validated key, and survived another restart." + ); console.log(`Configuration encryption screenshots: ${JSON.stringify(screenshots)}`); } finally { if (session) await session.app.stop(); await firstVault.dispose(); if (secondVault) await secondVault.dispose(); + if (retryVault) await retryVault.dispose(); } } diff --git a/test/e2e-obsidian/scripts/local-suite.ts b/test/e2e-obsidian/scripts/local-suite.ts index 6857ce0e..8453c584 100644 --- a/test/e2e-obsidian/scripts/local-suite.ts +++ b/test/e2e-obsidian/scripts/local-suite.ts @@ -18,6 +18,11 @@ const testSteps: Step[] = [ { name: "conflict dialogue policy", args: ["run", "test:e2e:obsidian:conflict-dialog-policy"] }, { name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] }, { name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] }, + { name: "configuration encryption", args: ["run", "test:e2e:obsidian:configuration-encryption"] }, + { + name: "configuration encryption synchronisation", + args: ["run", "test:e2e:obsidian:configuration-encryption-sync"], + }, { name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] }, { name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] }, { name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] }, diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index 090e50be..b0199b89 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -14,6 +14,7 @@ const focusedScenarios = new Set([ "document-history-restore", "settings-ui", "configuration-encryption", + "configuration-encryption-sync", "review-harness", "p2p-pane", "vault-reflection",