From 8451102176699bb678a68f94fca7e15858e916cf Mon Sep 17 00:00:00 2001
From: vorotamoroz
Date: Wed, 11 Nov 2026 11:18:50 +0000
Subject: [PATCH 1/3] Require explicit opt-in for unencrypted content with E2EE
---
docs/settings.md | 8 +
package-lock.json | 568 ++---------
package.json | 5 +-
.../messages/LiveSyncProvisionalMessages.ts | 7 +
src/common/replicatorConfigurationIdentity.ts | 1 +
...plicatorConfigurationIdentity.unit.spec.ts | 14 +
src/features/ConfigSync/PluginDialogModal.ts | 6 +-
src/modules/coreObsidian/UILib/dialogs.ts | 25 +-
...SettingAsMarkdown.credentials.unit.spec.ts | 55 +
.../ModuleObsidianSettingAsMarkdown.ts | 8 +-
.../features/SettingDialogue/PaneHelp.ts | 12 +-
.../SettingDialogue/PaneRemoteConfig.ts | 4 +
.../PaneRemoteConfig.unit.spec.ts | 42 +
.../features/SettingDialogue/settingUtils.ts | 9 +-
.../SettingDialogue/settingUtils.unit.spec.ts | 31 +
src/modules/features/SetupManager.ts | 55 +-
.../features/SetupManager.unit.spec.ts | 224 ++++-
.../dialogs/OutroAskUserMode.svelte | 9 +-
.../dialogs/OutroExistingUser.svelte | 17 +-
.../SetupWizard/dialogs/OutroNewUser.svelte | 17 +-
.../dialogs/PlaintextReceiptWarning.svelte | 10 +
.../dialogs/SetupRemoteE2EE.svelte | 13 +
.../SetupWizard/dialogs/setupDialogTypes.ts | 4 +
styles.css | 49 +
.../scripts/e2ee-plaintext-policy.ts | 936 ++++++++++++++++++
updates.md | 17 +
26 files changed, 1614 insertions(+), 532 deletions(-)
create mode 100644 src/modules/features/SettingDialogue/settingUtils.unit.spec.ts
create mode 100644 src/modules/features/SetupWizard/dialogs/PlaintextReceiptWarning.svelte
create mode 100644 test/e2e-obsidian/scripts/e2ee-plaintext-policy.ts
diff --git a/docs/settings.md b/docs/settings.md
index abf32307..5201c800 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -331,6 +331,14 @@ For CouchDB, this encrypts paths, times, sizes, and Chunk references in the Meta
Enabling the preference protects future Metadata writes. Existing Metadata and earlier revisions can remain readable in the remote database. If you want to protect existing Metadata too, prepare the authoritative data, update every device to a compatible version, and manually Rebuild the remote database. LiveSync does not gather data or start a Rebuild when you change this preference. The action to enable it without rebuilding explicitly reminds you to update every other synchronising device first, including devices currently running LiveSync. Plaintext and encrypted Metadata can coexist during the transition. Document IDs, revision information, document counts, and ciphertext lengths remain visible.
+#### Accept unencrypted content
+
+Setting key: `acceptUnencryptedContent`
+
+This preference is disabled by default. With End-to-End Encryption enabled, it allows us to receive plaintext content written by third-party tools which do not use the E2EE passphrase. It affects receipt only; content sent by this plug-in remains encrypted. Anyone with write access to the CouchDB database can inject plaintext.
+
+The setting is included in exported settings Markdown, Setup URIs, and QR codes. Its saved value is retained while E2EE is off, but has no effect then. With E2EE enabled and this preference on, the E2EE Configuration summary reports its status and effect. Changing only this preference applies it to the active CouchDB connection without a Fetch or Rebuild. Other encryption changes keep their existing application requirements.
+
#### Encryption Algorithm
Setting key: E2EEAlgorithm
diff --git a/package-lock.json b/package-lock.json
index c06ab8b7..6a7b4ff8 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.4.2",
+ "@vrtmrz/livesync-commonlib": "0.4.3",
"@vrtmrz/obsidian-plugin-kit": "0.1.7",
"@vrtmrz/ui-interactions": "0.1.2",
"@vrtmrz/value-store": "0.1.0",
@@ -63,7 +63,7 @@
"esbuild-plugin-inline-worker": "^0.1.1",
"esbuild-svelte": "^0.9.4",
"eslint": "^9.39.3",
- "eslint-plugin-obsidianmd": "^0.4.1",
+ "eslint-plugin-obsidianmd": "^0.5.1",
"eslint-plugin-svelte": "^3.19.0",
"events": "^3.3.0",
"globals": "^14.0.0",
@@ -925,13 +925,12 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
- "version": "3.972.19",
- "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.19.tgz",
- "integrity": "sha512-Cw8IOMdBUEIl8ZlhRC3Dc/E64D5B5/8JhV6vhPLiPfJwcRC84S6F8aBOIi/N4vR9ZyA4I5Cc0Ateb/9EHaJXeQ==",
+ "version": "3.972.43",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.43.tgz",
+ "integrity": "sha512-rG8QVx4DOrJWptsogu1Aq+CDsnPKaBrAlJpKLxrJrIDXAq1ElnW0yQjjBnlWSXll0cDakCG6UDvaqwz0A9d9Og==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.14.1",
- "fast-xml-parser": "5.7.1",
+ "@smithy/types": "^4.20.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -1624,19 +1623,46 @@
}
},
"node_modules/@eslint/json": {
- "version": "0.14.0",
- "resolved": "https://registry.npmjs.org/@eslint/json/-/json-0.14.0.tgz",
- "integrity": "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@eslint/json/-/json-2.1.1.tgz",
+ "integrity": "sha512-ffVzPZALaJzxuo46ggBtJ9c8YBWKLMh/R35aFBHk/dRobmFNortJlHJfBWbHGuydoFeVZbvPy0PLX2wvCI9T9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.17.0",
- "@eslint/plugin-kit": "^0.4.1",
- "@humanwhocodes/momoa": "^3.3.10",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.3",
+ "@humanwhocodes/momoa": "^3.3.13",
"natural-compare": "^1.4.0"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/json/node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/json/node_modules/@eslint/plugin-kit": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz",
+ "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/object-schema": {
@@ -1738,9 +1764,9 @@
}
},
"node_modules/@humanwhocodes/momoa": {
- "version": "3.3.10",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-3.3.10.tgz",
- "integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==",
+ "version": "3.3.13",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-3.3.13.tgz",
+ "integrity": "sha512-AbKq3EW41+HY6pNMJgAOrz68iyiTTf0Nr1JRHHfuG5zwniJ9Dw8J3U7oa1I2ptpqYwmEf1sXKOGK6lbdZ9eqmg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1957,18 +1983,6 @@
"url": "https://paulmillr.com/funding/"
}
},
- "node_modules/@nodable/entities": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
- "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/nodable"
- }
- ],
- "license": "MIT"
- },
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
@@ -2166,19 +2180,6 @@
"node": ">=20.0.0"
}
},
- "node_modules/@pkgr/core": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz",
- "integrity": "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/unts"
- }
- },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -2991,9 +2992,9 @@
}
},
"node_modules/@smithy/types": {
- "version": "4.15.0",
- "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz",
- "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==",
+ "version": "4.20.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.20.0.tgz",
+ "integrity": "sha512-lCA6QG22dPk9ecHbv91jDQZsmtPUjdS1aE2Il6NNPqe7DrYc0dZQi6DhcESk2KYXH2sp5xbAcGdYMZDFnuzA2A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -3381,17 +3382,6 @@
"integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
"license": "MIT"
},
- "node_modules/@types/eslint": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
- "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "*",
- "@types/json-schema": "*"
- }
- },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -4081,9 +4071,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.4.2.tgz",
- "integrity": "sha512-Wp5Orssnzm9vO36CB4+IK0Z19ULBZ+A7Mzm4NP7GgPce2rCCDzeS9m5zIenPtgz/xG2eF/T0Ni+5AWfs/ERALQ==",
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.4.3.tgz",
+ "integrity": "sha512-XtWGSgZ8Pu3VIfN6EZNAJI/cbchoycbig48V/tw3VH60W1AIWZMWkNKHeLNsnc95H9ECC6iekcmT9v1DSbbNQg==",
"license": "MIT",
"dependencies": {
"@aws-crypto/sha256-js": "5.2.0",
@@ -5763,92 +5753,6 @@
"semver": "bin/semver.js"
}
},
- "node_modules/eslint-plugin-json-schema-validator": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-json-schema-validator/-/eslint-plugin-json-schema-validator-5.1.0.tgz",
- "integrity": "sha512-ZmVyxRIjm58oqe2kTuy90PpmZPrrKvOjRPXKzq8WCgRgAkidCgm5X8domL2KSfadZ3QFAmifMgGTcVNhZ5ez2g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.3.0",
- "ajv": "^8.0.0",
- "debug": "^4.3.1",
- "eslint-compat-utils": "^0.5.0",
- "json-schema-migrate": "^2.0.0",
- "jsonc-eslint-parser": "^2.0.0",
- "minimatch": "^8.0.0",
- "synckit": "^0.9.0",
- "toml-eslint-parser": "^0.9.0",
- "tunnel-agent": "^0.6.0",
- "yaml-eslint-parser": "^1.0.0"
- },
- "engines": {
- "node": "^14.18.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ota-meshi"
- },
- "peerDependencies": {
- "eslint": ">=6.0.0"
- }
- },
- "node_modules/eslint-plugin-json-schema-validator/node_modules/ajv": {
- "version": "8.20.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
- "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^3.0.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/eslint-plugin-json-schema-validator/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/eslint-plugin-json-schema-validator/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/eslint-plugin-json-schema-validator/node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/eslint-plugin-json-schema-validator/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/eslint-plugin-n": {
"version": "17.10.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.10.3.tgz",
@@ -5899,92 +5803,74 @@
}
},
"node_modules/eslint-plugin-obsidianmd": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.4.1.tgz",
- "integrity": "sha512-Nv3593kVsFOS8kir/HWaJ5CR3xcyiPWEPyXst5Pib8mxRYYuLYPpJS2ALMoZXHulHyiGwoYW8AaxvLRc4rhrRg==",
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.5.1.tgz",
+ "integrity": "sha512-pocDy3OfOAQyT6hd1cMj1JqJvGgVQSR84337Dve44YO4YcjZsmG7+jzd93nnB1Ke8PenLTwGDzRpzaS+ftvDgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
- "@eslint/config-helpers": "^0.4.2",
- "@eslint/js": "^9.30.1",
- "@eslint/json": "0.14.0",
+ "@eslint/config-helpers": "^0.7.0",
+ "@eslint/core": "^1.2.1",
+ "@eslint/json": "^2.0.0",
+ "@eslint/plugin-kit": "^0.7.3",
"@microsoft/eslint-plugin-sdl": "^1.1.0",
- "@types/eslint": "9.6.1",
- "@types/node": "20.12.12",
- "@typescript-eslint/types": "^8.33.1",
- "@typescript-eslint/utils": "^8.33.1",
- "eslint": ">=9.19.0",
- "eslint-plugin-depend": "1.3.1",
+ "@typescript-eslint/utils": "^8.46.0",
+ "eslint-plugin-depend": "^1.3.1",
"eslint-plugin-import": "^2.31.0",
- "eslint-plugin-json-schema-validator": "5.1.0",
"eslint-plugin-no-unsanitized": "^4.1.5",
- "eslint-plugin-security": "2.1.1",
"globals": "14.0.0",
- "obsidian": "1.12.3",
"semver": "^7.7.4",
- "typescript": "5.4.5",
"typescript-eslint": "^8.35.1"
},
- "bin": {
- "eslint-plugin-obsidian": "dist/lib/index.js"
- },
"engines": {
- "node": ">= 18"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"peerDependencies": {
- "@eslint/js": "^9.30.1",
- "@eslint/json": "0.14.0",
"eslint": ">=9.19.0",
- "obsidian": "1.8.7",
+ "typescript": ">=4.8.4",
"typescript-eslint": "^8.35.1"
}
},
- "node_modules/eslint-plugin-obsidianmd/node_modules/@types/node": {
- "version": "20.12.12",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
- "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "undici-types": "~5.26.4"
- }
- },
- "node_modules/eslint-plugin-obsidianmd/node_modules/obsidian": {
- "version": "1.12.3",
- "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
- "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/codemirror": "5.60.8",
- "moment": "2.29.4"
- },
- "peerDependencies": {
- "@codemirror/state": "6.5.0",
- "@codemirror/view": "6.38.6"
- }
- },
- "node_modules/eslint-plugin-obsidianmd/node_modules/typescript": {
- "version": "5.4.5",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
- "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
+ "node_modules/eslint-plugin-obsidianmd/node_modules/@eslint/config-helpers": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
+ "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
"dev": true,
"license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
+ "dependencies": {
+ "@eslint/core": "^1.2.1"
},
"engines": {
- "node": ">=14.17"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
- "node_modules/eslint-plugin-obsidianmd/node_modules/undici-types": {
- "version": "5.26.5",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
- "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "node_modules/eslint-plugin-obsidianmd/node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
"dev": true,
- "license": "MIT"
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/eslint-plugin-obsidianmd/node_modules/@eslint/plugin-kit": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz",
+ "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
},
"node_modules/eslint-plugin-react": {
"version": "7.37.3",
@@ -6084,16 +5970,6 @@
"semver": "bin/semver.js"
}
},
- "node_modules/eslint-plugin-security": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-2.1.1.tgz",
- "integrity": "sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "safe-regex": "^2.1.1"
- }
- },
"node_modules/eslint-plugin-svelte": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.19.0.tgz",
@@ -6386,60 +6262,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fast-uri": {
- "version": "3.1.9",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
- "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/fastify"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/fastify"
- }
- ],
- "license": "BSD-3-Clause"
- },
- "node_modules/fast-xml-builder": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
- "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "path-expression-matcher": "^1.5.0",
- "xml-naming": "^0.1.0"
- }
- },
- "node_modules/fast-xml-parser": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz",
- "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "@nodable/entities": "^2.1.0",
- "fast-xml-builder": "^1.1.5",
- "path-expression-matcher": "^1.5.0",
- "strnum": "^2.2.3"
- },
- "bin": {
- "fxparser": "src/cli/cli.js"
- }
- },
"node_modules/fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
@@ -7558,40 +7380,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/json-schema-migrate": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz",
- "integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ajv": "^8.0.0"
- }
- },
- "node_modules/json-schema-migrate/node_modules/ajv": {
- "version": "8.20.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
- "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^3.0.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/json-schema-migrate/node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -7619,43 +7407,6 @@
"json5": "lib/cli.js"
}
},
- "node_modules/jsonc-eslint-parser": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.2.tgz",
- "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.5.0",
- "eslint-visitor-keys": "^3.0.0",
- "espree": "^9.0.0",
- "semver": "^7.3.5"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ota-meshi"
- }
- },
- "node_modules/jsonc-eslint-parser/node_modules/espree": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
- "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.9.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^3.4.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -8857,21 +8608,6 @@
"node": ">=8"
}
},
- "node_modules/path-expression-matcher": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
- "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -9616,16 +9352,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/regexp-tree": {
- "version": "0.1.27",
- "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
- "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "regexp-tree": "bin/regexp-tree"
- }
- },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -9647,16 +9373,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/require-from-string": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
- "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -9795,16 +9511,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/safe-regex": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz",
- "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "regexp-tree": "~0.1.1"
- }
- },
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
@@ -10262,18 +9968,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/strnum": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
- "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT"
- },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
@@ -10513,23 +10207,6 @@
}
}
},
- "node_modules/synckit": {
- "version": "0.9.3",
- "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.3.tgz",
- "integrity": "sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@pkgr/core": "^0.1.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": "^14.18.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/unts"
- }
- },
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
@@ -10656,22 +10333,6 @@
"node": ">=14.0.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",
- "integrity": "sha512-moYoCvkNUAPCxSW9jmHmRElhm4tVJpHL8ItC/+uYD0EpPSFXbck7yREz9tNdJVTSpHVod8+HoipcpbQ0oE6gsw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-visitor-keys": "^3.0.0"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ota-meshi"
- }
- },
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
@@ -10795,19 +10456,6 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
- "node_modules/tunnel-agent": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "safe-buffer": "^5.0.1"
- },
- "engines": {
- "node": "*"
- }
- },
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
@@ -11528,21 +11176,6 @@
"integrity": "sha512-azrivNydKRYt7zwLV5wWUK7YzKTWs3q87xSmY6DlHapPrCvaT6ZrukvM5erV+yCSSPmZT8zkSdttOHQpWWm9zw==",
"license": "BSD"
},
- "node_modules/xml-naming": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
- "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=16.0.0"
- }
- },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -11585,23 +11218,6 @@
"url": "https://github.com/sponsors/eemeli"
}
},
- "node_modules/yaml-eslint-parser": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.2.tgz",
- "integrity": "sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-visitor-keys": "^3.0.0",
- "yaml": "^2.0.0"
- },
- "engines": {
- "node": "^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ota-meshi"
- }
- },
"node_modules/yargs": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
diff --git a/package.json b/package.json
index 1159d503..325bccf9 100644
--- a/package.json
+++ b/package.json
@@ -137,6 +137,7 @@
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json",
"test:e2e:obsidian:startup-integrity": "tsx test/e2e-obsidian/scripts/startup-integrity.ts",
"test:e2e:obsidian:third-party-chunks": "tsx test/e2e-obsidian/scripts/third-party-chunks.ts",
+ "test:e2e:obsidian:e2ee-plaintext-policy": "tsx test/e2e-obsidian/scripts/e2ee-plaintext-policy.ts",
"test:e2e:obsidian:upgrade-livesync-batching": "tsx test/e2e-obsidian/scripts/upgrade-livesync-batching.ts",
"pretest:browser-ui-lifecycle": "svelte-check --tsconfig test/browser-apps/ui-lifecycle/tsconfig.json --fail-on-warnings && vite build --config test/browser-apps/ui-lifecycle/vite.config.ts",
"test:browser-ui-lifecycle": "deno test -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/ui-lifecycle/browser-ui-lifecycle.test.ts",
@@ -172,7 +173,7 @@
"esbuild-plugin-inline-worker": "^0.1.1",
"esbuild-svelte": "^0.9.4",
"eslint": "^9.39.3",
- "eslint-plugin-obsidianmd": "^0.4.1",
+ "eslint-plugin-obsidianmd": "^0.5.1",
"eslint-plugin-svelte": "^3.19.0",
"events": "^3.3.0",
"globals": "^14.0.0",
@@ -214,7 +215,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
- "@vrtmrz/livesync-commonlib": "0.4.2",
+ "@vrtmrz/livesync-commonlib": "0.4.3",
"@vrtmrz/obsidian-plugin-kit": "0.1.7",
"@vrtmrz/ui-interactions": "0.1.2",
"@vrtmrz/value-store": "0.1.0",
diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts
index 209a3f80..f595c4cb 100644
--- a/src/common/messages/LiveSyncProvisionalMessages.ts
+++ b/src/common/messages/LiveSyncProvisionalMessages.ts
@@ -351,6 +351,13 @@ export const liveSyncProvisionalEnglishMessages = {
"Connection settings": "Connection settings",
"Saved connections": "Saved connections",
"ID generation": "ID generation",
+ "Accept unencrypted content": "Accept unencrypted content",
+ "Enabled. Third-party tools can add content without the E2EE passphrase. Anyone with database write access can inject plaintext; content sent by this plug-in remains encrypted.":
+ "Enabled. Third-party tools can add content without the E2EE passphrase. Anyone with database write access can inject plaintext; content sent by this plug-in remains encrypted.",
+ "With this option enabled, third-party tools can add content without the E2EE passphrase. Anyone with database write access can inject plaintext. This affects received content only; content sent by this plug-in remains encrypted.":
+ "With this option enabled, third-party tools can add content without the E2EE passphrase. Anyone with database write access can inject plaintext. This affects received content only; content sent by this plug-in remains encrypted.",
+ "Accepting unencrypted content will be enabled. Third-party tools can add content without the E2EE passphrase. Anyone with database write access can inject plaintext. This affects received content only; content sent by this plug-in remains encrypted.":
+ "Accepting unencrypted content will be enabled. Third-party tools can add content without the E2EE passphrase. Anyone with database write access can inject plaintext. This affects received content only; content sent by this plug-in remains encrypted.",
"Keep current configuration": "Keep current configuration",
"Set an ID key": "Set an ID key",
"Current configuration: a saved ID key is used.": "Current configuration: a saved ID key is used.",
diff --git a/src/common/replicatorConfigurationIdentity.ts b/src/common/replicatorConfigurationIdentity.ts
index d2a1b1cd..b85e91c1 100644
--- a/src/common/replicatorConfigurationIdentity.ts
+++ b/src/common/replicatorConfigurationIdentity.ts
@@ -85,6 +85,7 @@ export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSett
settings.useCouchDBCacheControlHeadersOnly === true,
settings.disableRequestURI,
projectRemoteSecurity(settings),
+ settings.encrypt === true && settings.acceptUnencryptedContent === true,
usesEncryptedInternalMetadata(settings),
settings.enableCompression,
]);
diff --git a/src/common/replicatorConfigurationIdentity.unit.spec.ts b/src/common/replicatorConfigurationIdentity.unit.spec.ts
index 13bd92c2..65195ef6 100644
--- a/src/common/replicatorConfigurationIdentity.unit.spec.ts
+++ b/src/common/replicatorConfigurationIdentity.unit.spec.ts
@@ -130,6 +130,20 @@ describe("active Replicator configuration identity", () => {
);
});
+ it("recreates an encrypted CouchDB connection when plaintext receipt becomes enabled", () => {
+ const current = configuredSettings({ encrypt: true });
+ const enabled = { ...current, acceptUnencryptedContent: true };
+
+ expect(getCouchDBReplicatorConfigurationIdentity(enabled)).not.toBe(
+ getCouchDBReplicatorConfigurationIdentity(current)
+ );
+
+ const disabledE2EE = { ...current, encrypt: false, acceptUnencryptedContent: false };
+ expect(
+ getCouchDBReplicatorConfigurationIdentity({ ...disabledE2EE, acceptUnencryptedContent: true })
+ ).toBe(getCouchDBReplicatorConfigurationIdentity(disabledE2EE));
+ });
+
it("projects only the active CouchDB authentication mode", () => {
const basic = configuredSettings({ useJWT: false, jwtKey: "inactive-a" });
expect(getCouchDBReplicatorConfigurationIdentity({ ...basic, jwtKey: "inactive-b" })).toBe(
diff --git a/src/features/ConfigSync/PluginDialogModal.ts b/src/features/ConfigSync/PluginDialogModal.ts
index f5dc12ef..14902377 100644
--- a/src/features/ConfigSync/PluginDialogModal.ts
+++ b/src/features/ConfigSync/PluginDialogModal.ts
@@ -16,11 +16,7 @@ export class PluginDialogModal extends Modal {
override onOpen() {
const { contentEl } = this;
- this.contentEl.setCssStyles({
- overflow: "auto",
- display: "flex",
- flexDirection: "column",
- });
+ contentEl.addClass("sls-config-sync-modal-content");
this.titleEl.setText("Customization Sync (Beta3)");
if (!this.component) {
this.component = mount(PluginPane, {
diff --git a/src/modules/coreObsidian/UILib/dialogs.ts b/src/modules/coreObsidian/UILib/dialogs.ts
index 0923263e..d45988d7 100644
--- a/src/modules/coreObsidian/UILib/dialogs.ts
+++ b/src/modules/coreObsidian/UILib/dialogs.ts
@@ -192,10 +192,7 @@ export class MessageBox extends AutoClosableModal {
contentEl.closest(".modal-container")?.classList.add("livesync-message-box-container");
this.titleEl.setText(this.title);
const div = contentEl.createDiv();
- div.setCssStyles({
- userSelect: "text",
- webkitUserSelect: "text",
- });
+ div.addClass("sls-message-box-markdown");
void MarkdownRenderer.render(this.plugin.app, this.contentMd, div, "/", this.component);
const buttonSetting = new Setting(contentEl);
const labelWrapper = contentEl.createDiv();
@@ -206,23 +203,18 @@ export class MessageBox extends AutoClosableModal {
labelEl.addClass("sls-dialogue-note-countdown");
if (!this.timeout || !this.timer) {
labelWrapper.empty();
- labelWrapper.setCssStyles({ display: "none" });
+ labelWrapper.addClass("sls-hidden");
}
- buttonSetting.infoEl.setCssStyles({ display: "none" });
- buttonSetting.controlEl.setCssStyles({ flexWrap: "wrap" });
+ buttonSetting.infoEl.addClass("sls-hidden");
+ buttonSetting.controlEl.addClass("sls-message-box-button-control");
if (this.wideButton) {
- buttonSetting.controlEl.setCssStyles({
- flexDirection: "column",
- alignItems: "center",
- justifyContent: "center",
- flexGrow: "1",
- });
+ buttonSetting.controlEl.addClass("sls-message-box-button-control-wide");
}
contentEl.addEventListener("click", () => {
if (this.timer) {
labelWrapper.empty();
- labelWrapper.setCssStyles({ display: "none" });
+ labelWrapper.addClass("sls-hidden");
compatGlobal.clearInterval(this.timer);
this.timer = undefined;
this.defaultButtonComponent?.setButtonText(`${this.defaultAction}`);
@@ -244,10 +236,7 @@ export class MessageBox extends AutoClosableModal {
btn.setCta();
}
if (this.wideButton) {
- btn.buttonEl.setCssStyles({
- flexGrow: "1",
- width: "100%",
- });
+ btn.buttonEl.addClass("sls-message-box-wide-button");
}
return btn;
});
diff --git a/src/modules/features/ModuleObsidianSettingAsMarkdown.credentials.unit.spec.ts b/src/modules/features/ModuleObsidianSettingAsMarkdown.credentials.unit.spec.ts
index a39195ee..f0aac9d3 100644
--- a/src/modules/features/ModuleObsidianSettingAsMarkdown.credentials.unit.spec.ts
+++ b/src/modules/features/ModuleObsidianSettingAsMarkdown.credentials.unit.spec.ts
@@ -55,11 +55,13 @@ describe("ModuleObsidianSettingsAsMarkdown", () => {
"respects the sharing option for new and existing files (existing: %s)",
async (existingFile) => {
const { module, settings, files, storageAccess } = createHarness(false, existingFile);
+ (settings as any).acceptUnencryptedContent = true;
settings.P2P_managedType = CLOUDFLARE_TURN_TYPE;
await module.saveSettingToMarkdown(settings.settingSyncFile);
const content = files.get(settings.settingSyncFile)!;
expect(storageAccess.writeFileAuto).toHaveBeenCalled();
const written = parse(module.extractSettingFromWholeText(content).body);
+ expect(written.acceptUnencryptedContent).toBe(true);
expect(written).not.toHaveProperty("couchDB_PASSWORD");
expect(content).not.toContain(settings.encryptedCouchDBConnection);
expect(Object.keys(written).filter((key) => ["accessKey", "secretKey"].includes(key))).toEqual([]);
@@ -76,6 +78,7 @@ describe("ModuleObsidianSettingsAsMarkdown", () => {
const { module, settings, files } = createHarness(true);
settings.couchDB_USER = "synthetic-export-couch-user";
settings.passphrase = "synthetic-export-passphrase";
+ (settings as any).acceptUnencryptedContent = true;
settings.idDerivationKey = "synthetic-export-id-key";
settings.jwtKey = "synthetic-export-jwt-key";
settings.jwtKid = "synthetic-export-jwt-kid";
@@ -138,6 +141,7 @@ describe("ModuleObsidianSettingsAsMarkdown", () => {
expect(written.couchDB_USER).toBe(settings.couchDB_USER);
expect(written.couchDB_PASSWORD).toBe(settings.couchDB_PASSWORD);
expect(written.passphrase).toBe(settings.passphrase);
+ expect(written.acceptUnencryptedContent).toBe(true);
expect(written.idDerivationKey).toBe(settings.idDerivationKey);
expect(written.jwtKey).toBe(settings.jwtKey);
expect(written.jwtKid).toBe(settings.jwtKid);
@@ -231,6 +235,7 @@ describe("ModuleObsidianSettingsAsMarkdown", () => {
couchDB_PASSWORD: "synthetic-local-password",
couchDB_CustomHeaders: "X-Couch-Local: synthetic-couch-header",
bucketCustomHeaders: "X-Bucket-Local: synthetic-bucket-header",
+ acceptUnencryptedContent: true,
jwtKey: "synthetic-local-jwt-key",
jwtKid: "synthetic-local-kid",
jwtSub: "synthetic-local-subject",
@@ -305,6 +310,7 @@ describe("ModuleObsidianSettingsAsMarkdown", () => {
const appliedSettings = applyExternalSettings.mock.calls[0]![0];
expect(appliedSettings.P2P_AutoStart).toBe(true);
+ expect(appliedSettings.acceptUnencryptedContent).toBe(true);
expect(appliedSettings.accessKey).toBe(currentSettings.accessKey);
expect(appliedSettings.secretKey).toBe(currentSettings.secretKey);
expect(appliedSettings.couchDB_USER).toBe(currentSettings.couchDB_USER);
@@ -320,4 +326,53 @@ describe("ModuleObsidianSettingsAsMarkdown", () => {
expect(appliedSettings.idDerivationVersion).toBe(currentSettings.idDerivationVersion);
expect(appliedSettings.idDerivationKey).toBe(currentSettings.idDerivationKey);
});
+
+ it("explains the plaintext receipt risk in the existing settings application dialogue", async () => {
+ const filename = "LiveSync/plaintext-policy.md";
+ const currentSettings = Object.assign(structuredClone(DEFAULT_SETTINGS), {
+ encrypt: true,
+ settingSyncFile: filename,
+ acceptUnencryptedContent: false,
+ });
+ const markdownSettings = { encrypt: true, acceptUnencryptedContent: true };
+ const document = "Synthetic note\n````yaml:livesync-setting\n" + stringify(markdownSettings) + "\n````\n";
+ const files = new Map([[filename, document]]);
+ const storageAccess = {
+ isExists: vi.fn(async (path: string) => files.has(path)),
+ readFileText: vi.fn(async (path: string) => files.get(path) ?? ""),
+ };
+ let openApplyDialogue: ((anchor: HTMLAnchorElement) => void) | undefined;
+ let chooseApply: (() => void) | undefined;
+ const askSelectStringDialogue = vi.fn(async (_message: string, _choices: string[]) => "Cancel");
+ const module = Object.assign(Object.create(ModuleObsidianSettingsAsMarkdown.prototype), {
+ core: {
+ settings: currentSettings,
+ storageAccess,
+ confirm: {
+ askInPopup: vi.fn((_key: string, _text: string, callback: (anchor: HTMLAnchorElement) => void) => {
+ openApplyDialogue = callback;
+ }),
+ askSelectStringDialogue,
+ },
+ },
+ _log: vi.fn(),
+ }) as ModuleObsidianSettingsAsMarkdown;
+
+ await module.checkAndApplySettingFromMarkdown(filename, false);
+ openApplyDialogue?.({
+ set text(_value: string) {},
+ addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => {
+ chooseApply = listener as () => void;
+ },
+ } as HTMLAnchorElement);
+ chooseApply?.();
+
+ await vi.waitFor(() => expect(askSelectStringDialogue).toHaveBeenCalledOnce());
+ expect(askSelectStringDialogue.mock.calls[0]![0]).toContain(
+ "Anyone with database write access can inject plaintext"
+ );
+ expect(askSelectStringDialogue.mock.calls[0]![0]).toContain(
+ "Third-party tools can add content without the E2EE passphrase"
+ );
+ });
});
diff --git a/src/modules/features/ModuleObsidianSettingAsMarkdown.ts b/src/modules/features/ModuleObsidianSettingAsMarkdown.ts
index 64442378..251d7f41 100644
--- a/src/modules/features/ModuleObsidianSettingAsMarkdown.ts
+++ b/src/modules/features/ModuleObsidianSettingAsMarkdown.ts
@@ -159,8 +159,14 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
const APPLY_AND_REBUILD = "Apply settings and restart obsidian with red_flag_rebuild.md";
const APPLY_AND_FETCH = "Apply settings and restart obsidian with red_flag_fetch.md";
const CANCEL = "Cancel";
+ const enablesUnencryptedContent =
+ settingToApply.encrypt === true &&
+ settingToApply.acceptUnencryptedContent === true &&
+ !(this.settings.encrypt === true && this.settings.acceptUnencryptedContent === true);
const result = await this.core.confirm.askSelectStringDialogue(
- "Ready for apply the setting.",
+ enablesUnencryptedContent
+ ? "Accepting unencrypted content will be enabled. Third-party tools can add content without the E2EE passphrase. Anyone with database write access can inject plaintext. Content sent by this plug-in remains encrypted."
+ : "Ready for apply the setting.",
[APPLY_AND_RESTART, APPLY_ONLY, APPLY_AND_FETCH, APPLY_AND_REBUILD, CANCEL],
{ defaultAction: APPLY_AND_RESTART }
);
diff --git a/src/modules/features/SettingDialogue/PaneHelp.ts b/src/modules/features/SettingDialogue/PaneHelp.ts
index c0923752..9fef4d7d 100644
--- a/src/modules/features/SettingDialogue/PaneHelp.ts
+++ b/src/modules/features/SettingDialogue/PaneHelp.ts
@@ -56,11 +56,9 @@ export function paneHelp(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
lifetimeComponent
);
if (pageDisposed) return;
- troubleShootEl.querySelector(".sls-troubleshoot-anchor")?.parentElement?.setCssStyles({
- position: "sticky",
- top: "-1em",
- backgroundColor: "var(--modal-background)",
- });
+ troubleShootEl
+ .querySelector(".sls-troubleshoot-anchor")
+ ?.parentElement?.addClass("sls-troubleshoot-sticky");
troubleShootEl.querySelectorAll("a.internal-link").forEach((anchorEl) => {
anchorEl.addEventListener("click", (evt) => {
fireAndForget(async () => {
@@ -77,7 +75,7 @@ export function paneHelp(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
uri.substring(1).toLowerCase()
);
if (target) {
- target.setCssStyles({ scrollMargin: "3em" });
+ target.addClass("sls-troubleshoot-heading-target");
target.scrollIntoView({
behavior: "instant",
block: "start",
@@ -86,7 +84,7 @@ export function paneHelp(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
} else {
evt.preventDefault();
await loadMarkdownPage(uri, basePath);
- troubleShootEl.setCssStyles({ scrollMargin: "1em" });
+ troubleShootEl.addClass("sls-troubleshoot-scroll-target");
troubleShootEl.scrollIntoView({
behavior: "instant",
block: "start",
diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts
index 81a68312..31b2c194 100644
--- a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts
+++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts
@@ -145,9 +145,13 @@ export function paneRemoteConfig(
if (applied) {
this.editingSettings.encryptInternalMetadata =
this.core.settings.encryptInternalMetadata;
+ this.editingSettings.acceptUnencryptedContent =
+ this.core.settings.acceptUnencryptedContent;
if (this.initialSettings) {
this.initialSettings.encryptInternalMetadata =
this.core.settings.encryptInternalMetadata;
+ this.initialSettings.acceptUnencryptedContent =
+ this.core.settings.acceptUnencryptedContent;
}
this.requestUpdate();
}
diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts
index 8b2a0187..265011a2 100644
--- a/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts
+++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts
@@ -258,4 +258,46 @@ describe("paneRemoteConfig", () => {
expect(host.initialSettings.idDerivationKey).toBe(nextIdKey);
expect(host.requestUpdate).toHaveBeenCalledOnce();
});
+
+ it("copies the applied plaintext receipt preference into both dialogue buffers", async () => {
+ const originalSettings = Object.assign(
+ {
+ encrypt: true,
+ passphrase: "passphrase",
+ E2EEAlgorithm: "v2",
+ usePathObfuscation: true,
+ encryptInternalMetadata: false,
+ remoteConfigurations: {},
+ },
+ { acceptUnencryptedContent: false }
+ );
+ const setupManager = {
+ onlyE2EEConfiguration: vi.fn(() => {
+ host.core.settings.acceptUnencryptedContent = true;
+ return Promise.resolve(true);
+ }),
+ };
+ const host = {
+ editingSettings: { ...originalSettings },
+ initialSettings: { ...originalSettings },
+ core: {
+ settings: { ...originalSettings },
+ getModule: vi.fn(() => setupManager),
+ },
+ lifetimeComponent: { register: vi.fn() },
+ requestUpdate: vi.fn(),
+ };
+ const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
+ then(callback: (paneEl: HTMLElement) => void) {
+ if (heading === "E2EE Configuration") callback(createPanelElement());
+ },
+ }));
+
+ paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
+ await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
+
+ expect(host.editingSettings.acceptUnencryptedContent).toBe(true);
+ expect(host.initialSettings.acceptUnencryptedContent).toBe(true);
+ expect(host.requestUpdate).toHaveBeenCalledOnce();
+ });
});
diff --git a/src/modules/features/SettingDialogue/settingUtils.ts b/src/modules/features/SettingDialogue/settingUtils.ts
index debcab56..f53e7365 100644
--- a/src/modules/features/SettingDialogue/settingUtils.ts
+++ b/src/modules/features/SettingDialogue/settingUtils.ts
@@ -11,6 +11,7 @@ import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import type PouchDB from "pouchdb-core";
import type {} from "pouchdb-replication";
+import { $msg as translateMessage } from "@/common/translation";
/**
* Generates a summary of P2P configuration settings
@@ -56,7 +57,13 @@ export function getCouchDBConfigSummary(setting: ObsidianLiveSyncSettings, showA
*/
export function getE2EEConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced = false) {
const settingTable: Partial = pickEncryptionSettings(setting);
- return getSummaryFromPartialSettings(settingTable, showAdvanced);
+ const summary = getSummaryFromPartialSettings(settingTable, showAdvanced);
+ if (setting.encrypt === true && setting.acceptUnencryptedContent === true) {
+ summary[translateMessage("Accept unencrypted content")] = translateMessage(
+ "Enabled. Third-party tools can add content without the E2EE passphrase. Anyone with database write access can inject plaintext; content sent by this plug-in remains encrypted."
+ );
+ }
+ return summary;
}
/**
diff --git a/src/modules/features/SettingDialogue/settingUtils.unit.spec.ts b/src/modules/features/SettingDialogue/settingUtils.unit.spec.ts
new file mode 100644
index 00000000..dc34ef21
--- /dev/null
+++ b/src/modules/features/SettingDialogue/settingUtils.unit.spec.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
+import { getE2EEConfigSummary } from "./settingUtils";
+
+describe("getE2EEConfigSummary", () => {
+ it("shows the active plaintext receipt policy and its effect", () => {
+ const settings = Object.assign(createNewVaultSettings(), {
+ encrypt: true,
+ acceptUnencryptedContent: true,
+ });
+
+ expect(getE2EEConfigSummary(settings)["Accept unencrypted content"]).toContain(
+ "Third-party tools can add content without the E2EE passphrase"
+ );
+ expect(getE2EEConfigSummary(settings)["Accept unencrypted content"]).toContain(
+ "database write access can inject plaintext"
+ );
+ });
+
+ it.each([
+ [false, true],
+ [true, false],
+ ])("omits the plaintext receipt policy when E2EE is off or the preference is false", (encrypt, accept) => {
+ const settings = Object.assign(createNewVaultSettings(), {
+ encrypt,
+ acceptUnencryptedContent: accept,
+ });
+
+ expect(getE2EEConfigSummary(settings)).not.toHaveProperty("Accept unencrypted content");
+ });
+});
diff --git a/src/modules/features/SetupManager.ts b/src/modules/features/SetupManager.ts
index ffd24a90..82fea814 100644
--- a/src/modules/features/SetupManager.ts
+++ b/src/modules/features/SetupManager.ts
@@ -7,6 +7,7 @@ import {
REMOTE_COUCHDB,
REMOTE_MINIO,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
+import { normaliseAcceptUnencryptedContent } from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "@/common/newVaultSettings";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
@@ -42,6 +43,7 @@ import type {
UseSetupURIResultType,
ApplySettingsInitialisationResultType,
ApplySettingsInitialisationInitialData,
+ OutroApplyInitialData,
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
import {
applySettingsAndFetchOnActivation,
@@ -60,7 +62,7 @@ function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings):
};
}
-function normaliseImportedIdentitySettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
+function normaliseCompleteImportedSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
// Setup URIs are complete imports even when their encoder omitted default-valued fields.
// Fill each missing half so a receiving device cannot supply the unrelated saved key.
return {
@@ -74,6 +76,8 @@ function normaliseImportedIdentitySettings(settings: ObsidianLiveSyncSettings):
idDerivationKey: Object.prototype.hasOwnProperty.call(settings, "idDerivationKey")
? settings.idDerivationKey
: "",
+ // Do not inherit a destination's preference when a complete import omits this newer field.
+ acceptUnencryptedContent: normaliseAcceptUnencryptedContent(settings.acceptUnencryptedContent),
};
}
@@ -238,7 +242,7 @@ export class SetupManager extends AbstractModule {
return false;
}
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
- return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdentitySettings(newSetting), userMode);
+ return await this.onConfirmApplySettingsFromWizard(normaliseCompleteImportedSettings(newSetting), userMode);
}
/**
@@ -383,16 +387,25 @@ export class SetupManager extends AbstractModule {
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
return false;
}
- const onlyInternalMetadataPreferenceChanged =
- currentSetting.encryptInternalMetadata !== e2eeConf.encryptInternalMetadata &&
+ const internalMetadataPreferenceChanged =
+ currentSetting.encryptInternalMetadata !== e2eeConf.encryptInternalMetadata;
+ const plaintextReceiptPreferenceChanged =
+ normaliseAcceptUnencryptedContent(currentSetting.acceptUnencryptedContent) !==
+ normaliseAcceptUnencryptedContent(e2eeConf.acceptUnencryptedContent);
+ const onlyE2EEPreferenceChanges =
+ (internalMetadataPreferenceChanged || plaintextReceiptPreferenceChanged) &&
currentSetting.encrypt === e2eeConf.encrypt &&
currentSetting.passphrase === e2eeConf.passphrase &&
currentSetting.E2EEAlgorithm === e2eeConf.E2EEAlgorithm &&
currentSetting.usePathObfuscation === e2eeConf.usePathObfuscation &&
currentSetting.idDerivationVersion === e2eeConf.idDerivationVersion &&
currentSetting.idDerivationKey === e2eeConf.idDerivationKey;
- if (userMode === UserMode.Update && onlyInternalMetadataPreferenceChanged) {
- if (e2eeConf.encryptInternalMetadata && currentSetting.remoteType === REMOTE_COUCHDB) {
+ if (userMode === UserMode.Update && onlyE2EEPreferenceChanges) {
+ if (
+ internalMetadataPreferenceChanged &&
+ e2eeConf.encryptInternalMetadata &&
+ currentSetting.remoteType === REMOTE_COUCHDB
+ ) {
const proceed = "Enable without rebuilding — update every other device first";
const choice = await this.core.confirm.askSelectStringDialogue(
"A manual remote Rebuild is strongly recommended to protect existing file properties. " +
@@ -404,10 +417,16 @@ export class SetupManager extends AbstractModule {
);
if (choice !== proceed) return false;
}
- await this.services.setting.applyPartial(
- { encryptInternalMetadata: e2eeConf.encryptInternalMetadata },
- true
- );
+ const partial: Partial = {};
+ if (internalMetadataPreferenceChanged) {
+ partial.encryptInternalMetadata = e2eeConf.encryptInternalMetadata;
+ }
+ if (plaintextReceiptPreferenceChanged) {
+ partial.acceptUnencryptedContent = normaliseAcceptUnencryptedContent(
+ e2eeConf.acceptUnencryptedContent
+ );
+ }
+ await this.services.setting.applyPartial(partial, true);
return true;
}
const newSetting = {
@@ -480,6 +499,10 @@ export class SetupManager extends AbstractModule {
...this.settings,
...newConf,
});
+ const enableUnencryptedContent =
+ newConf.encrypt === true &&
+ normaliseAcceptUnencryptedContent(newConf.acceptUnencryptedContent) &&
+ !(this.settings.encrypt === true && normaliseAcceptUnencryptedContent(this.settings.acceptUnencryptedContent));
let userMode = _userMode;
if (userMode === UserMode.Unknown) {
if (isObjectDifferent(this.settings, newConf, true) === false) {
@@ -505,8 +528,10 @@ export class SetupManager extends AbstractModule {
if (applied) this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
return applied;
} else {
- const userModeResult =
- await this.dialogManager.openWithExplicitCancel(OutroAskUserMode);
+ const userModeResult = await this.dialogManager.openWithExplicitCancel<
+ OutroAskUserModeResultType,
+ OutroApplyInitialData
+ >(OutroAskUserMode, { isP2P: isP2PMainRemote(newConf), enableUnencryptedContent });
if (userModeResult === "new-user") {
userMode = UserMode.NewUser;
} else if (userModeResult === "existing-user") {
@@ -525,8 +550,8 @@ export class SetupManager extends AbstractModule {
const component = userMode === UserMode.NewUser ? OutroNewUser : OutroExistingUser;
const confirm = await this.dialogManager.openWithExplicitCancel<
OutroNewUserResultType | OutroExistingUserResultType,
- { isP2P: boolean }
- >(component, { isP2P: isP2PMainRemote(newConf) });
+ OutroApplyInitialData
+ >(component, { isP2P: isP2PMainRemote(newConf), enableUnencryptedContent });
if (confirm === "cancelled") {
this._log("User cancelled applying settings from wizard..", LOG_LEVEL_NOTICE);
return false;
@@ -572,7 +597,7 @@ export class SetupManager extends AbstractModule {
async decodeQR(qr: string) {
let newSettings: ObsidianLiveSyncSettings;
try {
- newSettings = normaliseImportedIdentitySettings(decodeSettingsFromQRCodeData(qr));
+ newSettings = normaliseCompleteImportedSettings(decodeSettingsFromQRCodeData(qr));
} catch {
this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE);
return false;
diff --git a/src/modules/features/SetupManager.unit.spec.ts b/src/modules/features/SetupManager.unit.spec.ts
index e7952952..abac4c50 100644
--- a/src/modules/features/SetupManager.unit.spec.ts
+++ b/src/modules/features/SetupManager.unit.spec.ts
@@ -301,7 +301,8 @@ describe("SetupManager", () => {
pathNormalizationVersion: 1,
idDerivationVersion: 1,
idDerivationKey: savedKey,
- };
+ acceptUnencryptedContent: true,
+ } as ObsidianLiveSyncSettings;
const imported = {
...createLegacyRemoteSetting(),
isConfigured: true,
@@ -318,6 +319,7 @@ describe("SetupManager", () => {
expect(mergedSettings.pathNormalizationVersion).toBe(0);
expect(mergedSettings.idDerivationVersion).toBe(0);
expect(mergedSettings.idDerivationKey).toBe("");
+ expect((mergedSettings as any).acceptUnencryptedContent).toBe(false);
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
});
@@ -329,7 +331,8 @@ describe("SetupManager", () => {
isConfigured: true,
idDerivationVersion: 1,
idDerivationKey: savedKey,
- };
+ acceptUnencryptedContent: true,
+ } as ObsidianLiveSyncSettings;
const imported = {
...createLegacyRemoteSetting(),
isConfigured: true,
@@ -383,9 +386,99 @@ describe("SetupManager", () => {
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
expect(mergedSettings.idDerivationVersion).toBe(0);
expect(mergedSettings.idDerivationKey).toBe("");
+ expect((mergedSettings as any).acceptUnencryptedContent).toBe(false);
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
});
+ it("normalises a malformed plaintext receipt value in a Setup URI to false", async () => {
+ const { manager, setting, dialogManager } = createSetupManager();
+ setting.settings = Object.assign(createLegacyRemoteSetting(), { acceptUnencryptedContent: true });
+ const imported = Object.assign(createLegacyRemoteSetting(), { acceptUnencryptedContent: "true" });
+ vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
+ dialogManager.openWithExplicitCancel.mockResolvedValueOnce(imported).mockResolvedValueOnce("cancelled");
+
+ await manager.onUseSetupURI(UserMode.Unknown, "mock-config://invalid-plaintext-policy");
+
+ expect((vi.mocked(setting.adjustSettings).mock.calls[0]![0] as any).acceptUnencryptedContent).toBe(false);
+ });
+
+ it.each(["setup-uri", "qr"] as const)("preserves an explicit plaintext receipt choice from %s", async (source) => {
+ const { manager, setting, dialogManager } = createSetupManager();
+ const imported = Object.assign(createLegacyRemoteSetting(), { acceptUnencryptedContent: true });
+ vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
+ if (source === "setup-uri") {
+ dialogManager.openWithExplicitCancel.mockResolvedValueOnce(imported).mockResolvedValueOnce("cancelled");
+ await manager.onUseSetupURI(UserMode.Unknown, "mock-config://enabled-plaintext-policy");
+ } else {
+ vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(imported);
+ dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
+ await manager.decodeQR("qr-data");
+ }
+
+ expect((vi.mocked(setting.adjustSettings).mock.calls[0]![0] as any).acceptUnencryptedContent).toBe(true);
+ });
+
+ it("warns in the existing application confirmation when an import enables plaintext receipt", async () => {
+ const { manager, setting, dialogManager } = createSetupManager();
+ setting.settings = Object.assign(setting.currentSettings(), {
+ encrypt: true,
+ acceptUnencryptedContent: false,
+ });
+ const imported = { ...setting.currentSettings(), acceptUnencryptedContent: true };
+ dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
+
+ await expect(manager.onConfirmApplySettingsFromWizard(imported, UserMode.ExistingUser)).resolves.toBe(false);
+
+ expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ enableUnencryptedContent: true })
+ );
+ });
+
+ it("applies a preference-only import to an existing vault without Fetch or Rebuild", async () => {
+ const { manager, setting, dialogManager, core } = createSetupManager();
+ setting.settings = Object.assign(setting.currentSettings(), {
+ isConfigured: true,
+ encrypt: true,
+ acceptUnencryptedContent: false,
+ });
+ const imported = { ...setting.currentSettings(), acceptUnencryptedContent: true };
+ dialogManager.openWithExplicitCancel.mockResolvedValueOnce("compatible-existing-user");
+
+ await expect(manager.onConfirmApplySettingsFromWizard(imported, UserMode.Unknown)).resolves.toBe(true);
+
+ expect((setting.currentSettings() as any).acceptUnencryptedContent).toBe(true);
+ expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
+ expect(core.rebuilder.scheduleRebuild).not.toHaveBeenCalled();
+ });
+
+ it("passes the warning through the mode choice before a compatible or new-user application", async () => {
+ const { manager, setting, dialogManager } = createSetupManager();
+ setting.settings = Object.assign(setting.currentSettings(), {
+ encrypt: true,
+ acceptUnencryptedContent: false,
+ });
+ const imported = {
+ ...setting.currentSettings(),
+ couchDB_DBNAME: "imported-vault",
+ acceptUnencryptedContent: true,
+ };
+ dialogManager.openWithExplicitCancel.mockResolvedValueOnce("new-user").mockResolvedValueOnce("cancelled");
+
+ await expect(manager.onConfirmApplySettingsFromWizard(imported, UserMode.Unknown)).resolves.toBe(false);
+
+ expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
+ 1,
+ expect.anything(),
+ expect.objectContaining({ enableUnencryptedContent: true })
+ );
+ expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
+ 2,
+ expect.anything(),
+ expect.objectContaining({ enableUnencryptedContent: true })
+ );
+ });
+
it("rejects invalid QR settings before applying them", async () => {
const { manager, setting } = createSetupManager();
vi.mocked(decodeSettingsFromQRCodeData).mockImplementationOnce(() => {
@@ -481,6 +574,7 @@ describe("SetupManager", () => {
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), {
isP2P: true,
+ enableUnencryptedContent: false,
});
});
@@ -922,3 +1016,129 @@ describe("internal Metadata configuration", () => {
}
);
});
+
+describe("plaintext receipt configuration", () => {
+ it("retains the saved preference when encryption is turned off", async () => {
+ const { manager, setting, dialogManager } = createSetupManager();
+ const current = Object.assign(setting.currentSettings(), {
+ encrypt: true,
+ acceptUnencryptedContent: true,
+ });
+ dialogManager.openWithExplicitCancel.mockResolvedValue({ ...current, encrypt: false });
+ const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
+
+ await expect(manager.onlyE2EEConfiguration(UserMode.Update, current)).resolves.toBe(true);
+
+ expect(confirm).toHaveBeenCalledWith(
+ expect.objectContaining({ encrypt: false, acceptUnencryptedContent: true }),
+ UserMode.Update
+ );
+ });
+
+ it("keeps the normal setup path when the E2EE passphrase changes with plaintext receipt", async () => {
+ const { manager, setting, dialogManager } = createSetupManager();
+ const current = Object.assign(setting.currentSettings(), {
+ encrypt: true,
+ passphrase: "old-passphrase",
+ acceptUnencryptedContent: false,
+ });
+ dialogManager.openWithExplicitCancel.mockResolvedValue({
+ ...current,
+ passphrase: "new-passphrase",
+ acceptUnencryptedContent: true,
+ });
+ const apply = vi.spyOn(setting, "applyPartial");
+ const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
+
+ await expect(manager.onlyE2EEConfiguration(UserMode.Update, current)).resolves.toBe(true);
+
+ expect(apply).not.toHaveBeenCalled();
+ expect(confirm).toHaveBeenCalledWith(
+ expect.objectContaining({ passphrase: "new-passphrase", acceptUnencryptedContent: true }),
+ UserMode.Update
+ );
+ });
+
+ it("applies a plaintext receipt preference alone without setup initialisation", async () => {
+ const { manager, setting, dialogManager, core } = createSetupManager();
+ const current = Object.assign(setting.currentSettings(), {
+ encrypt: true,
+ acceptUnencryptedContent: false,
+ });
+ dialogManager.openWithExplicitCancel.mockResolvedValue({ ...current, acceptUnencryptedContent: true });
+ const apply = vi.spyOn(setting, "applyPartial").mockImplementation(async (partial) => {
+ Object.assign(setting.settings, partial);
+ });
+ const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard");
+
+ await expect(manager.onlyE2EEConfiguration(UserMode.Update, current)).resolves.toBe(true);
+
+ expect(apply).toHaveBeenCalledWith(expect.objectContaining({ acceptUnencryptedContent: true }), true);
+ expect(confirm).not.toHaveBeenCalled();
+ expect((setting.currentSettings() as any).acceptUnencryptedContent).toBe(true);
+ expect(core.rebuilder.scheduleRebuild).not.toHaveBeenCalled();
+ expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
+ });
+
+ it("does not show the Metadata Rebuild warning when only plaintext receipt changes", async () => {
+ const { manager, setting, dialogManager, core } = createSetupManager();
+ const current = Object.assign(setting.currentSettings(), {
+ encrypt: true,
+ encryptInternalMetadata: true,
+ acceptUnencryptedContent: false,
+ remoteType: REMOTE_COUCHDB,
+ });
+ dialogManager.openWithExplicitCancel.mockResolvedValue({ ...current, acceptUnencryptedContent: true });
+ const ask = vi.fn(async (_message: string, choices: string[]) => choices[0]);
+ core.confirm = { askSelectStringDialogue: ask };
+ const apply = vi.spyOn(setting, "applyPartial").mockResolvedValue(undefined);
+
+ await expect(manager.onlyE2EEConfiguration(UserMode.Update, current)).resolves.toBe(true);
+
+ expect(ask).not.toHaveBeenCalled();
+ expect(apply).toHaveBeenCalledWith(expect.objectContaining({ acceptUnencryptedContent: true }), true);
+ expect(core.rebuilder.scheduleRebuild).not.toHaveBeenCalled();
+ expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
+ });
+
+ it.each([true, false])(
+ "retains the internal Metadata warning when both preferences change (accepted: %s)",
+ async (accept) => {
+ const { manager, setting, dialogManager, core } = createSetupManager();
+ const current = Object.assign(setting.currentSettings(), {
+ encrypt: true,
+ encryptInternalMetadata: false,
+ acceptUnencryptedContent: false,
+ remoteType: REMOTE_COUCHDB,
+ });
+ dialogManager.openWithExplicitCancel.mockResolvedValue({
+ ...current,
+ encryptInternalMetadata: true,
+ acceptUnencryptedContent: true,
+ });
+ const ask = vi.fn(async (_message: string, choices: string[]) => (accept ? choices[0] : "Cancel"));
+ core.confirm = { askSelectStringDialogue: ask };
+ const apply = vi.spyOn(setting, "applyPartial").mockImplementation(async (partial) => {
+ Object.assign(setting.settings, partial);
+ });
+
+ await expect(manager.onlyE2EEConfiguration(UserMode.Update, current)).resolves.toBe(accept);
+
+ expect(ask).toHaveBeenCalledOnce();
+ expect(ask.mock.calls[0]![0]).toContain("currently running LiveSync");
+ expect(apply).toHaveBeenCalledTimes(accept ? 1 : 0);
+ if (accept) {
+ expect(apply.mock.calls[0]![0]).toEqual(
+ expect.objectContaining({
+ encryptInternalMetadata: true,
+ acceptUnencryptedContent: true,
+ })
+ );
+ expect(setting.currentSettings().encryptInternalMetadata).toBe(true);
+ expect((setting.currentSettings() as any).acceptUnencryptedContent).toBe(true);
+ }
+ expect(core.rebuilder.scheduleRebuild).not.toHaveBeenCalled();
+ expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
+ }
+ );
+});
diff --git a/src/modules/features/SetupWizard/dialogs/OutroAskUserMode.svelte b/src/modules/features/SetupWizard/dialogs/OutroAskUserMode.svelte
index 68ca63bd..67ce8e5e 100644
--- a/src/modules/features/SetupWizard/dialogs/OutroAskUserMode.svelte
+++ b/src/modules/features/SetupWizard/dialogs/OutroAskUserMode.svelte
@@ -4,6 +4,7 @@
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
+ import PlaintextReceiptWarning from "./PlaintextReceiptWarning.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
@@ -13,13 +14,16 @@
TYPE_EXISTING,
TYPE_NEW,
TYPE_COMPATIBLE_EXISTING,
+ type OutroApplyInitialData,
} from "./setupDialogTypes";
import { $msg as translateMessage } from "@/common/translation";
type Props = {
setResult: (result: OutroAskUserModeResultType) => void;
+ getInitialData?: () => OutroApplyInitialData | undefined;
};
- const { setResult }: Props = $props();
+ const { setResult, getInitialData }: Props = $props();
+ const enableUnencryptedContent = $derived(getInitialData?.()?.enableUnencryptedContent === true);
let userType = $state(TYPE_CANCELLED);
const canProceed = $derived.by(() => {
return userType === TYPE_EXISTING || userType === TYPE_NEW || userType === TYPE_COMPATIBLE_EXISTING;
@@ -79,6 +83,9 @@
"Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you are doing."
)}
+ {#if enableUnencryptedContent && userType === TYPE_COMPATIBLE_EXISTING}
+
+ {/if}
diff --git a/src/modules/features/SetupWizard/dialogs/OutroExistingUser.svelte b/src/modules/features/SetupWizard/dialogs/OutroExistingUser.svelte
index 9911eb26..7aee4100 100644
--- a/src/modules/features/SetupWizard/dialogs/OutroExistingUser.svelte
+++ b/src/modules/features/SetupWizard/dialogs/OutroExistingUser.svelte
@@ -5,15 +5,22 @@
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
+ import PlaintextReceiptWarning from "./PlaintextReceiptWarning.svelte";
import { $msg as translateMessage } from "@/common/translation";
- import { TYPE_CANCELLED, TYPE_APPLY, type OutroExistingUserResultType } from "./setupDialogTypes";
+ import {
+ TYPE_CANCELLED,
+ TYPE_APPLY,
+ type OutroApplyInitialData,
+ type OutroExistingUserResultType,
+ } from "./setupDialogTypes";
type Props = {
setResult: (result: OutroExistingUserResultType) => void;
- getInitialData?: () => { isP2P?: boolean } | undefined;
+ getInitialData?: () => OutroApplyInitialData | undefined;
};
const { setResult, getInitialData }: Props = $props();
const isP2P = $derived(getInitialData?.()?.isP2P === true);
+ const enableUnencryptedContent = $derived(getInitialData?.()?.enableUnencryptedContent === true);
{#if isP2P}
@@ -37,6 +44,9 @@
{translateMessage("Restart this device, then choose the source device when P2P Rebuild opens.")}
+ {#if enableUnencryptedContent}
+
+ {/if}
+ {#if enableUnencryptedContent}
+
+ {/if}
void;
- getInitialData?: () => { isP2P?: boolean } | undefined;
+ getInitialData?: () => OutroApplyInitialData | undefined;
};
const { setResult, getInitialData }: Props = $props();
const isP2P = $derived(getInitialData?.()?.isP2P === true);
+ const enableUnencryptedContent = $derived(getInitialData?.()?.enableUnencryptedContent === true);
// let userType = $state(TYPE_CANCELLED);
@@ -26,6 +33,9 @@
{msg("Ui.SetupWizard.OutroNewP2PUser.GuidanceNotice")}
+ {#if enableUnencryptedContent}
+
+ {/if}
{msg("Ui.SetupWizard.OutroNewP2PUser.Question")}
@@ -55,6 +65,9 @@
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten."
)}
+ {#if enableUnencryptedContent}
+
+ {/if}
{msg("Please select the button below to restart and proceed to the final confirmation.")}
diff --git a/src/modules/features/SetupWizard/dialogs/PlaintextReceiptWarning.svelte b/src/modules/features/SetupWizard/dialogs/PlaintextReceiptWarning.svelte
new file mode 100644
index 00000000..1059df77
--- /dev/null
+++ b/src/modules/features/SetupWizard/dialogs/PlaintextReceiptWarning.svelte
@@ -0,0 +1,10 @@
+
+
+
+ {translateMessage(
+ "Accepting unencrypted content will be enabled. Third-party tools can add content without the E2EE passphrase. Anyone with database write access can inject plaintext. This affects received content only; content sent by this plug-in remains encrypted."
+ )}
+
diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte
index 0a06a719..0a164275 100644
--- a/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte
+++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte
@@ -40,6 +40,7 @@
E2EEAlgorithm: DEFAULT_SETTINGS.E2EEAlgorithm,
usePathObfuscation: true,
encryptInternalMetadata: true,
+ acceptUnencryptedContent: DEFAULT_SETTINGS.acceptUnencryptedContent,
idDerivationVersion: 0,
idDerivationKey: "",
};
@@ -392,6 +393,18 @@
"Changing the encryption algorithm will prevent access to any data previously encrypted with a different algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your data."
)}
+
+
+
+
+ {translateMessage(
+ "With this option enabled, third-party tools can add content without the E2EE passphrase. Anyone with database write access can inject plaintext. This affects received content only; content sent by this plug-in remains encrypted."
+ )}
+
diff --git a/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts b/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts
index 816fc8c9..8d9f0264 100644
--- a/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts
+++ b/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts
@@ -85,6 +85,10 @@ export type OutroAskUserModeResultType =
| typeof TYPE_CANCELLED;
export type OutroExistingUserResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
+export type OutroApplyInitialData = {
+ isP2P: boolean;
+ enableUnencryptedContent: boolean;
+};
export type OutroNewUserResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
diff --git a/styles.css b/styles.css
index 7fddcd4c..1b7e09b1 100644
--- a/styles.css
+++ b/styles.css
@@ -122,10 +122,30 @@
max-width: max-content;
}
+.sls-troubleshoot-preview .sls-troubleshoot-sticky {
+ position: sticky;
+ top: -1em;
+ background-color: var(--modal-background);
+}
+
+.sls-troubleshoot-heading-target {
+ scroll-margin: 3em;
+}
+
+.sls-troubleshoot-preview.sls-troubleshoot-scroll-target {
+ scroll-margin: 1em;
+}
+
.sls-troubleshoot-preview img {
max-width: 100%;
}
+.modal .modal-content.sls-config-sync-modal-content {
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+}
+
.sls-setting-tab {
display: none;
}
@@ -577,6 +597,35 @@ body.is-mobile .sls-setting .sls-setting-additional-action {
align-items: center;
}
+.sls-dialogue-note-wrapper.sls-hidden {
+ display: none;
+}
+
+.livesync-message-box-container .modal-content .sls-message-box-markdown {
+ user-select: text;
+ -webkit-user-select: text;
+}
+
+.livesync-message-box-container .setting-item-info.sls-hidden {
+ display: none;
+}
+
+.livesync-message-box-container .setting-item-control.sls-message-box-button-control {
+ flex-wrap: wrap;
+}
+
+.livesync-message-box-container .setting-item-control.sls-message-box-button-control-wide {
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex-grow: 1;
+}
+
+.livesync-message-box-container .setting-item-control .sls-message-box-wide-button {
+ flex-grow: 1;
+ width: 100%;
+}
+
.sls-dialogue-note-countdown {
font-size: 0.8em;
}
diff --git a/test/e2e-obsidian/scripts/e2ee-plaintext-policy.ts b/test/e2e-obsidian/scripts/e2ee-plaintext-policy.ts
new file mode 100644
index 00000000..0a1934f1
--- /dev/null
+++ b/test/e2e-obsidian/scripts/e2ee-plaintext-policy.ts
@@ -0,0 +1,936 @@
+import { createHash, randomUUID } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import {
+ assertCouchDbReachable,
+ createCouchDbDatabase,
+ deleteCouchDbDatabase,
+ fetchCouchDbDocument,
+ loadCouchDbConfig,
+ makeUniqueDatabaseName,
+ putCouchDbDocument,
+ type CouchDbConfig,
+ type CouchDbDocument,
+} from "../runner/couchdb.ts";
+import { evalObsidianJson } from "../runner/cli.ts";
+import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
+import {
+ assertEqual,
+ createE2eCouchDbPluginData,
+ createE2eObsidianDeviceLocalState,
+ prepareRemote,
+ waitForLiveSyncCoreReady,
+} from "../runner/liveSyncWorkflow.ts";
+import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
+import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
+
+process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
+
+const fixtureContent = "# Synthetic remote fixture\n\nPlaintext body used by the isolated E2EE policy E2E.\n";
+
+type RemoteFixture = {
+ fixtureId: string;
+ path: string;
+ metadataId: string;
+ metadataRev: string;
+ chunkId: string;
+ chunkRev: string;
+};
+
+type ReplicationOutcome = {
+ succeeded: boolean;
+ idle: boolean;
+};
+
+type LocalOutcome = {
+ metadataPresent: boolean;
+ metadataRevision: string | null;
+ chunkPresent: boolean;
+ decodedPlaintextPresent: boolean;
+ vaultFilePresent: boolean;
+ vaultContentMatches: boolean;
+};
+
+type VaultFileState = {
+ present: boolean;
+ contentMatches: boolean;
+};
+
+type DirectFetchOutcome = {
+ reflected: boolean;
+ requestedChunkCount: number;
+ returnedChunkCount: number;
+};
+
+type E2eePolicyUiOutcome = {
+ initialEncryptionEnabled: boolean;
+ initialAcceptanceChecked: boolean;
+ acceptanceRowInAdvanced: boolean;
+ acceptanceInitiallyEnabled: boolean;
+ acceptanceCheckedAfterClick: boolean;
+ acceptanceDisabledWhenEncryptionOff: boolean;
+ acceptanceCheckedWhenEncryptionOff: boolean;
+ acceptanceEnabledWhenEncryptionOn: boolean;
+ acceptanceCheckedWhenEncryptionOn: boolean;
+ warningMentionsThirdParty: boolean;
+ warningMentionsPassphrase: boolean;
+ warningMentionsDatabaseWriteInjection: boolean;
+ cancelReturnedFalse: boolean;
+ savedAcceptanceUnchanged: boolean;
+};
+
+function fixturePath(fixtureId: string): string {
+ return `E2E/e2ee-plaintext-policy/${fixtureId}.md`;
+}
+
+async function getMetadataId(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise {
+ return await evalObsidianJson(
+ cliBinary,
+ [
+ "(async()=>{",
+ "const core=app.plugins.plugins['obsidian-livesync'].core;",
+ `const path=${JSON.stringify(path)};`,
+ "return JSON.stringify(await core.services.path.path2id(path));",
+ "})()",
+ ].join(""),
+ env
+ );
+}
+
+async function injectPlaintextFixture(
+ cliBinary: string,
+ env: NodeJS.ProcessEnv,
+ couchDb: CouchDbConfig,
+ dbName: string,
+ fixtureId: string
+): Promise {
+ const path = fixturePath(fixtureId);
+ const metadataId = await getMetadataId(cliBinary, env, path);
+ const chunkId = `h:e2ee-plaintext-policy-${randomUUID()}`;
+ const now = Date.now();
+ const metadata: CouchDbDocument = {
+ _id: metadataId,
+ path,
+ type: "plain",
+ ctime: now,
+ mtime: now,
+ size: Buffer.byteLength(fixtureContent, "utf8"),
+ children: [chunkId],
+ eden: {},
+ };
+ const leaf: CouchDbDocument = { _id: chunkId, type: "leaf", data: fixtureContent };
+
+ const metadataPut = await putCouchDbDocument(couchDb, dbName, metadata);
+ const leafPut = await putCouchDbDocument(couchDb, dbName, leaf);
+ const remoteMetadata = await fetchCouchDbDocument(couchDb, dbName, metadataId);
+ const remoteLeaf = await fetchCouchDbDocument(couchDb, dbName, chunkId);
+
+ assertEqual(remoteMetadata.path === path, true, "The remote Metadata path did not match the synthetic fixture.");
+ assertEqual(remoteMetadata.type === "plain", true, "The remote Metadata type was invalid.");
+ assertEqual(
+ remoteMetadata.size === Buffer.byteLength(fixtureContent, "utf8"),
+ true,
+ "The remote Metadata size was invalid."
+ );
+ assertEqual(
+ remoteMetadata.children?.length === 1 && remoteMetadata.children[0] === chunkId,
+ true,
+ "The remote Metadata did not reference the injected leaf."
+ );
+ assertEqual(remoteLeaf.type === "leaf", true, "The injected remote document was not a leaf.");
+ assertEqual(
+ remoteLeaf.data === fixtureContent,
+ true,
+ "The injected remote leaf did not contain the synthetic fixture body."
+ );
+ assertEqual(!("e_" in remoteLeaf), true, "The injected remote leaf unexpectedly had the encryption marker.");
+ assertEqual(
+ remoteMetadata._rev === metadataPut.rev,
+ true,
+ "The remote Metadata revision did not match the injected document."
+ );
+ assertEqual(remoteLeaf._rev === leafPut.rev, true, "The remote leaf revision did not match the injected document.");
+
+ return {
+ fixtureId,
+ path,
+ metadataId,
+ metadataRev: metadataPut.rev,
+ chunkId,
+ chunkRev: leafPut.rev,
+ };
+}
+
+async function runOneShotReplication(
+ cliBinary: string,
+ env: NodeJS.ProcessEnv,
+ timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000)
+): Promise {
+ return await evalObsidianJson(
+ cliBinary,
+ [
+ "(async()=>{",
+ "const core=app.plugins.plugins['obsidian-livesync'].core;",
+ `const timeoutMs=${JSON.stringify(timeoutMs)};`,
+ "const succeeded=!!(await core.services.replication.replicate(true));",
+ "const deadline=Date.now()+timeoutMs;",
+ "let state;",
+ "do{",
+ " state={",
+ " finite:core.services.replicator.finiteReplicationActivityCount.value,",
+ " bounded:core.services.replicator.boundedRemoteActivityCount.value,",
+ " results:core.services.replication.replicationResultCount.value,",
+ " databaseQueue:core.services.replication.databaseQueueCount.value,",
+ " storageApplying:core.services.replication.storageApplyingCount.value,",
+ " };",
+ " if(state.finite===0&&state.bounded===0&&state.results===0&&state.databaseQueue===0&&state.storageApplying===0){",
+ " return JSON.stringify({succeeded,idle:true});",
+ " }",
+ " await new Promise(resolve=>setTimeout(resolve,100));",
+ "}while(Date.now() {
+ const local = await evalObsidianJson(
+ cliBinary,
+ [
+ "(async()=>{",
+ "const core=app.plugins.plugins['obsidian-livesync'].core;",
+ `const path=${JSON.stringify(fixture.path)};`,
+ `const metadataId=${JSON.stringify(fixture.metadataId)};`,
+ `const chunkId=${JSON.stringify(fixture.chunkId)};`,
+ `const content=${JSON.stringify(fixtureContent)};`,
+ "const db=core.localDatabase.localDatabase;",
+ "const rows=await db.allDocs({keys:[metadataId,chunkId],include_docs:true});",
+ "const metadataRow=rows.rows.find(row=>row.id===metadataId&&row.doc&&!row.value?.deleted);",
+ "const chunkRow=rows.rows.find(row=>row.id===chunkId&&row.doc&&!row.value?.deleted);",
+ "const metadata=metadataRow?.doc;",
+ "const chunk=chunkRow?.doc;",
+ "const decoded=metadata&&chunk?await core.localDatabase.getDBEntryFromMeta(metadata,false,false,true).catch(()=>false):false;",
+ "const decodedData=decoded?(Array.isArray(decoded.data)?decoded.data.join(''):decoded.data):undefined;",
+ "const file=app.vault.getAbstractFileByPath(path);",
+ "const decodedPlaintextPresent=decodedData===content;",
+ "return JSON.stringify({",
+ " metadataPresent:!!metadata,metadataRevision:metadata?._rev??null,chunkPresent:!!chunk,",
+ " decodedPlaintextPresent,",
+ " vaultFilePresent:!!file,",
+ " vaultContentMatches:!!file&&(await app.vault.read(file))===content,",
+ "});",
+ "})()",
+ ].join(""),
+ env
+ );
+ const diskState = await readVaultFileState(vault, fixture.path);
+ return {
+ ...local,
+ vaultFilePresent: local.vaultFilePresent && diskState.present,
+ vaultContentMatches: local.vaultContentMatches && diskState.contentMatches,
+ };
+}
+
+async function readVaultFileState(vault: TemporaryVault, path: string): Promise {
+ try {
+ return { present: true, contentMatches: (await readFile(join(vault.path, path), "utf8")) === fixtureContent };
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { present: false, contentMatches: false };
+ throw error;
+ }
+}
+
+async function assertPlaintextRefused(
+ cliBinary: string,
+ env: NodeJS.ProcessEnv,
+ vault: TemporaryVault,
+ fixture: RemoteFixture
+): Promise {
+ const outcome = await readLocalOutcome(cliBinary, env, vault, fixture);
+ assertEqual(
+ outcome.chunkPresent,
+ false,
+ "The unencrypted leaf entered the local database while acceptance was disabled."
+ );
+ assertEqual(
+ outcome.decodedPlaintextPresent,
+ false,
+ "The synthetic plaintext body was readable from the local database while acceptance was disabled."
+ );
+ assertEqual(outcome.vaultFilePresent, false, "A file was reflected into the Vault while acceptance was disabled.");
+ assertEqual(
+ outcome.vaultContentMatches,
+ false,
+ "The synthetic plaintext body was reflected into the Vault while acceptance was disabled."
+ );
+}
+
+async function assertPlaintextAccepted(
+ cliBinary: string,
+ env: NodeJS.ProcessEnv,
+ vault: TemporaryVault,
+ fixture: RemoteFixture
+): Promise {
+ const outcome = await readLocalOutcome(cliBinary, env, vault, fixture);
+ assertEqual(outcome.metadataPresent, true, "The accepted remote Metadata was missing from the local database.");
+ assertEqual(
+ outcome.metadataRevision === fixture.metadataRev,
+ true,
+ "The accepted Metadata revision changed unexpectedly."
+ );
+ assertEqual(outcome.chunkPresent, true, "The explicitly accepted leaf was not stored in the local database.");
+ assertEqual(outcome.decodedPlaintextPresent, true, "The accepted body could not be read from the local database.");
+ assertEqual(outcome.vaultFilePresent, true, "The explicitly accepted body did not create a Vault file.");
+ assertEqual(outcome.vaultContentMatches, true, "The explicitly accepted body did not reach the Obsidian Vault.");
+}
+
+async function applySettingsPatch(
+ cliBinary: string,
+ env: NodeJS.ProcessEnv,
+ patch: Record,
+ expectedPolicy: boolean,
+ expectedReplicatorReplacement: boolean
+): Promise {
+ const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
+ const result = await evalObsidianJson<{
+ realised: number;
+ accepted: boolean;
+ replicatorReplaced: boolean;
+ patchApplied: boolean;
+ diagnostics: {
+ before: {
+ coreEncrypt: boolean;
+ serviceEncrypt: boolean;
+ encryptMatches: boolean;
+ coreAcceptUnencryptedContent: boolean;
+ serviceAcceptUnencryptedContent: boolean;
+ acceptUnencryptedContentMatches: boolean;
+ configurationIdentityPresent: boolean;
+ providerName: string | null;
+ };
+ after: {
+ coreEncrypt: boolean;
+ serviceEncrypt: boolean;
+ encryptMatches: boolean;
+ coreAcceptUnencryptedContent: boolean;
+ serviceAcceptUnencryptedContent: boolean;
+ acceptUnencryptedContentMatches: boolean;
+ configurationIdentityPresent: boolean;
+ providerName: string | null;
+ };
+ configurationIdentityEqual: boolean | null;
+ };
+ operationCalls: Record;
+ }>(
+ cliBinary,
+ [
+ "(async()=>{",
+ "const core=app.plugins.plugins['obsidian-livesync'].core;",
+ "const setting=core.services.setting;",
+ "const rebuilder=core.rebuilder;",
+ `const patch=${JSON.stringify(patch)};`,
+ `const expectedPolicy=${JSON.stringify(expectedPolicy)};`,
+ `const timeoutMs=${JSON.stringify(timeoutMs)};`,
+ "const operationCalls={};",
+ "const targets=[",
+ " [rebuilder,'$fetchLocalDBFast','fastFetch'],",
+ " [rebuilder,'$fetchLocal','fetch'],",
+ " [rebuilder,'fetchLocal','fetchLocal'],",
+ " [rebuilder,'fetchLocalDBFast','fetchLocalDBFast'],",
+ " [rebuilder,'scheduleFetch','scheduleFetch'],",
+ " [rebuilder,'$performRebuildDB','performRebuild'],",
+ " [rebuilder,'rebuildRemote','rebuildRemote'],",
+ " [rebuilder,'$rebuildRemote','rebuildRemoteInternal'],",
+ " [rebuilder,'rebuildEverything','rebuildEverything'],",
+ " [rebuilder,'$rebuildEverything','rebuildEverythingInternal'],",
+ " [rebuilder,'scheduleRebuild','scheduleRebuild'],",
+ " [rebuilder,'resetLocalDatabase','resetLocalDatabase'],",
+ "];",
+ "for(const [target,key] of [[rebuilder,'$fetchLocalDBFast'],[rebuilder,'$fetchLocal'],[rebuilder,'scheduleFetch'],[rebuilder,'$performRebuildDB'],[rebuilder,'scheduleRebuild']]){",
+ " if(typeof target?.[key]!=='function')throw new Error('A required Fetch or Rebuild operation could not be observed.');",
+ "}",
+ "const restorers=[];",
+ "for(const [target,key,label] of targets){",
+ " if(typeof target?.[key]!=='function') continue;",
+ " const descriptor=Object.getOwnPropertyDescriptor(target,key);",
+ " const original=target[key];",
+ " operationCalls[label]=0;",
+ " Object.defineProperty(target,key,{configurable:true,writable:true,value:function(...args){operationCalls[label]++;return original.apply(this,args);}});",
+ " restorers.push(()=>{if(descriptor)Object.defineProperty(target,key,descriptor);else delete target[key];});",
+ "}",
+ "const replicatorService=core.services.replicator;",
+ "const before=replicatorService.getActiveReplicator();",
+ "if(!before)throw new Error('No active CouchDB Replicator was available before settings apply.');",
+ "const beforeContext=replicatorService.inspectActiveReplicatorContext?.();",
+ "const readSettingsState=()=>{const coreEncrypt=core.settings.encrypt===true;const serviceEncrypt=setting.currentSettings().encrypt===true;const coreAcceptUnencryptedContent=core.settings.acceptUnencryptedContent===true;const serviceAcceptUnencryptedContent=setting.currentSettings().acceptUnencryptedContent===true;return {coreEncrypt,serviceEncrypt,encryptMatches:coreEncrypt===serviceEncrypt,coreAcceptUnencryptedContent,serviceAcceptUnencryptedContent,acceptUnencryptedContentMatches:coreAcceptUnencryptedContent===serviceAcceptUnencryptedContent};};",
+ "const providerName=context=>{const name=context?.provider?.diagnosticName??context?.provider?.kind;return typeof name==='string'?name:null;};",
+ "const beforeSettings=readSettingsState();",
+ "const beforeIdentity=beforeContext?.configurationIdentity;",
+ "let realised=0;",
+ "let resolveRealised;",
+ "const realisedPromise=new Promise(resolve=>{resolveRealised=resolve;});",
+ "const unregister=setting.onSettingRealised.addHandler(()=>{realised++;resolveRealised();return Promise.resolve(true);});",
+ "let timer;",
+ "try{",
+ " const apply=async()=>{await setting.applyPartial(patch,true);await realisedPromise;};",
+ " await Promise.race([apply(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Timed out waiting for settings to be realised.')),timeoutMs);})]);",
+ " if(realised!==1)throw new Error('The setting-realisation handler did not run exactly once.');",
+ " const after=replicatorService.getActiveReplicator();",
+ " const afterContext=replicatorService.inspectActiveReplicatorContext?.();",
+ " const afterSettings=readSettingsState();",
+ " const afterIdentity=afterContext?.configurationIdentity;",
+ " const actual=setting.currentSettings().acceptUnencryptedContent===true;",
+ " const patchApplied=Object.entries(patch).every(([key,value])=>core.settings[key]===value&&setting.currentSettings()[key]===value);",
+ " const identityPresent=typeof beforeIdentity==='string'&&typeof afterIdentity==='string';",
+ " const providerNameBefore=providerName(beforeContext);",
+ " const providerNameAfter=providerName(afterContext);",
+ " return JSON.stringify({realised,accepted:actual,replicatorReplaced:!!after&&after!==before,patchApplied,diagnostics:{before:{...beforeSettings,configurationIdentityPresent:typeof beforeIdentity==='string',providerName:providerNameBefore},after:{...afterSettings,configurationIdentityPresent:typeof afterIdentity==='string',providerName:providerNameAfter},configurationIdentityEqual:identityPresent?beforeIdentity===afterIdentity:null},operationCalls});",
+ "}finally{",
+ " clearTimeout(timer);",
+ " unregister();",
+ " for(const restore of restorers.reverse())restore();",
+ "}",
+ "})()",
+ ].join(""),
+ env
+ );
+ assertEqual(result.realised, 1, "The settings change did not reach onSettingRealised.");
+ assertEqual(result.accepted, expectedPolicy, "The plaintext policy setting was not applied.");
+ assertEqual(result.patchApplied, true, "The requested setting patch was not reflected in both settings views.");
+ assertEqual(
+ result.replicatorReplaced,
+ expectedReplicatorReplacement,
+ `Applying the setting did not meet the expected active Replicator replacement behaviour. Safe diagnostics: ${JSON.stringify(result.diagnostics)}`
+ );
+ assertEqual(
+ Object.values(result.operationCalls).every((count) => count === 0),
+ true,
+ "Changing the setting automatically invoked Fetch or Rebuild."
+ );
+}
+
+async function fetchAndReflectOnDemand(
+ cliBinary: string,
+ env: NodeJS.ProcessEnv,
+ fixture: RemoteFixture,
+ timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000)
+): Promise {
+ return await evalObsidianJson(
+ cliBinary,
+ [
+ "(async()=>{",
+ "const core=app.plugins.plugins['obsidian-livesync'].core;",
+ `const path=${JSON.stringify(fixture.path)};`,
+ `const chunkId=${JSON.stringify(fixture.chunkId)};`,
+ `const timeoutMs=${JSON.stringify(timeoutMs)};`,
+ "const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
+ "if(!metadata)throw new Error('The received Metadata is unavailable for direct Chunk retrieval.');",
+ "const replicator=core.services.replicator.getActiveReplicator();",
+ "if(!replicator||typeof replicator.fetchRemoteChunks!=='function')throw new Error('The active CouchDB Replicator has no direct Chunk retrieval method.');",
+ "const descriptor=Object.getOwnPropertyDescriptor(replicator,'fetchRemoteChunks');",
+ "const original=replicator.fetchRemoteChunks;",
+ "let requestedChunkCount=0;let returnedChunkCount=0;",
+ "replicator.fetchRemoteChunks=async function(ids,...args){",
+ " if(ids.includes(chunkId))requestedChunkCount++;",
+ " const result=await original.call(this,ids,...args);",
+ " if(Array.isArray(result))returnedChunkCount+=result.filter(doc=>doc?._id===chunkId).length;",
+ " return result;",
+ "};",
+ "let timer;",
+ "try{",
+ " const operation=core.serviceModules.fileHandler.dbToStorage(metadata,null,true);",
+ " const reflected=!!(await Promise.race([operation,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Timed out while reflecting the direct Chunk fixture.')),timeoutMs);})]));",
+ " return JSON.stringify({reflected,requestedChunkCount,returnedChunkCount});",
+ "}finally{",
+ " clearTimeout(timer);",
+ " if(descriptor)Object.defineProperty(replicator,'fetchRemoteChunks',descriptor);else delete replicator.fetchRemoteChunks;",
+ "}",
+ "})()",
+ ].join(""),
+ env
+ );
+}
+
+async function assertE2eeConfiguration(cliBinary: string, env: NodeJS.ProcessEnv): Promise {
+ const settings = await evalObsidianJson<{
+ encrypted: boolean;
+ algorithm: string;
+ acceptUnencryptedContent: boolean;
+ }>(
+ cliBinary,
+ [
+ "(()=>{",
+ "const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();",
+ "return JSON.stringify({encrypted:settings.encrypt===true,algorithm:settings.E2EEAlgorithm,acceptUnencryptedContent:settings.acceptUnencryptedContent===true});",
+ "})()",
+ ].join(""),
+ env
+ );
+ assertEqual(settings.encrypted, true, "The E2EE fixture did not enable encryption.");
+ assertEqual(settings.algorithm, "v2", "The E2EE fixture did not use V2.");
+ assertEqual(settings.acceptUnencryptedContent, false, "The default plaintext policy did not deny acceptance.");
+}
+
+async function assertNonChunkReferenceRefused(
+ cliBinary: string,
+ env: NodeJS.ProcessEnv,
+ couchDb: CouchDbConfig,
+ dbName: string,
+ vault: TemporaryVault
+): Promise {
+ const fixtureId = `non-chunk-${randomUUID()}`;
+ const path = fixturePath(fixtureId);
+ const metadataId = await getMetadataId(cliBinary, env, path);
+ const chunkId = `soft-deleted-${randomUUID()}.md`;
+ const now = Date.now();
+ const metadataPut = await putCouchDbDocument(couchDb, dbName, {
+ _id: metadataId,
+ path,
+ type: "plain",
+ ctime: now,
+ mtime: now,
+ size: Buffer.byteLength(fixtureContent, "utf8"),
+ children: [chunkId],
+ eden: {},
+ });
+
+ // Receive the referring Metadata before the remote body exists, so the
+ // subsequent read must use the on-demand arrival path rather than a local row.
+ const replication = await runOneShotReplication(cliBinary, env);
+ assertEqual(replication.idle, true, "The non-Chunk fixture Metadata did not reach application quiescence.");
+ const bodyPut = await putCouchDbDocument(couchDb, dbName, {
+ _id: chunkId,
+ type: "plain",
+ deleted: true,
+ data: fixtureContent,
+ });
+ const fixture: RemoteFixture = {
+ fixtureId,
+ path,
+ metadataId,
+ metadataRev: metadataPut.rev,
+ chunkId,
+ chunkRev: bodyPut.rev,
+ };
+ const before = await readLocalOutcome(cliBinary, env, vault, fixture);
+ assertEqual(before.metadataPresent, true, "The referring Metadata was not received.");
+ assertEqual(before.chunkPresent, false, "The non-Chunk fixture was already present locally.");
+
+ const rejected = await fetchAndReflectOnDemand(cliBinary, env, fixture);
+ assertEqual(rejected.requestedChunkCount > 0, true, "The non-Chunk fixture did not use on-demand retrieval.");
+ assertEqual(rejected.returnedChunkCount > 0, true, "The control-exempt body did not reach Chunk validation.");
+ const after = await readLocalOutcome(cliBinary, env, vault, fixture);
+ const acceptance = {
+ reflected: rejected.reflected,
+ stored: after.chunkPresent,
+ vaultFilePresent: after.vaultFilePresent,
+ vaultContentMatches: after.vaultContentMatches,
+ };
+ assertEqual(
+ Object.values(acceptance).some(Boolean),
+ false,
+ `A non-Chunk document was accepted as file content: ${JSON.stringify(acceptance)}`
+ );
+ await assertPlaintextRefused(cliBinary, env, vault, fixture);
+
+ // A corrected remote leaf remains retrievable after opting in to plaintext.
+ const repaired = await putCouchDbDocument(couchDb, dbName, {
+ _id: chunkId,
+ _rev: bodyPut.rev,
+ type: "leaf",
+ data: fixtureContent,
+ });
+ await applySettingsPatch(cliBinary, env, { acceptUnencryptedContent: true }, true, true);
+ const accepted = await fetchAndReflectOnDemand(cliBinary, env, { ...fixture, chunkRev: repaired.rev });
+ assertEqual(accepted.requestedChunkCount > 0, true, "The corrected Chunk was not requested again.");
+ assertEqual(accepted.reflected, true, "The corrected plaintext leaf was not reflected after opt-in.");
+ await assertPlaintextAccepted(cliBinary, env, vault, fixture);
+ return fixtureId;
+}
+
+async function inspectE2eePolicyDialog(cliBinary: string, env: NodeJS.ProcessEnv): Promise {
+ const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
+ return await evalObsidianJson(
+ cliBinary,
+ [
+ "(async()=>{",
+ "const timeoutMs=",
+ JSON.stringify(timeoutMs),
+ ";",
+ "const deadline=Date.now()+timeoutMs;",
+ "const waitFor=async(predicate,label,end=deadline)=>{while(Date.now()setTimeout(resolve,50));}throw new Error('Timed out while '+label+'.');};",
+ "const core=app.plugins.plugins['obsidian-livesync'].core;",
+ "const initialAcceptance=core.settings.acceptUnencryptedContent;",
+ "const manager=core.modules.find(module=>module.constructor.name==='SetupManager');",
+ "if(typeof manager?.onlyE2EEConfiguration!=='function')throw new Error('Could not find SetupManager.onlyE2EEConfiguration.');",
+ "let dialogSettled=false;let dialogResult;let dialogFailure;let dialog=null;",
+ "const dialogPromise=manager.onlyE2EEConfiguration('unknown',{...core.settings});",
+ "void dialogPromise.then(result=>{dialogResult=result;dialogSettled=true;},error=>{dialogFailure=error;dialogSettled=true;});",
+ "try{",
+ " dialog=await waitFor(()=>document.querySelector('.sls-e2ee-dialog'),'opening the End-to-End Encryption dialogue');",
+ " const findRow=label=>Array.from(dialog.querySelectorAll('label.row')).find(row=>row.querySelector('span')?.textContent?.trim()===label);",
+ " const e2eeRow=findRow('End-to-End Encryption');",
+ " if(!e2eeRow)throw new Error('The E2EE checkbox label row was not rendered.');",
+ " const e2eeInput=e2eeRow.querySelector('input[type=checkbox]');",
+ " if(!e2eeInput)throw new Error('The E2EE checkbox was not rendered.');",
+ " const details=Array.from(dialog.querySelectorAll('details')).find(element=>element.querySelector('summary')?.textContent?.trim()==='Advanced');",
+ " if(!details)throw new Error('The Advanced details section was not rendered.');",
+ " details.querySelector('summary').click();",
+ " await waitFor(()=>details.open,'opening the Advanced details section');",
+ " const acceptanceRow=findRow('Accept unencrypted content');",
+ " if(!acceptanceRow)throw new Error('The plaintext acceptance checkbox label row was not rendered.');",
+ " const acceptanceInput=acceptanceRow.querySelector('input[type=checkbox]');",
+ " if(!acceptanceInput)throw new Error('The plaintext acceptance checkbox was not rendered.');",
+ " const acceptanceRowInAdvanced=details.contains(acceptanceRow);",
+ " const initialEncryptionEnabled=e2eeInput.checked;",
+ " const initialAcceptanceChecked=acceptanceInput.checked;",
+ " const acceptanceInitiallyEnabled=!acceptanceInput.disabled;",
+ " const warningText=details.textContent??'';",
+ " const warningMentionsThirdParty=warningText.includes('third-party tools can add content');",
+ " const warningMentionsPassphrase=warningText.includes('without the E2EE passphrase');",
+ " const warningMentionsDatabaseWriteInjection=warningText.includes('Anyone with database write access can inject plaintext');",
+ " acceptanceInput.click();",
+ " await waitFor(()=>acceptanceInput.checked,'enabling plaintext acceptance');",
+ " const acceptanceCheckedAfterClick=acceptanceInput.checked;",
+ " e2eeInput.click();",
+ " await waitFor(()=>!e2eeInput.checked&&acceptanceInput.disabled&&acceptanceInput.checked,'disabling E2EE while retaining plaintext acceptance');",
+ " const acceptanceDisabledWhenEncryptionOff=acceptanceInput.disabled;",
+ " const acceptanceCheckedWhenEncryptionOff=acceptanceInput.checked;",
+ " e2eeInput.click();",
+ " await waitFor(()=>e2eeInput.checked&&!acceptanceInput.disabled&&acceptanceInput.checked,'re-enabling E2EE with plaintext acceptance retained');",
+ " const acceptanceEnabledWhenEncryptionOn=!acceptanceInput.disabled;",
+ " const acceptanceCheckedWhenEncryptionOn=acceptanceInput.checked;",
+ " const cancel=Array.from(dialog.querySelectorAll('button')).find(button=>button.textContent?.trim()==='Cancel');",
+ " if(!cancel)throw new Error('The E2EE dialogue Cancel button was not rendered.');",
+ " cancel.click();",
+ " await waitFor(()=>dialogSettled,'waiting for SetupManager dialogue completion');",
+ " if(dialogFailure)throw new Error('SetupManager dialogue failed.');",
+ " return JSON.stringify({initialEncryptionEnabled,initialAcceptanceChecked,acceptanceRowInAdvanced,acceptanceInitiallyEnabled,acceptanceCheckedAfterClick,acceptanceDisabledWhenEncryptionOff,acceptanceCheckedWhenEncryptionOff,acceptanceEnabledWhenEncryptionOn,acceptanceCheckedWhenEncryptionOn,warningMentionsThirdParty,warningMentionsPassphrase,warningMentionsDatabaseWriteInjection,cancelReturnedFalse:dialogResult===false,savedAcceptanceUnchanged:core.settings.acceptUnencryptedContent===initialAcceptance&&core.services.setting.currentSettings().acceptUnencryptedContent===initialAcceptance});",
+ "}finally{",
+ " if(!dialogSettled){",
+ " const root=dialog??document.querySelector('.sls-e2ee-dialog');",
+ " const cancel=root&&Array.from(root.querySelectorAll('button')).find(button=>button.textContent?.trim()==='Cancel');",
+ " if(cancel)cancel.click();",
+ " await waitFor(()=>dialogSettled,'cleaning up the E2EE dialogue',Date.now()+timeoutMs);",
+ " }",
+ "}",
+ "})()",
+ ].join(""),
+ env
+ );
+}
+
+async function main(): Promise {
+ const binary = requireObsidianBinary();
+ const cliBinary = requireObsidianCli();
+ const artifactRoot = process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd();
+ const couchDb = await loadCouchDbConfig();
+ await assertCouchDbReachable(couchDb);
+
+ const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "e2ee-plaintext-policy");
+ const connection = { ...couchDb, dbName };
+ const vault = await createTemporaryVault("obsidian-livesync-e2ee-plaintext-policy-");
+ let session: ObsidianLiveSyncSession | undefined;
+ let databaseCreated = false;
+
+ try {
+ await createCouchDbDatabase(couchDb, dbName);
+ databaseCreated = true;
+ session = await startObsidianLiveSyncSession({
+ binary,
+ cliBinary,
+ vault,
+ artifactRoot,
+ pluginData: createE2eCouchDbPluginData(connection, {
+ encrypt: true,
+ E2EEAlgorithm: "v2",
+ passphrase: "synthetic-e2ee-plaintext-policy-fixture",
+ acceptUnencryptedContent: false,
+ encryptInternalMetadata: false,
+ usePathObfuscation: false,
+ readChunksOnline: false,
+ useOnlyLocalChunk: false,
+ useRequestAPI: true,
+ periodicReplication: false,
+ syncOnStart: false,
+ syncOnSave: false,
+ syncOnFileOpen: false,
+ syncOnEditorSave: false,
+ syncAfterMerge: false,
+ }),
+ localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
+ });
+ await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
+ await prepareRemote(cliBinary, session.cliEnv);
+ await assertE2eeConfiguration(cliBinary, session.cliEnv);
+ const e2eePolicyUi = await inspectE2eePolicyDialog(cliBinary, session.cliEnv);
+ assertEqual(
+ e2eePolicyUi.initialEncryptionEnabled,
+ true,
+ "The E2EE configuration dialogue did not open with encryption enabled."
+ );
+ assertEqual(
+ e2eePolicyUi.initialAcceptanceChecked,
+ false,
+ "The E2EE configuration dialogue did not open with plaintext acceptance disabled."
+ );
+ assertEqual(
+ e2eePolicyUi.acceptanceRowInAdvanced,
+ true,
+ "The plaintext acceptance control was outside the Advanced details section."
+ );
+ assertEqual(
+ e2eePolicyUi.acceptanceInitiallyEnabled,
+ true,
+ "The plaintext acceptance control was disabled while E2EE was enabled."
+ );
+ assertEqual(
+ e2eePolicyUi.acceptanceCheckedAfterClick,
+ true,
+ "The plaintext acceptance control did not bind its checked state."
+ );
+ assertEqual(
+ e2eePolicyUi.acceptanceDisabledWhenEncryptionOff,
+ true,
+ "The plaintext acceptance control remained enabled while E2EE was disabled."
+ );
+ assertEqual(
+ e2eePolicyUi.acceptanceCheckedWhenEncryptionOff,
+ true,
+ "Disabling E2EE discarded the selected plaintext acceptance value."
+ );
+ assertEqual(
+ e2eePolicyUi.acceptanceEnabledWhenEncryptionOn,
+ true,
+ "The plaintext acceptance control remained disabled after re-enabling E2EE."
+ );
+ assertEqual(
+ e2eePolicyUi.acceptanceCheckedWhenEncryptionOn,
+ true,
+ "Re-enabling E2EE discarded the selected plaintext acceptance value."
+ );
+ assertEqual(
+ e2eePolicyUi.warningMentionsThirdParty,
+ true,
+ "The plaintext acceptance warning did not explain third-party content injection."
+ );
+ assertEqual(
+ e2eePolicyUi.warningMentionsPassphrase,
+ true,
+ "The plaintext acceptance warning did not explain that the E2EE passphrase is not required."
+ );
+ assertEqual(
+ e2eePolicyUi.warningMentionsDatabaseWriteInjection,
+ true,
+ "The plaintext acceptance warning did not explain database-write injection risk."
+ );
+ assertEqual(
+ e2eePolicyUi.cancelReturnedFalse,
+ true,
+ "Cancelling the E2EE configuration dialogue did not return false."
+ );
+ assertEqual(
+ e2eePolicyUi.savedAcceptanceUnchanged,
+ true,
+ "Cancelling the E2EE configuration dialogue changed the saved plaintext policy."
+ );
+
+ const ordinary = await injectPlaintextFixture(
+ cliBinary,
+ session.cliEnv,
+ couchDb,
+ dbName,
+ `ordinary-${randomUUID()}`
+ );
+ const ordinaryReplication = await runOneShotReplication(cliBinary, session.cliEnv);
+ assertEqual(ordinaryReplication.idle, true, "The ordinary CouchDB pull did not reach application quiescence.");
+ await assertPlaintextRefused(cliBinary, session.cliEnv, vault, ordinary);
+ assertEqual(
+ ordinaryReplication.succeeded,
+ false,
+ "The ordinary CouchDB pull did not fail when it received a forbidden plaintext leaf."
+ );
+
+ await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: true }, true, true);
+ await assertPlaintextRefused(cliBinary, session.cliEnv, vault, ordinary);
+
+ const acceptedReplication = await runOneShotReplication(cliBinary, session.cliEnv);
+ assertEqual(
+ acceptedReplication.succeeded,
+ true,
+ "The ordinary CouchDB pull did not accept the same remote plaintext leaf after policy apply."
+ );
+ assertEqual(
+ acceptedReplication.idle,
+ true,
+ "The accepted ordinary CouchDB pull did not reach application quiescence."
+ );
+ const ordinaryRemoteMetadata = await fetchCouchDbDocument(couchDb, dbName, ordinary.metadataId);
+ const ordinaryRemoteLeaf = await fetchCouchDbDocument(couchDb, dbName, ordinary.chunkId);
+ assertEqual(
+ ordinaryRemoteMetadata._rev === ordinary.metadataRev,
+ true,
+ "The ordinary Metadata revision changed during acceptance."
+ );
+ assertEqual(
+ ordinaryRemoteLeaf._rev === ordinary.chunkRev,
+ true,
+ "The ordinary leaf revision changed during acceptance."
+ );
+ assertEqual(
+ !("e_" in ordinaryRemoteLeaf),
+ true,
+ "The accepted ordinary remote leaf unexpectedly had the encryption marker."
+ );
+ await assertPlaintextAccepted(cliBinary, session.cliEnv, vault, ordinary);
+
+ await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: false }, false, true);
+ await applySettingsPatch(cliBinary, session.cliEnv, { readChunksOnline: true }, false, false);
+ const onDemand = await injectPlaintextFixture(
+ cliBinary,
+ session.cliEnv,
+ couchDb,
+ dbName,
+ `on-demand-${randomUUID()}`
+ );
+ const onDemandReplication = await runOneShotReplication(cliBinary, session.cliEnv);
+ assertEqual(
+ onDemandReplication.idle,
+ true,
+ "The on-demand Metadata pull did not reach application quiescence."
+ );
+ const onDemandMetadata = await evalObsidianJson<{ present: boolean; revision: string | null }>(
+ cliBinary,
+ [
+ "(async()=>{",
+ `const path=${JSON.stringify(onDemand.path)};`,
+ "const metadata=await app.plugins.plugins['obsidian-livesync'].core.localDatabase.getDBEntryMeta(path,undefined,true);",
+ "return JSON.stringify({present:!!metadata,revision:metadata?._rev??null});",
+ "})()",
+ ].join(""),
+ session.cliEnv
+ );
+ assertEqual(onDemandMetadata.present, true, "The on-demand pull did not receive the valid Metadata.");
+ assertEqual(
+ onDemandMetadata.revision === onDemand.metadataRev,
+ true,
+ "The on-demand pull received an unexpected Metadata revision."
+ );
+ await assertPlaintextRefused(cliBinary, session.cliEnv, vault, onDemand);
+
+ const deniedDirectFetch = await fetchAndReflectOnDemand(cliBinary, session.cliEnv, onDemand);
+ assertEqual(
+ deniedDirectFetch.requestedChunkCount > 0,
+ true,
+ "The denied direct read did not request the remote Chunk."
+ );
+ assertEqual(deniedDirectFetch.returnedChunkCount, 0, "The denied direct read returned the plaintext Chunk.");
+ assertEqual(deniedDirectFetch.reflected, false, "The denied direct read reflected the plaintext body.");
+ await assertPlaintextRefused(cliBinary, session.cliEnv, vault, onDemand);
+ const onDemandMetadataAfterDenial = await evalObsidianJson<{ present: boolean; revision: string | null }>(
+ cliBinary,
+ [
+ "(async()=>{",
+ `const path=${JSON.stringify(onDemand.path)};`,
+ "const metadata=await app.plugins.plugins['obsidian-livesync'].core.localDatabase.getDBEntryMeta(path,undefined,true);",
+ "return JSON.stringify({present:!!metadata,revision:metadata?._rev??null});",
+ "})()",
+ ].join(""),
+ session.cliEnv
+ );
+ assertEqual(onDemandMetadataAfterDenial.present, true, "The rejected on-demand read removed its Metadata.");
+ assertEqual(
+ onDemandMetadataAfterDenial.revision === onDemand.metadataRev,
+ true,
+ "The rejected on-demand read changed its Metadata revision."
+ );
+
+ await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: true }, true, true);
+ const acceptedDirectFetch = await fetchAndReflectOnDemand(cliBinary, session.cliEnv, onDemand);
+ assertEqual(
+ acceptedDirectFetch.requestedChunkCount > 0,
+ true,
+ "The accepted direct read did not request the remote Chunk again."
+ );
+ assertEqual(
+ acceptedDirectFetch.returnedChunkCount > 0,
+ true,
+ "The accepted direct read did not receive the same remote Chunk."
+ );
+ assertEqual(
+ acceptedDirectFetch.reflected,
+ true,
+ "The accepted direct read did not reflect the plaintext body."
+ );
+ const onDemandRemoteMetadata = await fetchCouchDbDocument(couchDb, dbName, onDemand.metadataId);
+ const onDemandRemoteLeaf = await fetchCouchDbDocument(couchDb, dbName, onDemand.chunkId);
+ assertEqual(
+ onDemandRemoteMetadata._rev === onDemand.metadataRev,
+ true,
+ "The on-demand Metadata revision changed during acceptance."
+ );
+ assertEqual(
+ onDemandRemoteLeaf._rev === onDemand.chunkRev,
+ true,
+ "The on-demand leaf revision changed during acceptance."
+ );
+ assertEqual(
+ !("e_" in onDemandRemoteLeaf),
+ true,
+ "The accepted on-demand remote leaf unexpectedly had the encryption marker."
+ );
+ await assertPlaintextAccepted(cliBinary, session.cliEnv, vault, onDemand);
+
+ await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: false }, false, true);
+ const nonChunkFixtureId = await assertNonChunkReferenceRefused(
+ cliBinary,
+ session.cliEnv,
+ couchDb,
+ dbName,
+ vault
+ );
+
+ const artifactHash = createHash("sha256")
+ .update(new Uint8Array(await readFile(join(artifactRoot, "main.js"))))
+ .digest("hex");
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ scenario: "e2ee-plaintext-policy",
+ artifactHash,
+ fixtureIds: [ordinary.fixtureId, onDemand.fixtureId, nonChunkFixtureId],
+ assertions: {
+ remoteLeafHadNoEncryptionMarker: true,
+ defaultOrdinaryPullRejectedThePlaintextLeafWithoutStoringOrReflectingIt: true,
+ policyApplyReachedSettingRealisedAndReplacedTheReplicatorWithoutFetchOrRebuild: true,
+ sameRemoteMetadataAndLeafRevisionWereAcceptedAfterPolicyChange: true,
+ onDemandMetadataArrivedBeforePlaintextWasReflected: true,
+ directChunkReadWasDeniedThenRetriedAndReflectedAfterPolicyChange: true,
+ e2eeDialogBoundThePlaintextPolicyAndCancelLeftItUnchanged: true,
+ nonChunkReferenceWasRejectedBeforeStorageAndVaultReflection: true,
+ correctedRemoteLeafRemainedRetrievableAfterOptIn: true,
+ },
+ },
+ null,
+ 2
+ )
+ );
+ } finally {
+ if (session) await session.app.stop();
+ await vault.dispose();
+ if (databaseCreated) await deleteCouchDbDatabase(couchDb, dbName);
+ }
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.stack : error);
+ process.exitCode = 1;
+});
diff --git a/updates.md b/updates.md
index 80d368e6..e4d0f603 100644
--- a/updates.md
+++ b/updates.md
@@ -12,6 +12,23 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased
+### Changed
+
+#### Encryption
+
+- With E2EE enabled, CouchDB documents containing unencrypted file content are now rejected by default.
+ - To retain the previous behaviour for third-party integrations that intentionally write plaintext, expand **Advanced** in the E2EE dialogue and enable **Accept unencrypted content**. The setting is off by default.
+ - Enabling it allows anyone with write access to the remote database to supply plaintext file content. Content sent by this plug-in remains encrypted.
+ - The setting is included in exported settings Markdown, Setup URIs, and QR codes. Changing this setting alone does not require Fetch or Rebuild.
+
+### Miscellaneous
+
+- Updated dependencies.
+
+### Acknowledgements
+
+- @Hama1cco, for reporting plaintext content acceptance with E2EE enabled.
+
## 1.0.48
11th November, 2026
From 48062a343ea86b405f1b046d2f6842aa25afb02b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 11 Nov 2026 11:33:18 +0000
Subject: [PATCH 2/3] Releasing 1.0.49
---
manifest.json | 2 +-
package-lock.json | 10 +++++-----
package.json | 2 +-
src/apps/cli/package.json | 2 +-
src/apps/webapp/package.json | 2 +-
src/apps/webpeer/package.json | 2 +-
updates.md | 4 ++++
versions.json | 3 ++-
8 files changed, 16 insertions(+), 11 deletions(-)
diff --git a/manifest.json b/manifest.json
index ea702c71..6fb5be79 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
- "version": "1.0.48",
+ "version": "1.0.49",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
diff --git a/package-lock.json b/package-lock.json
index 6a7b4ff8..d39063d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
- "version": "1.0.48",
+ "version": "1.0.49",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
- "version": "1.0.48",
+ "version": "1.0.49",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -11278,7 +11278,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
- "version": "1.0.48-cli",
+ "version": "1.0.49-cli",
"dependencies": {
"@aws-crypto/sha256-js": "5.2.0",
"chokidar": "6.0.0",
@@ -11335,7 +11335,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
- "version": "1.0.48-webapp",
+ "version": "1.0.49-webapp",
"dependencies": {
"octagonal-wheels": "0.1.56"
},
@@ -11347,7 +11347,7 @@
}
},
"src/apps/webpeer": {
- "version": "1.0.48-webpeer",
+ "version": "1.0.49-webpeer",
"dependencies": {
"octagonal-wheels": "0.1.56"
},
diff --git a/package.json b/package.json
index 325bccf9..ccebe4df 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
- "version": "1.0.48",
+ "version": "1.0.49",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json
index b4f0d52d..fa0dd251 100644
--- a/src/apps/cli/package.json
+++ b/src/apps/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
- "version": "1.0.48-cli",
+ "version": "1.0.49-cli",
"main": "dist/index.cjs",
"type": "module",
"engines": {
diff --git a/src/apps/webapp/package.json b/src/apps/webapp/package.json
index b536f173..b092a109 100644
--- a/src/apps/webapp/package.json
+++ b/src/apps/webapp/package.json
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
- "version": "1.0.48-webapp",
+ "version": "1.0.49-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
diff --git a/src/apps/webpeer/package.json b/src/apps/webpeer/package.json
index 4ea51b8c..8e724fe2 100644
--- a/src/apps/webpeer/package.json
+++ b/src/apps/webpeer/package.json
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
- "version": "1.0.48-webpeer",
+ "version": "1.0.49-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/updates.md b/updates.md
index e4d0f603..3a70fb54 100644
--- a/updates.md
+++ b/updates.md
@@ -12,6 +12,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased
+## 1.0.49
+
+11th November, 2026
+
### Changed
#### Encryption
diff --git a/versions.json b/versions.json
index 910a5dcd..132aa75d 100644
--- a/versions.json
+++ b/versions.json
@@ -59,5 +59,6 @@
"1.0.45": "1.7.2",
"1.0.46": "1.7.2",
"1.0.47": "1.7.2",
- "1.0.48": "1.7.2"
+ "1.0.48": "1.7.2",
+ "1.0.49": "1.7.2"
}
From 1642bab0ea2822299d0107486ed620f66f36c264 Mon Sep 17 00:00:00 2001
From: vorotamoroz
Date: Wed, 11 Nov 2026 11:37:28 +0000
Subject: [PATCH 3/3] Archive published 1.0.43 release notes
---
docs/releases/1.0.md | 57 ++++++++++++++++++++++++++++++++++++++++++++
updates.md | 57 --------------------------------------------
2 files changed, 57 insertions(+), 57 deletions(-)
diff --git a/docs/releases/1.0.md b/docs/releases/1.0.md
index d58f6596..68ad4d28 100644
--- a/docs/releases/1.0.md
+++ b/docs/releases/1.0.md
@@ -2,6 +2,63 @@
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
+## 1.0.43
+
+30th October, 2026
+
+### Synchronisation
+
+#### New Feature
+
+- We can avoid accidentally editing out-of-date notes while a large CouchDB catch-up is in progress. (#1241, #1275)
+ - Turn on **Enable SyncGuard** under **General Settings** → **Appearance** to cover the workspace until incoming changes have been received and applied to the Vault.
+ - Disabled by default. During CouchDB LiveSync mode start-up or resume, protection begins after a configurable number of distinct incoming notes is detected (default: 20).
+ - **Continue without waiting** releases the workspace, including after a failed or interrupted transfer. Synchronisation can continue, and some notes may still be out of date.
+ - The settings apply only to this device.
+ - Small catch-up transfers and ordinary live changes remain uninterrupted. Some changes can arrive before the guard activates; it does not prevent other plug-ins or external tools from editing files.
+
+#### Improved
+
+- We can tell that CouchDB synchronisation is progressing while it compares existing revisions, even before any files are uploaded. (#1293, #1294)
+ - Separate `⏩↑` and `⏩↓` counts show the change-feed entries compared for push and pull. These are comparison counts; the upload and download totals remain separate.
+ - Existing CSS snippets which target `livesync-stat-ff` to hide comparison progress need to target both `livesync-stat-ff-push` and `livesync-stat-ff-pull`. The status display guide includes updated examples.
+
+### Setup and recovery
+
+#### Fixed
+
+- We can choose how a new Join or scheduled Fetch handles our existing files, without silently reusing choices from an earlier Fetch. (#1244, #1296)
+ - Each new operation asks again, including after a previous Fetch changed the local database suffix. Fast Setup retains interrupted choices in an editable code block in the fetch flag.
+ - On restart, we confirm the current remote and saved choices, choose again, or cancel; a remaining flag never reapplies choices silently.
+ - If the saved selection cannot be read, we can choose new options for that attempt without repairing or rewriting the flag. Completing or cancelling the operation removes the whole flag.
+ - After updating during an interrupted Fetch, confirm the choices once more. Older separately stored choices are ignored; downloaded data and the existing checkpoint are not removed by this change.
+
+### Dialogues
+
+#### Fixed
+
+- Confirmation and setup actions are easier to follow, and keyboard focus remains visible. (#1297)
+ - Buttons align with the content above them, with room for confirmation buttons' focus outlines.
+- Mobile dialogues retain Obsidian's standard spacing and leave more usable space above the software keyboard. (#1297)
+
+### Translations
+
+- More settings, setup, recovery, and configuration messages are available in Simplified Chinese. (#1258)
+ - Expanded the translation catalogue to include previously untranslated messages and shared-library labels.
+
+### Miscellaneous
+
+- Docker setup now handles shell-sensitive characters in health-check credentials correctly, avoiding false authentication failures which could prevent CouchDB initialisation. (#1295)
+
+### Acknowledgements
+
+Thank you for your contributions!
+
+- @Akilaydin, for adding the optional SyncGuard workspace protection (#1275).
+- @ARCJ137442, for adding separate push and pull comparison progress displays (#1294).
+- @Jovlang, for correcting Docker health-check credential quoting and authenticated readiness checks (#1295).
+- @kannazuki-c, for expanding the Simplified Chinese translations (#1258).
+
## 1.0.42
27th October, 2026
diff --git a/updates.md b/updates.md
index 3a70fb54..d3218ea8 100644
--- a/updates.md
+++ b/updates.md
@@ -258,60 +258,3 @@ Please let me know if any problems arise from these changes.
Thank you for your contributions!
- @AdrianNicu, for improving decryption handling during database initialisation (Commonlib #191).
-
-## 1.0.43
-
-30th October, 2026
-
-### Synchronisation
-
-#### New Feature
-
-- We can avoid accidentally editing out-of-date notes while a large CouchDB catch-up is in progress. (#1241, #1275)
- - Turn on **Enable SyncGuard** under **General Settings** → **Appearance** to cover the workspace until incoming changes have been received and applied to the Vault.
- - Disabled by default. During CouchDB LiveSync mode start-up or resume, protection begins after a configurable number of distinct incoming notes is detected (default: 20).
- - **Continue without waiting** releases the workspace, including after a failed or interrupted transfer. Synchronisation can continue, and some notes may still be out of date.
- - The settings apply only to this device.
- - Small catch-up transfers and ordinary live changes remain uninterrupted. Some changes can arrive before the guard activates; it does not prevent other plug-ins or external tools from editing files.
-
-#### Improved
-
-- We can tell that CouchDB synchronisation is progressing while it compares existing revisions, even before any files are uploaded. (#1293, #1294)
- - Separate `⏩↑` and `⏩↓` counts show the change-feed entries compared for push and pull. These are comparison counts; the upload and download totals remain separate.
- - Existing CSS snippets which target `livesync-stat-ff` to hide comparison progress need to target both `livesync-stat-ff-push` and `livesync-stat-ff-pull`. The status display guide includes updated examples.
-
-### Setup and recovery
-
-#### Fixed
-
-- We can choose how a new Join or scheduled Fetch handles our existing files, without silently reusing choices from an earlier Fetch. (#1244, #1296)
- - Each new operation asks again, including after a previous Fetch changed the local database suffix. Fast Setup retains interrupted choices in an editable code block in the fetch flag.
- - On restart, we confirm the current remote and saved choices, choose again, or cancel; a remaining flag never reapplies choices silently.
- - If the saved selection cannot be read, we can choose new options for that attempt without repairing or rewriting the flag. Completing or cancelling the operation removes the whole flag.
- - After updating during an interrupted Fetch, confirm the choices once more. Older separately stored choices are ignored; downloaded data and the existing checkpoint are not removed by this change.
-
-### Dialogues
-
-#### Fixed
-
-- Confirmation and setup actions are easier to follow, and keyboard focus remains visible. (#1297)
- - Buttons align with the content above them, with room for confirmation buttons' focus outlines.
-- Mobile dialogues retain Obsidian's standard spacing and leave more usable space above the software keyboard. (#1297)
-
-### Translations
-
-- More settings, setup, recovery, and configuration messages are available in Simplified Chinese. (#1258)
- - Expanded the translation catalogue to include previously untranslated messages and shared-library labels.
-
-### Miscellaneous
-
-- Docker setup now handles shell-sensitive characters in health-check credentials correctly, avoiding false authentication failures which could prevent CouchDB initialisation. (#1295)
-
-### Acknowledgements
-
-Thank you for your contributions!
-
-- @Akilaydin, for adding the optional SyncGuard workspace protection (#1275).
-- @ARCJ137442, for adding separate push and pull comparison progress displays (#1294).
-- @Jovlang, for correcting Docker health-check credential quoting and authenticated readiness checks (#1295).
-- @kannazuki-c, for expanding the Simplified Chinese translations (#1258).