Reject unsupported WebApp identity and align family guidance

Reject explicit NFC settings before WebApp services start and when settings
are loaded or saved. Preserve Legacy interoperability, and clarify host
support and the Commonlib-first publication prerequisite.
This commit is contained in:
vorotamoroz committed 2026-11-03 08:44:31 +00:00
1 parent 6f8df8f731
commit 9920da8f94
9 files changed
+272 -38

No files matched your search

+10
View File
@@ -126,6 +126,16 @@ Shared synchronisation code is compiled and typed by the `@vrtmrz/livesync-commo
Changes spanning both repositories must first produce a packed Commonlib artefact which passes its standalone package checks. Install that exact artefact in LiveSync, then run the LiveSync type checks, unit tests, application builds, CLI E2E, and any focused real-Obsidian E2E required by the changed boundary. Replace the temporary artefact reference with the reviewed immutable package version before release.
When a change requires a new Commonlib entry point or behaviour, publish and
validate that immutable Commonlib version before releasing a dependent
application. Follow the [Commonlib release sequence](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/releasing.md#coordinating-package-and-application-releases),
then update this repository's exact `package.json` dependency and lockfile
integrity from the registry. The plug-in, CLI, WebApp, and WebPeer use that root
dependency; validate their affected builds and runtime boundaries against the
same artefact. Bridge has its own import map and lockfile and must be pinned
separately. Local tarball overlays are preparation evidence, not clean registry
installation evidence.
## Architecture
The [Project glossary](docs/glossary.md#developer-and-design-terms) defines the
+11 -5
View File
@@ -34,10 +34,15 @@ admission checks and supported transports.
The central replication gate, mismatch dialogues, and RedFlag Fetch preparation
consume the same effective differences. The P2P transport retains its separate
policy: ordinary representation differences warn without rejecting transfer,
while its existing passphrase and peer checks still apply. NFC path identity
is outside that warning-only policy: P2P refuses version `1`. Sharing assessment
does not make the central replication policy appropriate for every transport.
policy: ordinary representation and case differences warn without rejecting
transfer, while its existing passphrase and peer checks still apply. Path
identity has a separate required check: both peers must use the same version,
and a version mismatch rejects before replication. Commonlib `0.3.0` includes
NFC protocol support for Journal and P2P, but the matching CLI and Obsidian host
adoption remains an unreleased extension tracked by
[the Unicode path identity design](unicode_path_identity.md). Sharing
assessment does not make the central replication policy appropriate for every
transport.
## Host responsibilities
@@ -96,7 +101,8 @@ failed transfer into a completed transfer.
## Verification boundaries
Commonlib tests protect missing-value interpretation, representation differences,
directional consequences, immutable results, central admission, and P2P policy.
directional consequences, immutable results, central admission, and P2P policy,
including the distinction between ordinary warnings and a path-version mismatch.
LiveSync unit tests protect ordinary versus reconstruction choices, trial-setting
selection, partial-setting adoption, and invalidated decisions. RedFlag tests
protect the distinction between adopting settings for Fetch and retaining local
+30 -23
View File
@@ -22,20 +22,23 @@ and is included in current Commonlib `0.2.2`, used by Self-hosted LiveSync
`1.0.42`. This later handling is distinct from the initial NFC identity
release.
An unreleased extension adds Journal and P2P for the same maintained CLI and
Obsidian hosts, moves the setting to General Settings, and defaults new
synchronisation environments to NFC. The original dates
and reviewed versions above remain the historical baseline.
Commonlib `0.3.0` publishes the Journal and P2P path-identity protocol
extension. The matching Self-hosted LiveSync CLI and Obsidian host adoption,
including moving the setting to General Settings and defaulting new
synchronisation environments to NFC, remains an unreleased candidate tracked
by [pull request #1309](https://github.com/vrtmrz/obsidian-livesync/pull/1309).
The original dates and reviewed versions above remain the historical baseline.
## Unreleased shared storage boundary
The CLI adopts Commonlib's shared Unicode resolver and typed physical-I/O
ports. Each NFC scan retains one comparison-policy snapshot; directory and
The current LiveSync candidate adopts Commonlib's shared Unicode resolver and
typed physical-I/O ports in the CLI, and shared namespace and comparison
helpers in Obsidian while retaining its Vault APIs and existing alias-guard
policy. Each NFC scan retains one comparison-policy snapshot; directory and
permission errors propagate instead of producing a successful partial scan.
Obsidian uses the shared namespace and comparison helpers while retaining its
Vault APIs and existing alias-guard policy. The [developer guide](../../devs.md#file-path-handling)
links the Commonlib design and ADR. This extraction does not change Metadata
IDs, defaults, database migration, or the host support matrix below.
These additions are unreleased and do not change the support status of a
published host. The [developer guide](../../devs.md#file-path-handling) links
the Commonlib design and ADR.
## Scope
@@ -43,8 +46,8 @@ IDs, defaults, database migration, or the host support matrix below.
| --- | --- |
| Logical identity | Version `1` uses NFC for Metadata ID and path comparison keys. Existing case handling remains a separate rule. Missing and `0` retain legacy behaviour. |
| Stored paths and content | Keep the spelling in `Metadata.path` and existing physical Vault names. NFC does not change Chunk IDs or content. |
| Maintained hosts and transports | CLI and Obsidian with CouchDB; Journal and P2P are added by the unreleased extension. |
| Other consumers | Direct file access refuses a remote declaring the NFC feature. Other host adapters remain outside the support matrix. |
| Maintained hosts and transports | CLI and Obsidian with CouchDB are released; their Journal and P2P host adoption remains unreleased. See the support matrix below. |
| Other consumers | Direct-file access and other host adapters are covered separately below; shared path primitives alone do not enable NFC in an application. |
| Migration | Rebuild both databases from one complete authoritative Vault, then Fetch each remaining client. Do not rewrite IDs or merge histories in place. |
## Path identity contract
@@ -228,14 +231,15 @@ updates and deletions.
## Compatibility matrix
| Client and remote | Version `0` | Version `1` |
| Host or API | Published baseline | Unreleased or host-specific status |
| --- | --- | --- |
| CLI with CouchDB | Supported with legacy identity. | Supported with NFC path resolution. |
| Obsidian with CouchDB | Supported with legacy identity. | Supported with NFC path resolution. |
| CLI or Obsidian with Journal | Existing behaviour. | Added by the unreleased extension; Rebuild remote journals and Fetch other devices. |
| CLI or Obsidian with P2P | Existing behaviour. | Added by the unreleased extension; update and migrate every peer. |
| Direct file access | Existing behaviour. | Refuses a remote requiring `nfc-path-identity-v1`. |
| Other host adapters, including WebApp | Existing behaviour, subject to their existing support. | Outside the initial support matrix until physical-path handling is verified. |
| CLI and Obsidian with CouchDB | NFC identity is supported from Self-hosted LiveSync `1.0.38` with Commonlib `0.1.42`. | The shared resolver and typed path ports are in the current LiveSync candidate; they are not part of a published Commonlib package yet. |
| CLI and Obsidian with Journal | Commonlib `0.3.0` publishes the NFC protocol support. | Matching LiveSync host adoption remains an unreleased candidate tracked by [pull request #1309](https://github.com/vrtmrz/obsidian-livesync/pull/1309). Update and migrate every client before using version `1`. |
| CLI and Obsidian with P2P | Commonlib `0.3.0` publishes the NFC protocol support. | Matching LiveSync host adoption remains an unreleased candidate. All peers must use the same path version; P2P warns and continues for ordinary representation or case differences. |
| Bridge | The published baseline uses Legacy version `0`. | The Bridge integration is an unreleased candidate with explicit per-peer opt-in to version `1`; every peer in a group must use the same version. Its ordinary case mismatch check remains enabled unless the Bridge-wide `disableCheckingConfigMismatch: true` override disables that case check. The override does not bypass a path-version mismatch. |
| `DirectFileManipulator` | The published Commonlib baseline refuses a remote which declares `nfc-path-identity-v1`. | An unpublished addition accepts explicit `pathNormalizationVersion` values `0` or `1`, defaulting to `0`, and rejects a populated database in the opposite mode. This admission setting does not provide physical-path resolution. |
| WebApp | Legacy version `0` only. | WebApp remains Legacy-only and rejects an explicit version `1` at startup. The browser resolver primitive does not enable NFC in WebApp. |
| WebPeer | A vaultless P2P utility, not a filesystem host. | No NFC filesystem support is implied by its P2P role. |
| Older client without this feature | Can use a legacy database. | Cannot join a database declaring `nfc-path-identity-v1`. |
## Journal and P2P extension
@@ -291,8 +295,11 @@ The implementation acceptance matrix covers:
version-`0` clients against an NFC database, and enforcement when ordinary
mismatch warnings are disabled;
- URI round trips, checkpoint invalidation across path versions, and
rejection for Direct file access; Journal and P2P acceptance and identity
mismatch checks belong to the unreleased extension;
rejection by the published Direct file access baseline; the unpublished
`DirectFileManipulator` addition covers explicit Legacy/NFC admission.
Journal and P2P protocol support is published in Commonlib `0.3.0`, while
matching CLI and Obsidian host adoption and end-to-end checks remain
unreleased;
- refusal for ambiguous file or parent-directory candidates before local or
remote reset, preserving both databases on rejection, within the paths
returned by each host's supported listing; and
@@ -417,8 +424,8 @@ This design does not:
- choose among ambiguous NFC-equivalent files or parents, or merge their
content;
- automatically change existing settings or database identity;
- enable version `1` for Direct file access, WebApp, or an
unverified host adapter; or
- automatically enable version `1` for WebApp or an unverified host adapter;
or
- claim native iOS or macOS filesystem compatibility.
## References
+6 -1
View File
@@ -28,7 +28,12 @@ Automated browser coverage uses Chromium. Other browsers, background execution,
## Path identity compatibility
WebApp currently supports **Legacy path identity** (`pathNormalizationVersion: 0`) only. **Unicode NFC path identity** (`1`) is not yet supported. Use legacy identity in WebApp's `.livesync/settings.json` and on every client sharing its CouchDB database or P2P group, including new CLI and Obsidian environments which default to NFC.
WebApp currently supports **Legacy path identity** (`pathNormalizationVersion: 0`) only. **Unicode NFC path identity** (`1`) is not yet supported. WebApp rejects NFC settings before creating its services or opening its local database; it reports the reason and leaves the Vault files and settings unchanged. Use legacy identity in WebApp's `.livesync/settings.json` and on every client sharing its CouchDB database or P2P group, including new CLI and Obsidian environments which default to NFC.
The File System Access API can supply the directory listings and handles needed
for NFC resolution. Commonlib provides that adapter, but WebApp has not yet
integrated it with its file cache, inventory, and watcher behaviour. API-level
support does not enable this application boundary automatically.
Changing the setting alone does not migrate an existing NFC database. Migration back to legacy identity requires a coordinated Rebuild and Fetch from a complete, authoritative Vault. Back up the Vault and database, and stop synchronisation on all clients before migrating. Keep WebApp disconnected until the migration is complete. See [Recovery and flag files](../../../docs/recovery.md) before rebuilding.
+24 -5
View File
@@ -1,3 +1,4 @@
import { getPathNormalizationVersion } from "@vrtmrz/livesync-commonlib/settings";
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
/** Browser runtime for Self-hosted LiveSync over the File System Access API. */
@@ -55,6 +56,14 @@ const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
autoSweepPluginsPeriodic: false,
};
function assertWebAppPathIdentity(settings: Partial<ObsidianLiveSyncSettings> | null): void {
if (getPathNormalizationVersion(settings ?? {}) === 1) {
throw new Error(
"WebApp supports Legacy path identity (pathNormalizationVersion: 0) only. Unicode NFC path identity is not supported."
);
}
}
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
export interface WebAppRuntimeOptions {
@@ -100,6 +109,7 @@ export class WebAppRuntime {
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
settings: {
save: async (data) => {
assertWebAppPathIdentity(data);
try {
await this.saveSettingsToFile(data);
this.addLog("Saved to .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
@@ -108,15 +118,17 @@ export class WebAppRuntime {
}
},
load: async () => {
let data: Partial<ObsidianLiveSyncSettings> | null = null;
try {
const data = await this.loadSettingsFromFile();
if (data) {
this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
}
data = await this.loadSettingsFromFile();
} catch {
this.addLog("Failed to load settings; using defaults", LOG_LEVEL_NOTICE, "settings");
}
assertWebAppPathIdentity(data);
if (data) {
this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
}
return DEFAULT_SETTINGS as ObsidianLiveSyncSettings;
},
},
@@ -205,6 +217,13 @@ export class WebAppRuntime {
throw new Error("The WebApp runtime has already been started");
}
try {
assertWebAppPathIdentity(await this.loadSettingsFromFile());
} catch (error) {
this.showError(`Failed to start: ${String(error)}`);
throw error;
}
// Create service context and hub
this.serviceHub = createLiveSyncBrowserServiceHub<ServiceContext>(this.createServiceOptions());
this.addLog("Self-hosted LiveSync WebApp", LOG_LEVEL_INFO, "initialise");
@@ -167,6 +167,76 @@ describe("WebAppRuntime lifecycle", () => {
runtimeMocks.updateToDatabase.mockResolvedValue(undefined);
});
function settingsRoot(settings: Partial<ObsidianLiveSyncSettings>): FileSystemDirectoryHandle {
return {
name: "identity-test-vault",
getDirectoryHandle: async () => ({
getFileHandle: async () => ({
getFile: async () => new File([JSON.stringify(settings)], "settings.json"),
}),
}),
} as unknown as FileSystemDirectoryHandle;
}
it.each([undefined, 0] as const)("starts with supported legacy identity %s", async (version) => {
const runtime = new WebAppRuntime(settingsRoot({ pathNormalizationVersion: version }), {
deviceValues: new MemoryValueStore(),
});
await runtime.start();
expect(runtimeMocks.onLoad).toHaveBeenCalledOnce();
expect(runtimeMocks.scanDirectory).toHaveBeenCalledOnce();
await runtime.shutdown();
});
it.each([1, 2] as const)(
"rejects unsupported identity %s before constructing services or starting file work",
async (version) => {
const reportStatus = vi.fn();
const runtime = new WebAppRuntime(settingsRoot({ pathNormalizationVersion: version as 1 }), {
reportStatus,
deviceValues: new MemoryValueStore(),
});
await expect(runtime.start()).rejects.toThrow(/path identity|path normalisation/i);
expect(runtimeMocks.options).toBeUndefined();
expect(runtimeMocks.onLoad).not.toHaveBeenCalled();
expect(runtimeMocks.onReady).not.toHaveBeenCalled();
expect(runtimeMocks.scanDirectory).not.toHaveBeenCalled();
expect(runtimeMocks.collectFilesOnStorage).not.toHaveBeenCalled();
expect(runtimeMocks.updateToDatabase).not.toHaveBeenCalled();
expect(reportStatus).toHaveBeenCalledWith(
"error",
expect.stringMatching(/path identity|path normalisation/i)
);
}
);
it("rejects NFC during settings reload instead of substituting defaults", async () => {
const runtime = new WebAppRuntime(settingsRoot({ pathNormalizationVersion: 1 }), {
deviceValues: new MemoryValueStore(),
});
const options = (
runtime as unknown as { createServiceOptions(): LiveSyncBrowserServiceHubOptions<never> }
).createServiceOptions();
if (!options.settings) throw new Error("The runtime must provide settings callbacks");
await expect(options.settings.load()).rejects.toThrow(/WebApp supports Legacy path identity/);
});
it("rejects saving unsupported NFC settings before opening the settings directory", async () => {
const getDirectoryHandle = vi.fn();
const runtime = new WebAppRuntime(
{ name: "identity-test-vault", getDirectoryHandle } as unknown as FileSystemDirectoryHandle,
{ deviceValues: new MemoryValueStore() }
);
const options = (
runtime as unknown as { createServiceOptions(): LiveSyncBrowserServiceHubOptions<never> }
).createServiceOptions();
if (!options.settings) throw new Error("The runtime must provide settings callbacks");
await expect(
options.settings.save({ pathNormalizationVersion: 1 } as ObsidianLiveSyncSettings)
).rejects.toThrow(/WebApp supports Legacy path identity/);
expect(getDirectoryHandle).not.toHaveBeenCalled();
});
it("owns core start, directory scan, status reporting, and shutdown", async () => {
const reportStatus = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });
+1 -1
View File
@@ -6,7 +6,7 @@ Application unit tests are stored in `test/apps/webapp/` and `test/apps/webpeer/
Each application has its own production-bundle smoke-test directory outside `src`:
- `test/browser-apps/webapp/` covers Vault selection, OPFS start-up, and isolation between optional P2P settings and the main remote.
- `test/browser-apps/webapp/` covers Vault selection, Legacy OPFS start-up, isolation between optional P2P settings and the main remote, and explicit NFC refusal before database, scan, file-write, or remote activity. The refusal case retains the settings and native filename content.
- `test/browser-apps/webpeer/` covers start-up, settings persistence, and reload.
- `test/browser-apps/pages/` covers the final GitHub Pages layout, application subpaths, and relative assets.
+116 -3
View File
@@ -112,9 +112,7 @@ Deno.test({
"WebApp must not render a peer-menu action without a menu capability"
);
await runtimePage.locator(".p2p-control input[type='checkbox']").first().check();
await runtimePage
.getByPlaceholder("wss://exp-relay.vrtmrz.net, wss://xxxxx")
.fill("ws://127.0.0.1:4010/");
await runtimePage.getByPlaceholder("wss://exp-relay.vrtmrz.net, wss://xxxxx").fill("ws://127.0.0.1:4010/");
await runtimePage.getByPlaceholder("anything-you-like").fill("browser-e2e-room");
await runtimePage.getByPlaceholder("password").fill("browser-e2e-passphrase");
await runtimePage.getByPlaceholder("iphone-16").fill("browser-e2e-webapp");
@@ -159,3 +157,118 @@ Deno.test({
}
},
});
Deno.test({
name: "WebApp: rejects NFC settings before opening a database, scanning, writing, or synchronising",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(webAppDist);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const assertNoFailures = observePageFailures(page);
await page.addInitScript(`
Object.defineProperty(globalThis, "showDirectoryPicker", {
configurable: true,
value: async () => {
const originRoot = await navigator.storage.getDirectory();
const root = await originRoot.getDirectoryHandle("livesync-webapp-nfc-refusal", { create: true });
const settingsDirectory = await root.getDirectoryHandle(".livesync", { create: true });
const settingsText = JSON.stringify({
pathNormalizationVersion: 1, isConfigured: true, liveSync: true,
couchDB_URI: "https://couchdb.example.invalid", couchDB_DBNAME: "nfc-refusal-test"
});
const settingsWriter = await (await settingsDirectory.getFileHandle("settings.json", { create: true })).createWritable();
await settingsWriter.write(settingsText);
await settingsWriter.close();
const sentinelWriter = await (await root.getFileHandle("Résumé.md".normalize("NFD"), { create: true })).createWritable();
await sentinelWriter.write("retained native content");
await sentinelWriter.close();
const audit = globalThis.webAppIdentityAudit = {
settingsText, writes: 0, scans: 0, opens: [], remoteRequests: [],
databaseNamesBefore: (await indexedDB.databases()).map(item => item.name).sort()
};
const open = IDBFactory.prototype.open;
IDBFactory.prototype.open = function(...args) {
audit.opens.push(String(args[0]));
return Reflect.apply(open, this, args);
};
const createWritable = FileSystemFileHandle.prototype.createWritable;
FileSystemFileHandle.prototype.createWritable = function(...args) {
audit.writes++;
return Reflect.apply(createWritable, this, args);
};
for (const key of ["entries", "values", Symbol.asyncIterator]) {
const original = FileSystemDirectoryHandle.prototype[key];
if (typeof original !== "function") continue;
FileSystemDirectoryHandle.prototype[key] = function(...args) {
audit.scans++;
return Reflect.apply(original, this, args);
};
}
const fetch = globalThis.fetch;
globalThis.fetch = function(input, ...args) {
const url = String(input?.url ?? input);
if (url.startsWith("https://couchdb.example.invalid")) {
audit.remoteRequests.push(url);
return Promise.resolve(new Response("{}", { status: 503 }));
}
return Reflect.apply(fetch, this, [input, ...args]);
};
return root;
}
});
`);
await page.goto(new URL("webapp.html", server.baseUrl).href);
await page.locator("#status").filter({ hasText: "Select a vault folder" }).waitFor();
await page.getByRole("button", { name: "Choose new vault folder" }).click();
await page.locator("#status.error").filter({ hasText: "WebApp supports Legacy path identity" }).waitFor();
const state = await page.evaluate(async () => {
const globals = globalThis as typeof globalThis & {
webAppIdentityAudit: {
settingsText: string;
writes: number;
scans: number;
opens: string[];
remoteRequests: string[];
databaseNamesBefore: Array<string | undefined>;
};
livesyncApp: { getRuntime(): unknown };
};
const audit = globals.webAppIdentityAudit;
const root = await (
await navigator.storage.getDirectory()
).getDirectoryHandle("livesync-webapp-nfc-refusal");
const settingsDirectory = await root.getDirectoryHandle(".livesync");
return {
runtime: globals.livesyncApp.getRuntime(),
writes: audit.writes,
scans: audit.scans,
appDatabaseOpens: audit.opens.filter((name) => name !== "livesync-webapp-handles"),
remoteRequests: audit.remoteRequests,
databaseNamesBefore: audit.databaseNamesBefore,
databaseNamesAfter: (await indexedDB.databases()).map((item) => item.name).sort(),
settingsUnchanged:
(await (await (await settingsDirectory.getFileHandle("settings.json")).getFile()).text()) ===
audit.settingsText,
sentinel: await (await (await root.getFileHandle("Résumé.md".normalize("NFD"))).getFile()).text(),
};
});
assertEquals(state.runtime, null);
assertEquals(state.writes, 0);
assertEquals(state.scans, 0);
assertEquals(state.appDatabaseOpens, []);
assertEquals(state.remoteRequests, []);
assertEquals(state.databaseNamesAfter, state.databaseNamesBefore);
assertEquals(state.settingsUnchanged, true);
assertEquals(state.sentinel, "retained native content");
assertEquals(await page.locator("#vault-selector").isVisible(), true);
assertEquals(await page.getByRole("button", { name: "Scan local files", exact: true }).count(), 0);
assertNoFailures();
} finally {
await browser.close();
await server.close();
}
},
});
+4
View File
@@ -22,6 +22,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
- Pending conflict dialogues and their resolution steps stop using the released logger after the plug-in is disabled. Waiting continuations check for shutdown before logging or starting further resolution work.
#### WebApp
- The experimental WebApp stops before opening its database or processing files when its settings select unsupported NFC path identity. It reports the Legacy-only requirement and preserves the selected Vault files and settings. This does not migrate an NFC database to Legacy identity.
#### Translations
- The notice shown when a translation is available now displays the **Display Language** setting label correctly in English, French, Hebrew, Russian, and Simplified Chinese. (#1298) Thank you to @cwatanab for the contribution!