diff --git a/docs/adr/2026_07_release_notes_and_database_compatibility.md b/docs/adr/2026_07_release_notes_and_database_compatibility.md index 4985008d..5fa07af2 100644 --- a/docs/adr/2026_07_release_notes_and_database_compatibility.md +++ b/docs/adr/2026_07_release_notes_and_database_compatibility.md @@ -45,17 +45,17 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex - Continue to use the internal database version `VER` for changes which require explicit compatibility review. Changing the plug-in SemVer alone does not increment `VER`. - Store the last acknowledged internal database version through Commonlib's device-local small-configuration contract under `database-compatibility-version`. Copy the legacy raw local-storage value into that contract once, then remove the legacy key after the copy has completed. -- Initialise the marker to the current `VER` only when Commonlib identifies a genuinely new Vault with no pending review. A configured existing Vault with a missing or invalid marker requires review instead of being silently accepted. +- Initialise an absent marker to the current `VER` when no other compatibility reason requires review. This applies to both new and configured existing Vaults. An invalid marker still requires review. - Defer database compatibility evaluation for an existing unconfigured Vault. It cannot replicate, so do not persist a misleading pause or acknowledge its missing marker while onboarding is still pending. Keep the marker absent so a later configured start evaluates the same state before ordinary synchronisation. -- Treat a missing marker on a configured Vault as an ambiguous device transition. Copying or restoring a Vault, or opening it with a new Obsidian profile, can preserve settings and database files without preserving device-local storage. Do not infer acknowledgement from an empty local database: a recovery operation, partial copy, or remote-first setup can also produce that state. Explain these cases and require an explicit decision in the compatibility dialogue. +- A missing device-local marker alone is not evidence of incompatibility. A new device, a copied or restored Vault, a new Obsidian profile, or settings imported into another database namespace may have no marker. Initialise it without opening a review when all other checks permit this. Keep remote format, feature, and settings checks independent of this local baseline. - Derive one structured pause from the acknowledged database version, Commonlib's settings-migration state, and any persisted legacy review message. Persist the generic `versionUpFlash` message without changing any automatic synchronisation setting, because Commonlib already treats that field as a replication gate. - Treat non-empty `versionUpFlash` as a runtime replication gate. Standard and one-shot replication must stop before remote work begins. - Apply the same ordinary replication policy to P2P pull, push, and peer-requested synchronisation. An explicitly confirmed Fetch or Rebuild may bypass the ordinary policy because it is the operation selected to construct or recover the local state. - Present the reason in a dedicated dialogue after the Obsidian layout is ready. The details view is explanatory only and returns to the summary before any decision can be made. The safe default and closing either dialogue keep synchronisation paused. A persistent Notice and a command allow the dialogue to be reopened without using the settings pane. - Let the person read focused compatibility details without presenting the whole release history as a safety instruction. The Change Log remains a manually opened release-history pane and contains no compatibility acknowledgement control. -- Offer an explicit resume action only when every reason is recoverable in the running implementation. An upgrade, a missing or invalid marker on an existing Vault, and a reviewed migration from an older settings schema are resumable after all devices have been updated. A downgrade from a newer acknowledged `VER`, or settings saved by a future schema, cannot be acknowledged by the older installation. +- Offer an explicit resume action only when every reason is recoverable in the running implementation. An upgrade, an invalid marker on an existing Vault, and a reviewed migration from an older settings schema are resumable after all devices have been updated. A downgrade from a newer acknowledged `VER`, or settings saved by a future schema, cannot be acknowledged by the older installation. - On resume, clear `versionUpFlash` and persist that fail-closed change before recording the current `VER` as acknowledged. If saving fails, restore the gate. Reapply settings only after the marker has advanced so that the previously configured synchronisation behaviour can resume without reconstruction. -- Preserve the original legacy review message as a structured reason when no more specific database or settings-schema reason is available. Escape it before including it in Markdown UI. +- Preserve the original legacy review message as a structured reason when no more specific database or settings-schema reason is available. This includes a previously saved generic pause: its original cause cannot be inferred from an absent marker, so it still requires explicit resume. Escape it before including it in Markdown UI. - Continue to reject a remote version document which is newer than the running implementation. That receiver-side check is independent of the local upgrade review. - From remote generation 13, assess the `used_features` list in that document as a separate compatibility dimension. A client must recognise every listed feature before it interprets the database or runs maintenance which depends on Metadata. Declare a feature before writing its representation, and retain the declaration while older data may depend on it. An unknown identifier is reported as text without requiring a descriptive label in that client. - Do not advance the device-local `VER` acknowledgement merely because a remote feature is introduced. The remote generation and its feature list govern remote admission; `VER` remains the local compatibility review gate. Connecting to a generation-12 database does not promote it solely because the client understands generation 13. @@ -78,7 +78,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex - Preserve the existing ordered flag-file recovery handlers: SCRAM at priority 5, fetch-all at priority 10, and rebuild-all at priority 20. These files express an explicit recovery instruction and may invoke their focused storage or rebuild service while ordinary replication remains gated. - Present the compatibility review at priority 30, after any selected recovery operation. A recovery handler which cancels start-up, keeps SCRAM active, or schedules a restart returns `false`, so the current process does not open a competing compatibility dialogue. If recovery completes and start-up continues, the dialogue opens before normal synchronisation is allowed to resume. - Keep database preparation independent of an unanswered compatibility dialogue, because the compatibility gate already blocks replication. Before Config Doctor begins its interactive checks, await the active initial review so that the two update dialogues cannot overlap. -- Never mark compatibility as acknowledged merely because fetch, rebuild, or local database reset completed. The person must still use the explicit resume action. This keeps destructive recovery intent separate from protocol and settings compatibility acknowledgement. +- Never acknowledge a pending compatibility review merely because Fetch, Rebuild, or local database reset completed. The person must still use the explicit resume action for that review. This keeps destructive recovery intent separate from protocol and settings compatibility acknowledgement. ## Consequences @@ -87,7 +87,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex - An internal compatibility change remains fail-closed for replication, but it no longer destroys the person's synchronisation preferences. - A new installation has no previous internal-version marker and therefore does not show an upgrade review. Its initial settings and onboarding remain responsible for keeping replication disabled until configuration is complete. - An existing unconfigured installation also remains on onboarding without a compatibility warning. Unlike a genuinely new Vault, it does not receive an acknowledgement marker; activation leaves the compatibility decision for its next configured start. -- A copied or restored configured Vault can show a one-time compatibility review on its new device or profile. This is intentional even when its local database appears empty, because emptiness does not prove how the Vault was produced. +- A copied or restored configured Vault starts without a review when its device-local marker is absent and no other review is pending. Known version differences, invalid markers, settings-schema issues, and saved reviews still require the appropriate action. - A genuinely new Vault receives current recommendations without applying them as fallbacks to an existing configuration. It remains inert until onboarding is accepted. - Accepted new-device and existing-device setup cannot enable ordinary processing before the selected Rebuild or Fetch has been reserved. - An older installation cannot dismiss evidence that a newer implementation or settings schema has already been used on the device. @@ -96,11 +96,11 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex ## Verification -- Unit tests verify new-Vault initialisation, upgrades, missing and invalid markers, downgrades, future settings schemas, legacy marker migration, acknowledgement ordering, and save-failure recovery while retaining automatic synchronisation choices. +- Unit tests verify initialisation of missing markers in new and existing Vaults, upgrades, invalid markers, downgrades, future settings schemas, retained earlier reviews, legacy marker migration, acknowledgement ordering, and save-failure recovery while retaining automatic synchronisation choices. - Commonlib package tests verify conservative stored-setting completion, independently mutable new-Vault settings, legacy file-name case normalisation, future-schema protection, and the focused settings entry from a clean consumer. - Host unit tests verify new-Vault factory use, conservative import paths, the unconfigured start-up gate, deferred compatibility evaluation and later re-evaluation, flag-before-settings ordering, rollback when the flag cannot be reserved, ordinary configured edits, and compatibility acknowledgement persistence. - Unit tests verify that a pending review is honoured by the packaged Commonlib replication service before remote activity begins. - Unit and Compose tests verify that ordinary P2P replication observes the policy, explicit P2P rebuild uses the setup bypass, and replacement leaves host actions on the current replicator. - A real-Obsidian settings test verifies the dedicated summary and details dialogues, captures representative screenshots, confirms that the acknowledged internal version advances only after explicit resume, and confirms that the Change Log contains no acknowledgement control. -- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies the copied-or-restored Vault explanation, resumes through the actual dialogue, and then completes remote metadata, chunk, and activity checks. The two-Vault workflow performs the same review once per isolated Vault before reusing the acknowledged device state for later process launches. +- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies that it is initialised without a pause, and completes remote Metadata, Chunk, and activity checks. The two-Vault workflow checks the same automatic initialisation and reuses the profile state on later launches. The Object Storage Setup URI and QR workflows complete Fetch and a two-device round trip without an acknowledgement action, and check the marker after restart. The QR fixture carries a distinct database suffix and checks its namespace and marker before Fetch resets the local database using the receiving device's own suffix. - Unit tests fix configured Vault admission at priority 1, the three flag-file recovery priorities at 5, 10, and 20, and compatibility review at priority 30. A recovery which stops start-up therefore cannot race the compatibility dialogue. diff --git a/docs/design_docs/internal_metadata_encryption.md b/docs/design_docs/internal_metadata_encryption.md index 5c3c9cf6..e7d16ec6 100644 --- a/docs/design_docs/internal_metadata_encryption.md +++ b/docs/design_docs/internal_metadata_encryption.md @@ -132,6 +132,13 @@ Keep focused tests for settings defaults and imports, the Doctor condition matrix, acceptance and dismissal, connection replacement, and absence of an automatic Rebuild, Fetch, or restart for this rule. +Exercise the Doctor choices in real Obsidian as well: decline the consultation, +skip the recommendation with a reminder, dismiss the current Doctor version, +and reopen it through **Run Doctor** to accept. Restart the same Vault and +profile between choices to verify persistence and whether the consultation +reappears. Preserve the local database and existing remote documents throughout +acceptance, then verify that subsequent writes encrypt internal Metadata. + Keep unit tests for known and unknown feature notifications, generic identifier presentation, retirement without a circular wait, and the unchanged snapshot behaviour after KV failure or obsolete snapshot fields. The previous batch diff --git a/docs/design_docs/time_bound_setup_uri.md b/docs/design_docs/time_bound_setup_uri.md new file mode 100644 index 00000000..bf5ceef6 --- /dev/null +++ b/docs/design_docs/time_bound_setup_uri.md @@ -0,0 +1,425 @@ +--- +date: 2026-09-28 +commonlib-version: "0.1.33" +feasibility-probe-version: "0.1.27" +self-hosted-livesync-version: "1.0.32" +status: implementing +--- + +# Time-bound Setup URIs + +## Purpose and status + +Offer two modes when generating a [Setup URI](../glossary.md): + +- **Ephemeral**, selected by default, derives an effective passphrase from the + entered passphrase and the current fixed time window. +- **Persistent** uses the entered passphrase and the existing URI format + unchanged. It also serves as the compatibility mode for older readers. + +The generation mode choice shows the exact time until which an Ephemeral URI +can be opened through the ordinary reader. Import still requires only the URI and +the entered passphrase. Neither the mode nor a timestamp is stored in the URI. + +The design is technically feasible with the existing encryption primitives. +An executable protocol probe passes 22 cases against Commonlib 0.1.27 and +octagonal-wheels 0.1.54. The implementation was first published as Commonlib 0.1.32 +on the npm `next` tag. LiveSync now pins Commonlib 0.1.33 from that tag in its npm +dependency and independent Deno tool imports. A mobile performance bound remains +outstanding. + +This document records the proposed key derivation and integration contract. The +companion probe demonstrates them without changing an application entry point. + +## Scope + +The first version offers Ephemeral and Persistent only. Ephemeral uses one +fixed seven-day window shared by every implementation. Arbitrary start dates, +custom end dates, selectable window lengths, and rolling seven-day lifetimes +are outside this version. + +Keep the current settings filtering, remote profiles, main and P2P selections, +and short/full export variants. Time binding affects opening the exported +settings; it does not alter Vault encryption, remote credentials, replication, +or settings already imported by another device. + +The existing `settingsQR` format and QR aggregator are separate sharing paths. +They remain outside this design and must not be presented as time-bound. + +## Time window + +Use Unix milliseconds and the following protocol constants: + +```text +windowMilliseconds = 604800000 +windowNumber = floor(nowMilliseconds / windowMilliseconds) +windowStart = windowNumber * windowMilliseconds +usableUntil = (windowNumber + 1) * windowMilliseconds +``` + +The anchor is the Unix epoch, `1970-01-01T00:00:00Z`. Consequently, boundaries +fall on Thursdays at 00:00 UTC. This is a protocol convention, independent of +the device's locale, time zone, daylight-saving rules, and calendar week. +The anchor and window length are part of the derivation profile. Changing them +requires an explicit compatibility design; there is no visible version field +from which an importer can discover a different time condition. + +For example, a URI generated at `2026-09-28T12:00:00Z` belongs to the window +`[2026-09-24T00:00:00Z, 2026-10-01T00:00:00Z)`. Ordinary import rejects it at +the end instant. Generating it one second before that instant leaves one +second of availability. The UI must not describe this as seven days from +generation. + +Use safe, non-negative integer timestamps without 32-bit coercion. The +reader tries only its current window. There is no previous-window allowance, +future-window search, modulo wraparound, or grace period. Devices whose clocks +fall on different sides of a boundary can disagree; local date formatting +does not affect the calculation. + +## Passphrase derivation and encryption + +The time factor follows the counter construction used by +[TOTP, RFC 6238 section 4.2](https://www.rfc-editor.org/rfc/rfc6238.html#section-4.2). +This is a time-bound encryption format, not a TOTP authentication protocol: +there is no six-digit code, trusted validation server, or single-use state. + +Reuse Commonlib's current `encryptString` and `decryptString` path. It uses +octagonal-wheels' salted PBKDF2/HKDF and AES-GCM implementation. Persistent +passes the entered passphrase directly to that path. Only Ephemeral applies +a full-length transformation: + +```text +if mode == "persistent": + effectivePassphrase = enteredPassphrase +else: + hmacKey = SHA-256(UTF-8(enteredPassphrase)) + context = JSON.stringify([ + "livesync/setup-uri", + "tb1", + "ephemeral", + windowNumber + ]) + effectivePassphrase = lowercaseHex(HMAC-SHA-256(hmacKey, UTF-8(context))) +encryptedSettings = encryptString(preparedSettingsJSON, effectivePassphrase) +``` + +The mode strings are exactly `ephemeral` and `persistent`. JSON encoding, +field order, lowercase hexadecimal, and the domain/profile strings are part +of the proposed Ephemeral derivation. `tb1` is an internal derivation identifier; +it is not a URI prefix or stored field. Persistent applies no new transformation. + +The initial SHA-256 provides a fixed-length HMAC key, including for an empty +low-level input. It does not replace password stretching: the existing +encryption function still performs PBKDF2. Do not trim or normalise the +entered passphrase within this transformation. Existing host requirements for +a non-empty passphrase remain in force; this design adds no new password +policy. + +At the assessed dependency versions, the encryption path uses PBKDF2-SHA-256 +with 310,000 iterations, HKDF-SHA-256, and AES-256-GCM with a 128-bit tag. Its +`%$` payload includes the material needed for decryption. The PBKDF2 salt may +be reused within a session; the IV and HKDF salt are generated for each +encryption. Retain these existing semantics rather than claiming a new +per-URI PBKDF2 salt or introducing another encryption implementation. + +For the synthetic passphrase `test-passphrase`, fixed derivation vectors are: + +```text +context: ["livesync/setup-uri","tb1","ephemeral",1234] +effectivePassphrase: b39361c51f0b7bd835554db1dffbc9a540fb30789aa62bf53e39e07d1073013b + +mode: persistent +effectivePassphrase: test-passphrase +``` + +The probe checks the Ephemeral Web Crypto output against a value calculated +separately through Node's SHA-256 and HMAC interface, and checks that Persistent +retains the exact input. Encryption remains randomised; these vectors specify +the effective passphrase, not the complete URI. + +This composition needs Commonlib changes but no new cryptographic dependency +or remote service. The prototype verifies interoperability with the existing +encryption path; it is not an independent cryptographic audit. + +## URI format and decoding + +Keep the existing URI prefix and encrypted representation without a new marker: + +```text +obsidian://setuplivesync?settings= +``` + +Both modes use the existing `%$` encrypted representation. There is no mode +flag, timestamp, window number, duration, derivation-version marker, or separate +query parameter. For identical prepared settings, both modes have the same +binary layout and ciphertext length. Percent-encoded text length may vary with +the random ciphertext; equal URI text length is not a requirement. + +Persistent delegates to the existing encoder with the entered passphrase and +the same settings-filtering options. A new marker or a transformed Persistent +passphrase would defeat older readers. Ephemeral uses the same external format +to preserve mode hiding. The protocol handler continues to reconstruct the URI +from the existing `settings` query value. + +For a `%$` payload, including an existing URI made before this feature, the +updated decoder: + +1. validates the envelope and captures the current window; +2. derives the Ephemeral candidate for that window and takes the entered + passphrase unchanged as the Persistent candidate; +3. attempts authenticated decryption with both candidates, without returning + early after the first success; +4. checks the current window again before returning an Ephemeral result; and +5. returns settings only if exactly one candidate is accepted. + +Keep expected candidate failures internal. Two failed candidates produce one +generic opening failure. A wrong passphrase, an out-of-window Ephemeral URI, +and damaged authenticated ciphertext do not receive different user messages. +There is no information from which to report a definite historical end time +for an unreadable URI. + +The raw-passphrase attempt is an intentional part of the two-candidate reader. +An Ephemeral URI from another window still fails both candidates with the +entered passphrase; this attempt does not remove its time condition. There is +no plaintext recovery, time-window search, or additional key fallback. + +Older encryption representations other than `%$` retain their existing +legacy-only decoder and passphrase handling. Unsupported prefixes remain +unsupported. Malformed `%$` payloads fail authentication with both candidates. +Bound time-bound candidate work to two attempts; the existing decoder continues +to own any historical encryption-format compatibility trials. + +Because the derivation profile is not stored, an older reader cannot identify +an Ephemeral URI and give an upgrade-specific error. It reports a decryption +failure. A future change to the Ephemeral profile must define its bounded +candidate policy explicitly; the reader must not infer arbitrary profiles or +scan them without a limit. + +Two trials make the amount of cryptographic work predictable, but do not +constitute a constant-time implementation. This design does not claim to hide +the mode from a caller who can instrument the decoder and knows the passphrase. + +## Generation and import interaction + +Keep the existing passphrase prompt. After it, use the existing confirmation +dialogue to choose Time-bound (the Ephemeral encoder mode) or Compatible (the +Persistent encoder mode). Select Time-bound by default, show its exact absolute +end in the device's local time zone with its time-zone name or UTC offset, and +explain that this is the end of a fixed UTC window rather than seven days from +generation. Compatible has no time condition and remains readable by older +clients. No synchronised setting or permanent Vault preference is needed. + +Commonlib exposes the current window end for this pre-generation choice. +Compare it with the `usableUntil` returned alongside the generated URI. If +the window changed during selection or encryption, show a fresh choice with +the new end before opening the existing copy dialogue. Persistent generation +does not consult the clock and returns `null` for `usableUntil`. + +The existing copy dialogue does not monitor the clock. If it stays open across +the boundary, it can still display and copy a URI whose window has ended; +the reader will reject that URI. The end time was shown at the mode choice. +The copy dialogue does not claim a rolling week or silently switch to +Compatible. + +Import requires no mode selector or date input. Recheck the window at the +end of decryption so that an Ephemeral operation crossing the boundary does +not return settings. Once settings have been returned successfully, subsequent +confirmation, setup, and synchronisation do not remain time-bound: the URI +has already been opened. This is an import boundary, not remote revocation. + +## Security and privacy boundary + +The hidden mode is an external-format property. A passive holder of the URI +does not receive an explicit time condition or Persistent marker. Randomised +encryption and the unchanged prepared settings representation avoid adding +a mode-dependent field or length difference. + +The clock and reader are controlled by the receiving device. Anyone with the +correct passphrase can reproduce historical candidates, modify the reader, +or change the supplied clock. Including Persistent adds one possible +candidate; it does not make a small calendar search cryptographically hard. +Time binding adds no independent secret and is not a substitute for a strong +Setup URI passphrase. + +The same URI can be opened repeatedly within its window. A replayed clock +earlier than generation, but inside that same window, also derives the same +key. Successfully copied settings and remote credentials remain usable after +the window ends, subject to their own remote policies. + +No URI, entered or effective passphrase, derived key, decrypted settings, or +candidate-specific authentication result should enter ordinary logs or reports. +The generation screen necessarily reveals the selected mode and end time to +its user; hiding information in the URI does not hide that screen or a +separately shared message. + +## Ownership and compatibility + +Commonlib owns preparation of exported settings, the time-window calculation, +passphrase derivation, format parsing, and dual-candidate decoding. Make these +changes in the Commonlib repository. LiveSync must consume a validated packed +artefact and then an exact reviewed package version, following +[the Commonlib dependency workflow](../../devs.md#commonlib-dependency). + +Preserve the existing positional encoder API. Its third and fourth arguments +already mean properties to remove and whether to omit default values. Add a +separate encoder, provisionally `encodeTimeBoundSetupURI`, with an options +object containing the mode and the existing export options. Its result is: + +```typescript +type TimeBoundSetupURIResult = { + uri: string; + usableUntil: number | null; +}; +``` + +The new encoder defaults to Ephemeral. Preserve the old encoder's legacy +behaviour for callers which have not migrated. Extend +`decodeSettingsFromSetupURI` to try both candidates for `%$` while retaining its +settings return contract and historical format handling. Persistent generation +through the new API delegates to the old encoder. Time injection belongs in +internal test seams, not an import option exposed to users. + +| Surface | Required work | +| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Obsidian generation](../../src/serviceFeatures/setupObsidian/setupUri.ts) | Reuse the passphrase and copy dialogues, add a mode choice with the exact current window end, and use the new encoder for all three copy variants and the copy event. Preserve each variant's export filters. | +| [Obsidian import](../../src/modules/features/SetupWizard/dialogs/UseSetupURI.svelte) | Consume the updated decoder and retain one authentication failure message. The existing prefix check indicates URI shape, not successful authentication. | +| [Protocol handler](../../src/serviceFeatures/setupObsidian/setupProtocol.ts) | Keep the existing `settings` reconstruction; test encoded delimiters for both modes. | +| [CLI setup](../../src/apps/cli/commands/runCommand.ts) | Consume the updated decoder and leave settings unchanged after any rejection. | +| [WebPeer generator](../../src/apps/webpeer/src/P2PCheckSetup.ts) | Choose a mode explicitly and return availability to its result UI; keep Ephemeral as the default. | +| [Setup utility](../../utils/setup/generate_setup_uri.ts) | Add an explicit mode input, default to Ephemeral, and return/print the end time. Persistent is an explicit choice for durable provisioning output. | +| [Setup utility package facade](../../utils/setup/livesync-commonlib.ts) | Update its independent Commonlib pin and associated lockfiles; updating the root npm dependency alone is insufficient. | + +The Fly.io wrapper delegates to the setup utility and should inherit the same +contract. All maintained generators must migrate explicitly before this +feature is described as the default across applications. Non-interactive +automation which needs a durable URI must select Persistent. + +WebPeer hides an Ephemeral URI and disables its copy and additional-device +actions after the window ends. Its connection monitor remains available for a +device that imported the URI before the end; opening the URI does not expire +the imported P2P credentials. + +| URI | Updated reader | Reader without time-bound support | +| -------------- | ------------------------------------------------------- | --------------------------------- | +| Legacy | Existing behaviour, without a time condition | Existing behaviour | +| New Ephemeral | Opens in the current window with the correct passphrase | Rejected | +| New Persistent | Opens with the correct passphrase | Existing behaviour | + +The assessed, unchanged Commonlib 0.1.27 decoder opens Persistent with the +entered passphrase and rejects Ephemeral with that same passphrase. Persistent +compatibility means compatibility with readers which already support the +ordinary generated `%$` representation; it does not add that representation +to still older readers. Historical supported versions and their settings +schemas still need compatibility fixtures before rollout. + +Keep the original raw-passphrase behaviour of existing URIs. They are +indistinguishable from newly generated Persistent URIs to the updated reader. +The previous marked-envelope sketch rejected an added Persistent compatibility +case with `Unsupported encryption format`. The same synthetic settings and +entered passphrase now open through the unchanged decoder, while separate +tests retain old-reader rejection of Ephemeral and rejection outside its window. + +## Feasibility evidence and validation + +The [executable design probe](time_bound_setup_uri.probe.mjs) uses Web Crypto +for the proposed transformation and the actual Commonlib encoder/decoder for +settings filtering and encryption. It is an isolated specification exercise; +it is not imported by an application or registered in the production test suite. + +Run it after installing the dependency versions assessed by this document: + +```sh +NODE_OPTIONS=--max-old-space-size=512 node docs/design_docs/time_bound_setup_uri.probe.mjs +``` + +Alternatively, pass the directory of an unpacked Commonlib 0.1.27 package as +the first argument. Its module resolution must supply octagonal-wheels 0.1.54 +and the remaining declared dependencies. The probe verifies the Commonlib +version before running; it does not install or download packages. + +The assessment used Node.js 24.18.0 and registry artefacts for Commonlib 0.1.27 +and octagonal-wheels 0.1.54, verified against the lockfile's SHA-512 values. +All 22 cases passed: + +- fixed Ephemeral derivation checked through Web Crypto and Node HMAC, and an + unchanged Persistent passphrase; +- current-window success, previous/later-window rejection, and exact end; +- Persistent across distant timestamps and both candidate attempts on success; +- wrong-passphrase rejection and a shared external format; +- stable end-time metadata and randomised repeated encryption; +- legacy import, old-reader acceptance of Persistent, and old-reader rejection + of Ephemeral; +- unsupported-prefix, malformed-ciphertext, and ciphertext-tampering rejection; +- Persistent generation without reading the clock; +- protocol query decoding/re-encoding; +- generation and import crossing a boundary; +- equivalent timestamps expressed with different UTC offsets; +- Unicode, whitespace, empty low-level passphrase input, and distinct Ephemeral + and raw-passphrase candidates; +- the deliberate limitation that clock rollback reproduces an old key. + +This demonstrates the proposed mechanism and baseline interoperability, not +completed application support, formal mode indistinguishability, or a mobile +latency bound. The present encryption API may perform two PBKDF2 derivations +on a cold import. Do not infer production performance from this small probe, +whose operations can reuse the dependency's in-memory key cache. + +Implementation validation must cover Commonlib unit and packed-package tests, +plus shared fixed-input protocol vectors across consumers. Add targeted LiveSync +tests for the generation metadata, cancellation, a window change during mode +selection, encoded deep links, and rejected imports without settings writes. Extend CLI, WebPeer, +and Deno setup-tool round trips, including their Persistent selection. + +Then run the required LiveSync checks, unit suite, and production build, one +broad process at a time with `NODE_OPTIONS=--max-old-space-size=3072` and bounded +test workers. Use the existing focused real-Obsidian Setup URI workflow for +copy/paste, deep-link import, cancellation, and successful setup. Verify +selection-boundary handling with an injected test clock and mobile behaviour on a +supported runtime; do not change the host's system clock. No remote database +service is needed for the protocol tests themselves. + +There is no identified blocker in the Time-bound URI protocol. The Commonlib +candidate based on 0.1.31 passed its type, unit, boundary, release-process, +and packed-package gates before the 0.1.32 release. A clean `npm ci` against +the published 0.1.32 artefact and the frozen Deno tool lock resolve the same +registry integrity. LiveSync passes its source checks, production build, and +1,034 unit tests on a local stack with [PR #1222](https://github.com/vrtmrz/obsidian-livesync/pull/1222). +The two CLI installer tests need a runner which permits child processes; both +pass under normal process permissions. The Deno setup-tool suite, CLI setup +and file-operation contract, and WebPeer browser tests passed before stacking. + +Focused real-Obsidian generation checks pass on desktop and emulated mobile, +including the displayed Time-bound end, Compatible output, and mobile touch +targets. A two-device CouchDB workflow now passes on the local stack: the +provisioning tool creates a generation 12 database, the first device generates +a Time-bound Setup URI after declaring its required generation 13 feature, and +the second device imports it. An ordinary note completes a round trip, and a +hidden snippet synchronises. The separate real-Obsidian tests for live remote +feature changes, internal Metadata migration, and a percent-prefixed E2EE +passphrase also pass with published Commonlib 0.1.32. Unit and protocol tests +cover window boundaries and preserve the existing distinction between an empty +passphrase and cancellation. The WebPeer browser test confirms that its monitor +remains available after the URI window ends for a device which has already +imported the URI. + +The current Commonlib 0.1.33 integration includes the merged host changes from +PR #1222 and PR #1225. npm 10 and normal clean installations preserve the lockfile; +source checks, the production build, 1,064 unit tests, and ten Deno setup-tool +tests pass. Both URI modes preserve ID recovery and explicit legacy ID selection. + +Real Obsidian tests cover independent ID keys through Time-bound, Compatible, +and QR setup, encrypted local persistence, natural restarts of both devices, +and bidirectional note transfers with stable document and Chunk IDs. Incorrect +passphrases and past-window URIs leave runtime and persisted settings unchanged. +The past clock belongs to an isolated fixture worker, not Obsidian or the host. +CouchDB tests also cover a custom ID source and recovery code, rejection of +incompatible document keys before remote writes, and the Doctor's decline, +reminder, dismissal, and later acceptance paths. The detailed commands and the +remaining published-artefact and physical-device checks are recorded in the +[real Obsidian test guide](../../test/e2e-obsidian/README.md#combined-setup-and-security-regression-checks). + +Compatible preserves the URI encryption format; each receiving client must +still support the shared settings and the remote's declared features. The host +compatibility checks from PR #1222 remain necessary. These results do not +establish a mobile performance bound. The first implementation changes only +primary-language resources; translation changes require separate scope. diff --git a/docs/design_docs/time_bound_setup_uri.probe.mjs b/docs/design_docs/time_bound_setup_uri.probe.mjs new file mode 100644 index 00000000..d26a1143 --- /dev/null +++ b/docs/design_docs/time_bound_setup_uri.probe.mjs @@ -0,0 +1,272 @@ +// Executable design probe; this file is not part of any application bundle. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const packageDirectory = process.argv[2] + ? resolve(process.argv[2]) + : fileURLToPath(new URL(".", import.meta.resolve("@vrtmrz/livesync-commonlib/package.json"))); +const packageURL = pathToFileURL(`${packageDirectory}/`); +const metadata = JSON.parse(await readFile(new URL("package.json", packageURL), "utf8")); +assert.equal(metadata.version, "0.1.27", "Run against the Commonlib version assessed by this design"); +const { encodeSettingsToSetupURI: encodeLegacy, decodeSettingsFromSetupURI: decodeLegacy } = await import( + new URL("dist/API/processSetting.js", packageURL).href +); +const { configURIBase } = await import(new URL("dist/common/types.js", packageURL).href); + +const WEEK_MS = 604_800_000; +const passphrase = "synthetic time-bound URI passphrase"; +const settings = { + couchDB_URI: "https://example.invalid", + couchDB_USER: "synthetic-user", + couchDB_PASSWORD: "synthetic-secret", + isConfigured: true, +}; +const midweek = Date.parse("2026-09-28T12:00:00Z"); +const slot = (now) => { + assert.ok(Number.isSafeInteger(now) && now >= 0, "A supported UTC timestamp is required"); + return Math.floor(now / WEEK_MS); +}; +const end = (slot(midweek) + 1) * WEEK_MS; +const payloadOf = (uri) => { + assert.ok(uri.trim().startsWith(configURIBase)); + return decodeURIComponent(uri.trim().slice(configURIBase.length)); +}; +const wrap = (payload) => configURIBase + encodeURIComponent(payload); +const fixedClock = (now) => () => now; + +async function effectivePassphrase(secret, mode, bucket) { + if (mode === "persistent") return secret; + assert.equal(mode, "ephemeral"); + const text = new TextEncoder(); + const keyBytes = await crypto.subtle.digest("SHA-256", text.encode(secret)); + const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const context = JSON.stringify(["livesync/setup-uri", "tb1", "ephemeral", bucket]); + const signed = await crypto.subtle.sign("HMAC", key, text.encode(context)); + return Array.from(new Uint8Array(signed), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function encode(mode, clock, secret = passphrase) { + assert.ok(mode === "ephemeral" || mode === "persistent"); + if (mode === "persistent") { + return { uri: (await encodeLegacy(settings, secret)).trim(), usableUntil: null }; + } + const bucket = slot(clock()); + const effective = await effectivePassphrase(secret, mode, bucket); + const legacy = await encodeLegacy(settings, effective); + if (mode === "ephemeral" && slot(clock()) !== bucket) throw new Error("Window changed"); + return { + uri: legacy.trim(), + usableUntil: (bucket + 1) * WEEK_MS, + }; +} + +async function decode(uri, secret, clock, attempts = []) { + const payload = payloadOf(uri); + if (!payload.startsWith("%$")) { + return decodeLegacy(uri.trim(), secret); + } + const bucket = slot(clock()); + const authenticated = []; + for (const mode of ["ephemeral", "persistent"]) { + const effective = await effectivePassphrase(secret, mode, bucket); + attempts.push(mode); + try { + const value = await decodeLegacy(uri.trim(), effective); + if (value !== false) authenticated.push({ mode, value }); + } catch { + // Authentication failures are expected while trying the two candidates. + } + } + const currentBucket = slot(clock()); + const accepted = authenticated.filter(({ mode }) => mode === "persistent" || bucket === currentBucket); + if (accepted.length !== 1) throw new Error("Cannot open Setup URI"); + return accepted[0].value; +} + +const ephemeral = await encode("ephemeral", fixedClock(midweek)); +const persistent = await encode("persistent", fixedClock(midweek)); + +await test("Persistent opens in the unchanged legacy decoder with the entered passphrase", async () => { + assert.equal((await decodeLegacy(persistent.uri, passphrase)).couchDB_PASSWORD, settings.couchDB_PASSWORD); +}); + +await test("Web Crypto derivation matches fixed vectors computed through Node HMAC", async () => { + assert.equal( + await effectivePassphrase("test-passphrase", "ephemeral", 1234), + "b39361c51f0b7bd835554db1dffbc9a540fb30789aa62bf53e39e07d1073013b" + ); + assert.equal(await effectivePassphrase("test-passphrase", "persistent", 1234), "test-passphrase"); +}); + +await test("Ephemeral accepts the entire current UTC bucket, including before creation", async () => { + for (const now of [slot(midweek) * WEEK_MS, midweek, end - 1]) { + const attempts = []; + assert.equal( + (await decode(ephemeral.uri, passphrase, fixedClock(now), attempts)).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); + assert.deepEqual(attempts, ["ephemeral", "persistent"]); + } +}); + +await test("Ephemeral rejects the previous bucket, the exact end, and later buckets", async () => { + for (const now of [slot(midweek) * WEEK_MS - 1, end, end + WEEK_MS, end + 100 * WEEK_MS]) { + const attempts = []; + await assert.rejects(decode(ephemeral.uri, passphrase, fixedClock(now), attempts), /Cannot open/); + assert.deepEqual(attempts, ["ephemeral", "persistent"]); + } +}); + +await test("Persistent accepts distant supported timestamps using the same two attempts", async () => { + for (const now of [0, midweek, end, end + 100 * WEEK_MS]) { + const attempts = []; + assert.equal( + (await decode(persistent.uri, passphrase, fixedClock(now), attempts)).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); + assert.deepEqual(attempts, ["ephemeral", "persistent"]); + } +}); + +await test("Both modes reject an incorrect passphrase with the same final error", async () => { + for (const generated of [ephemeral, persistent]) { + await assert.rejects(decode(generated.uri, "incorrect", fixedClock(midweek)), /Cannot open Setup URI/); + } +}); + +await test("Generation metadata agrees with the fixed UTC boundary", () => { + assert.equal(ephemeral.usableUntil, end); + assert.equal(new Date(end).toISOString(), "2026-10-01T00:00:00.000Z"); + assert.equal(persistent.usableUntil, null); +}); + +await test("Both modes retain the legacy binary layout without a new marker or timestamp field", () => { + const binaryLength = (uri) => { + const payload = payloadOf(uri); + assert.ok(payload.startsWith("%$")); + return Buffer.from(payload.slice("%$".length), "base64").length; + }; + assert.equal(binaryLength(ephemeral.uri), binaryLength(persistent.uri)); +}); + +await test("Repeated generation has different ciphertext without changing the time limit", async () => { + const again = await encode("ephemeral", fixedClock(midweek)); + assert.notEqual(again.uri, ephemeral.uri); + assert.equal(again.usableUntil, ephemeral.usableUntil); +}); + +await test("Old decoder cannot open Ephemeral with the entered passphrase", async () => { + await assert.rejects(decodeLegacy(ephemeral.uri, passphrase)); +}); + +await test("Legacy URI remains readable without time binding", async () => { + const legacy = await encodeLegacy(settings, passphrase); + const attempts = []; + const value = await decode(legacy, passphrase, fixedClock(end + 100 * WEEK_MS), attempts); + assert.equal(value.couchDB_PASSWORD, settings.couchDB_PASSWORD); + assert.deepEqual(attempts, ["ephemeral", "persistent"]); +}); + +await test("Unsupported prefixes are rejected without time-bound trials", async () => { + for (const payload of ["tb1:" + payloadOf(ephemeral.uri), "tb2:", "", "unlimited"]) { + const attempts = []; + await assert.rejects(decode(wrap(payload), passphrase, fixedClock(midweek), attempts), /format/); + assert.deepEqual(attempts, []); + } +}); + +await test("Missing or truncated legacy-format ciphertext fails both candidates", async () => { + for (const payload of ["%$", "%$AA=="]) { + const attempts = []; + await assert.rejects(decode(wrap(payload), passphrase, fixedClock(midweek), attempts), /Cannot open/); + assert.deepEqual(attempts, ["ephemeral", "persistent"]); + } +}); + +await test("Persistent generation does not consult the clock", async () => { + const generated = await encode("persistent", () => { + throw new Error("The Persistent generator must not read time"); + }); + assert.equal(generated.usableUntil, null); + assert.equal((await decodeLegacy(generated.uri, passphrase)).couchDB_PASSWORD, settings.couchDB_PASSWORD); +}); + +await test("Changing authenticated ciphertext is rejected", async () => { + const payload = payloadOf(ephemeral.uri); + const encoded = payload.slice("%$".length); + const bytes = Buffer.from(encoded, "base64"); + bytes[bytes.length - 1] ^= 1; + await assert.rejects(decode(wrap("%$" + bytes.toString("base64")), passphrase, fixedClock(midweek)), /Cannot open/); +}); + +await test("Protocol query decoding and re-encoding preserve the new payload", async () => { + for (const generated of [ephemeral, persistent]) { + const incomingSettings = new URL(generated.uri).searchParams.get("settings"); + const reconstructed = configURIBase + encodeURIComponent(incomingSettings); + assert.equal( + (await decode(reconstructed, passphrase, fixedClock(midweek))).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); + } +}); + +await test("Generation crossing the boundary withholds an Ephemeral result", async () => { + let reads = 0; + await assert.rejects( + encode("ephemeral", () => (reads++ === 0 ? end - 1 : end)), + /Window changed/ + ); +}); + +await test("Import crossing the boundary withholds Ephemeral settings but accepts Persistent", async () => { + for (const [generated, succeeds] of [ + [ephemeral, false], + [persistent, true], + ]) { + let reads = 0; + const result = decode(generated.uri, passphrase, () => (reads++ === 0 ? end - 1 : end)); + if (succeeds) assert.equal((await result).couchDB_PASSWORD, settings.couchDB_PASSWORD); + else await assert.rejects(result, /Cannot open/); + } +}); + +await test("Explicit UTC offsets representing the same instant select the same bucket", async () => { + for (const date of ["2026-09-28T12:00:00Z", "2026-09-28T21:00:00+09:00", "2026-09-28T05:00:00-07:00"]) { + assert.equal( + (await decode(ephemeral.uri, passphrase, fixedClock(Date.parse(date)))).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); + } +}); + +await test("Passphrase transformation preserves Unicode and supports an empty low-level input", async () => { + for (const secret of ["", "合言葉🔑é", "e\u0301", " leading and trailing "]) { + const generated = await encode("ephemeral", fixedClock(midweek), secret); + assert.equal( + (await decode(generated.uri, secret, fixedClock(midweek))).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); + } + assert.notEqual( + await effectivePassphrase("é", "persistent", 0), + await effectivePassphrase("e\u0301", "persistent", 0) + ); +}); + +await test("Ephemeral derivation differs from the raw Persistent passphrase even for bucket zero", async () => { + assert.notEqual( + await effectivePassphrase(passphrase, "ephemeral", 0), + await effectivePassphrase(passphrase, "persistent", 0) + ); +}); + +await test("Clock rollback reproduces an old Ephemeral key", async () => { + await assert.rejects(decode(ephemeral.uri, passphrase, fixedClock(end)), /Cannot open/); + assert.equal( + (await decode(ephemeral.uri, passphrase, fixedClock(midweek))).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); +}); diff --git a/docs/settings.md b/docs/settings.md index 44b21a4b..70793faf 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -71,7 +71,7 @@ This pane always shows the current release history. It does not track whether a Internal database or settings compatibility reviews use a separate safety dialogue, not this pane. After the Obsidian layout is ready, a pending review opens as **Synchronisation paused for compatibility review**. The dialogue explains why remote synchronisation has been paused and preserves the automatic synchronisation choices which were configured before the update. Closing it or selecting **Keep synchronisation paused** leaves synchronisation paused. Use the persistent Notice's **Review why** link, or run the `Review why synchronisation is paused` command, to reopen it. Opening **Change Log** does not acknowledge the review. -A configured Vault which was copied, restored, or opened in a new Obsidian profile can require this review because its device-local acknowledgement is not part of the Vault data. An empty local database is not accepted as evidence that it is safe to continue. An existing unconfigured Vault remains in onboarding without this synchronisation warning; its missing acknowledgement is not filled in automatically, so it is evaluated if the Vault is configured later. When the detected state can be handled by the running version, **Resume synchronisation** records the current internal database version and restores the configured behaviour. An older installation cannot dismiss a pause caused by a newer database or settings version. +An absent device-local acknowledgement alone does not require a review, including when adding a device or opening a copied Vault in a new profile. LiveSync records the current internal database version if no other review is pending. An existing unconfigured Vault remains in onboarding; its next configured start evaluates compatibility before recording an absent marker. Known version differences, an invalid marker, settings-schema issues, and an already saved review still require attention. When the detected state can be handled by the running version, **Resume synchronisation** records the current internal database version and restores the configured behaviour. An older installation cannot dismiss a pause caused by a newer database or settings version. ## 1. Quick Setup and Extra menus diff --git a/docs/setup_object_storage.md b/docs/setup_object_storage.md index 4c72b716..cad31264 100644 --- a/docs/setup_object_storage.md +++ b/docs/setup_object_storage.md @@ -32,6 +32,7 @@ deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.co For providers which require them, set `force_path_style`, `use_custom_request_handler`, or `bucket_custom_headers` as described in the [setup utility reference](../utils/readme.md#object-storage). Store the generated Setup URI and Setup URI passphrase separately. The URI is encrypted, but it contains the Object Storage credentials. The generator also prints an ID recovery code; reuse it through `id_recovery_code` if you regenerate a URI for the same Vault. A new run without it creates a different ID key. The [setup utility reference](../utils/readme.md#setup-uri-generation) describes the `id_mode=legacy` option for existing Vaults. +The generator prints the exact end time of its default Ephemeral URI. Set `uri_mode=persistent` before running it if the URI must remain usable without a time condition. ## Set up the first device diff --git a/docs/setup_own_server.md b/docs/setup_own_server.md index 1177b820..1b7d0e9c 100644 --- a/docs/setup_own_server.md +++ b/docs/setup_own_server.md @@ -194,10 +194,13 @@ The generator also prints an ID recovery code for its random ID key. Save that c The generator consumes the exact registry-pinned Commonlib release used by the provisioning utility. It creates a configured CouchDB remote profile, applies the current defaults for a new Vault, and encodes them with Commonlib's Setup URI contract. +By default, the URI is Ephemeral and can be opened only until the exact UTC time printed by the generator. This is the end of a fixed seven-day window, not seven days after creation. Set `uri_mode=persistent` before running the command if the URI needs no time condition or must be readable by an older client that supports the existing encrypted format. + You will then get the following output: ```bash Generated couchdb Setup URI. +Ephemeral: usable until 2026-10-01T00:00:00.000Z (UTC). Your passphrase for the Setup URI is: H7vX...a-random-32-character-value This passphrase is never shown again, so store it safely. ID recovery code: sls-id-v1:<64 lowercase hexadecimal characters> diff --git a/docs/setup_p2p.md b/docs/setup_p2p.md index 82b74ba6..4797f420 100644 --- a/docs/setup_p2p.md +++ b/docs/setup_p2p.md @@ -136,3 +136,4 @@ deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.co ``` The generated Setup URI contains the encrypted room, relay, and Vault settings. It deliberately omits the device-specific name. Store the URI and its passphrase separately. The generator prints an ID recovery code; pass it as `id_recovery_code` if you regenerate a URI for the same Vault. A run without it creates a different ID key. Also reuse the original `p2p_room_id` and `p2p_passphrase`, since omitted values are generated afresh. The [setup utility reference](../utils/readme.md#setup-uri-generation) describes the `id_mode=legacy` option for existing Vaults. After importing the URI on the first device, continue from the initialisation step above, then generate a fresh Setup URI for an additional device from that working device. +The generator prints the exact end time of its default Ephemeral URI. Set `uri_mode=persistent` before running it if the URI must remain usable without a time condition. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0d19c90a..54cd2a4b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -55,7 +55,7 @@ At start-up, LiveSync can restore a missing device-local revision record when th ## Synchronisation is paused for compatibility review -A compatibility review is separate from the Change Log. It can appear after an internal database or settings-format change, or when a configured Vault is copied, restored, or opened in a new Obsidian profile without its device-local acknowledgement. +A compatibility review is separate from the Change Log. It can appear after an internal database or settings-format change, when the saved version marker is invalid, or when an earlier review remains pending. Adding a device or opening a copied Vault with no device-local acknowledgement does not itself trigger a review. A pause already saved by an earlier release still needs the explicit resume action, because the saved message does not identify its original cause. The **Synchronisation paused for compatibility review** dialogue opens after the Obsidian layout is ready. If it has been closed, use the persistent Notice's **Review why** link, or run `Review why synchronisation is paused` from the command palette. Opening **Change Log** does not clear the pause. diff --git a/package.json b/package.json index e9f69c34..5808c7d0 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ "test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts", "test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts", "test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts", + "test:e2e:obsidian:object-storage-compatible-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts --compatible", + "test:e2e:obsidian:object-storage-qr-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts --qr", "test:e2e:obsidian:object-storage-custom-http-handler-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts --custom-http-handler", "test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts", "pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer", @@ -89,6 +91,7 @@ "test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts", "test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts", "test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts", + "test:e2e:obsidian:internal-metadata-doctor": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts --doctor", "test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts", "test:e2e:obsidian:upgrade-from-stable": "tsx test/e2e-obsidian/scripts/upgrade-from-stable.ts", "test:e2e:obsidian:local-suite": "tsx test/e2e-obsidian/scripts/local-suite.ts", diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index f4d3a45a..61d253a1 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -419,6 +419,38 @@ describe("runCommand abnormal cases", () => { expect(appliedSettings.useIndexedDBAdapter).toBe(false); }); + it("setup opens Ephemeral in its window and leaves settings untouched afterwards", async () => { + const end = Date.parse("2026-10-01T00:00:00Z"); + const clock = vi.spyOn(Date, "now").mockReturnValue(end - 1_000); + const passphrase = "time-bound-passphrase"; + try { + const { uri, usableUntil } = await processSetting.encodeTimeBoundSetupURI( + { ...DEFAULT_SETTINGS, isConfigured: true, couchDB_DBNAME: "time-bound-vault" }, + passphrase + ); + expect(usableUntil).toBe(end); + + const inWindow = createCoreMock(); + inWindow.services.context.standardIo.prompt.mockResolvedValue(passphrase); + await runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: inWindow }); + expect(inWindow.services.setting.applyExternalSettings).toHaveBeenCalledWith( + expect.objectContaining({ couchDB_DBNAME: "time-bound-vault" }), + true + ); + + clock.mockReturnValue(end); + const outOfWindow = createCoreMock(); + outOfWindow.services.context.standardIo.prompt.mockResolvedValue(passphrase); + await expect(runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: outOfWindow })).rejects.toThrow( + "Cannot open Setup URI" + ); + expect(outOfWindow.services.setting.applyExternalSettings).not.toHaveBeenCalled(); + expect(outOfWindow.services.control.applySettings).not.toHaveBeenCalled(); + } finally { + clock.mockRestore(); + } + }); + it("setup imports managed TURN through the existing encrypted URI", async () => { const core = createCoreMock(); const profiles = { diff --git a/src/apps/webpeer/src/P2PCheck.svelte b/src/apps/webpeer/src/P2PCheck.svelte index 670b306f..151cc1c4 100644 --- a/src/apps/webpeer/src/P2PCheck.svelte +++ b/src/apps/webpeer/src/P2PCheck.svelte @@ -1,7 +1,8 @@