Merge pull request #1226 from vrtmrz/feature/time-bound-setup-uri

Add time-bound Setup URIs and clarify device setup choices
This commit is contained in:
vorotamoroz
2026-10-01 11:58:06 +09:00
committed by GitHub
49 changed files with 2209 additions and 418 deletions
@@ -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.
@@ -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
+425
View File
@@ -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=<encodeURIComponent(encryptedSettings)>
```
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.
@@ -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
);
});
+1 -1
View File
@@ -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
+1
View File
@@ -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
+3
View File
@@ -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>
+1
View File
@@ -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.
+1 -1
View File
@@ -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.
+3
View File
@@ -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",
@@ -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 = {
+71 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
import qrcode from "qrcode-generator";
import { onDestroy, tick } from "svelte";
import { onDestroy, onMount, tick } from "svelte";
import { isTimeBoundSetupURIUsableNow } from "@vrtmrz/livesync-commonlib/setup-uri";
import {
generateP2PCheckSetup,
@@ -106,6 +107,7 @@
let elapsedMilliseconds = $state(0);
let copied = $state<"uri" | "passphrase">();
let copyError = $state("");
let currentTime = $state(Date.now());
let freshCheckStarting = $state(false);
let additionalDeviceAttempt = $state<AdditionalDeviceAttempt>();
@@ -132,6 +134,12 @@
);
let elapsedSeconds = $derived(Math.floor(elapsedMilliseconds / 1_000));
let targetLabel = $derived(target === "desktop" ? "desktop" : "mobile");
let setupURIUsable = $derived(
setup !== undefined && currentTime >= 0 && isTimeBoundSetupURIUsableNow(setup.setupURIUsableUntil)
);
let remainingMinutes = $derived(
setup ? Math.max(0, Math.ceil((setup.setupURIUsableUntil - currentTime) / 60_000)) : 0
);
let additionalElapsedMilliseconds = $derived(
additionalDeviceAttempt
? Math.max(
@@ -178,6 +186,7 @@
const generated = await generateP2PCheckSetup(target, { relay });
qrDataURL = createQRCodeDataURL(generated.setupURI);
setup = generated;
currentTime = Date.now();
} catch (error) {
preparationError = formatError(error);
} finally {
@@ -215,6 +224,11 @@
async function copyText(value: string, kind: "uri" | "passphrase"): Promise<void> {
copyError = "";
if (kind === "uri" && (!setup || !isTimeBoundSetupURIUsableNow(setup.setupURIUsableUntil))) {
currentTime = Date.now();
copyError = "This Setup URI is outside its time window. Start a fresh check.";
return;
}
try {
await navigator.clipboard.writeText(value);
copied = kind;
@@ -254,6 +268,7 @@
async function startAdditionalDeviceAttempt(): Promise<void> {
if (
!setupURIUsable ||
!monitorActive ||
outcome !== "connected" ||
activeConnections === 0 ||
@@ -268,6 +283,38 @@
await showSetupQRCode();
}
function formatSetupURIEnd(usableUntil: number): string {
return new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
}).format(new Date(usableUntil));
}
function openSetupURI(event: MouseEvent): void {
if (!setup || !isTimeBoundSetupURIUsableNow(setup.setupURIUsableUntil)) {
event.preventDefault();
currentTime = Date.now();
}
}
onMount(() => {
const refresh = () => (currentTime = Date.now());
const timer = setInterval(refresh, 1_000);
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
return () => {
clearInterval(timer);
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
};
});
onDestroy(() => {
if (elapsedTimer !== undefined) {
clearInterval(elapsedTimer);
@@ -372,6 +419,7 @@
<div class="setup-grid">
<div class="qr-panel">
{#if setupURIUsable}
<img
src={qrDataURL}
alt={additionalDeviceAttempt
@@ -383,6 +431,12 @@
? "This is the original encrypted Setup URI; it was not regenerated."
: "QR contains the encrypted Setup URI only."}
</p>
{:else}
<p role="alert">This Setup URI is outside its time window. Start a fresh check to set up another device. A device that already imported it can still be monitored below.</p>
<button type="button" onclick={startFreshCheck} disabled={freshCheckStarting}>
{freshCheckStarting ? "Starting…" : "Start a fresh check"}
</button>
{/if}
</div>
<div class="credential-panel">
@@ -401,6 +455,16 @@
</div>
<p class="field-help">Type this when LiveSync asks to decrypt the Setup URI.</p>
<p class="field-help">
{#if setupURIUsable}
Ephemeral Setup URI: usable until {formatSetupURIEnd(setup.setupURIUsableUntil)}
({remainingMinutes < 1 ? "less than 1 minute" : `${remainingMinutes} minutes`} remaining).
{:else}
This Setup URI is outside its time window. Generate a new one before sharing it.
{/if}
</p>
{#if setupURIUsable}
<label for="setup-uri">Setup URI</label>
<textarea
id="setup-uri"
@@ -414,8 +478,9 @@
<button type="button" onclick={() => copyText(setup!.setupURI, "uri")}>
{copied === "uri" ? "Copied URI" : "Copy Setup URI"}
</button>
<a class="button-link" href={setup.setupURI}>Open in Obsidian</a>
<a class="button-link" href={setup.setupURI} onclick={openSetupURI}>Open in Obsidian</a>
</div>
{/if}
{#if copyError}
<p class="inline-error" role="alert">{copyError}</p>
{/if}
@@ -550,9 +615,11 @@
class="primary-action"
type="button"
onclick={startAdditionalDeviceAttempt}
disabled={activeConnections === 0}
disabled={!setupURIUsable || activeConnections === 0}
>
{activeConnections === 0
{!setupURIUsable
? "Setup URI window ended; start a fresh check"
: activeConnections === 0
? "Waiting for the first device to reconnect…"
: "Try another device without resetting"}
</button>
+15 -4
View File
@@ -1,4 +1,4 @@
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { encodeTimeBoundSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import {
P2P_DEFAULT_SETTINGS,
@@ -17,6 +17,7 @@ export type P2PCheckTarget = "desktop" | "mobile";
export interface GeneratedP2PCheckSetup {
readonly target: P2PCheckTarget;
readonly setupURI: string;
readonly setupURIUsableUntil: number;
readonly setupPassphrase: string;
readonly groupId: string;
readonly relay: string;
@@ -147,16 +148,26 @@ export async function generateP2PCheckSetup(
browserSettings.suspendParseReplicationResult = true;
const setupPassphrase = generateSetupPassphrase();
const setupURI = await encodeSettingsToSetupURI(
const { uri: setupURI, usableUntil } = await encodeTimeBoundSetupURI(
deviceSettings,
setupPassphrase,
["pluginSyncExtendedSetting", "doNotUseFixedRevisionForChunks", "P2P_DevicePeerName", "deviceAndVaultName"],
true
{
mode: "ephemeral",
removeProperties: [
"pluginSyncExtendedSetting",
"doNotUseFixedRevisionForChunks",
"P2P_DevicePeerName",
"deviceAndVaultName",
],
skipDefaultValue: true,
}
);
if (usableUntil === null) throw new Error("Ephemeral Setup URI has no time window");
return {
target,
setupURI: setupURI.trim(),
setupURIUsableUntil: usableUntil,
setupPassphrase,
groupId: credentials.groupId,
relay: deviceSettings.P2P_relays,
+5 -16
View File
@@ -8,7 +8,7 @@ export const DATABASE_COMPATIBILITY_LEGACY_VERSION_KEY_PREFIX = "obsidian-live-s
export const COMPATIBILITY_PAUSE_SETTING_MESSAGE =
"Remote synchronisation is paused until this device's compatibility review has been completed.";
export type DatabaseCompatibilityVersionState = "missing" | "invalid" | "upgrade" | "downgrade";
export type DatabaseCompatibilityVersionState = "invalid" | "upgrade" | "downgrade";
export interface DatabaseCompatibilityReason {
source: "database-version";
@@ -57,18 +57,9 @@ export interface CompatibilityEvaluationInput {
function databaseVersionReason(
acknowledgedVersion: string | null,
currentVersion: number,
isNewVault: boolean
currentVersion: number
): DatabaseCompatibilityReason | undefined {
if (acknowledgedVersion === null || acknowledgedVersion === "") {
if (isNewVault) return undefined;
return {
source: "database-version",
state: "missing",
currentVersion,
resumable: true,
};
}
if (acknowledgedVersion === null || acknowledgedVersion === "") return undefined;
const parsed = Number(acknowledgedVersion);
if (!Number.isSafeInteger(parsed)) {
@@ -104,9 +95,8 @@ function databaseVersionReason(
* The caller owns persistence, user interaction, and the actual replication gate.
*/
export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput): CompatibilityEvaluation {
const isNewVault = input.migrationState?.isNewVault === true;
const reasons: CompatibilityPauseReason[] = [];
const databaseReason = databaseVersionReason(input.acknowledgedVersion, input.currentVersion, isNewVault);
const databaseReason = databaseVersionReason(input.acknowledgedVersion, input.currentVersion);
if (databaseReason) reasons.push(databaseReason);
if (input.migrationState?.requiresSyncReview === true) {
@@ -130,8 +120,7 @@ export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput):
if (reasons.length === 0) {
return {
initialiseAcknowledgedVersion:
isNewVault && (input.acknowledgedVersion === null || input.acknowledgedVersion === ""),
initialiseAcknowledgedVersion: input.acknowledgedVersion === null || input.acknowledgedVersion === "",
};
}
+82 -48
View File
@@ -68,8 +68,32 @@ describe("database compatibility evaluation", () => {
});
});
it("requires review when an existing Vault has no valid acknowledged version", () => {
for (const acknowledgedVersion of [null, "invalid"]) {
it.each([null, ""])(
"initialises a missing marker (%s) without pausing an existing Vault",
(acknowledgedVersion) => {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "",
});
expect(result).toEqual({ initialiseAcknowledgedVersion: true });
}
);
it("initialises a missing marker when no settings migration state is available", () => {
expect(
evaluateCompatibilityPause({
acknowledgedVersion: null,
currentVersion: 12,
legacyReviewMessage: "",
})
).toEqual({ initialiseAcknowledgedVersion: true });
});
it("requires review when an existing Vault has an invalid acknowledged version", () => {
for (const acknowledgedVersion of ["invalid", "NaN", "12.5"]) {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
@@ -77,39 +101,44 @@ describe("database compatibility evaluation", () => {
legacyReviewMessage: "",
});
expect(result.pause?.resumable).toBe(true);
expect(result.pause?.reasons[0]).toMatchObject({ source: "database-version" });
expect(result.pause?.reasons[0]).toMatchObject({ source: "database-version", state: "invalid" });
expect(result.initialiseAcknowledgedVersion).toBe(false);
}
});
it("does not permit a future settings schema to be acknowledged by an older implementation", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 3,
targetVersion: 2,
isFromFutureSchema: true,
requiresSyncReview: true,
}),
legacyReviewMessage: "",
});
expect(result.pause).toEqual({
resumable: false,
reasons: [
{
source: "settings-schema",
it.each(["12", null])(
"does not permit a future settings schema with marker %s to be acknowledged",
(acknowledgedVersion) => {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 3,
currentVersion: 2,
targetVersion: 2,
isFromFutureSchema: true,
resumable: false,
reviewReasons: [],
},
],
});
});
requiresSyncReview: true,
}),
legacyReviewMessage: "",
});
it("retains a settings migration review in the host compatibility reason", () => {
expect(result.pause).toEqual({
resumable: false,
reasons: [
{
source: "settings-schema",
sourceVersion: 3,
currentVersion: 2,
isFromFutureSchema: true,
resumable: false,
reviewReasons: [],
},
],
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
}
);
it.each(["12", null])("retains a settings migration review with marker %s", (acknowledgedVersion) => {
const reviewReasons = [
{
code: "legacy-update-review-pending",
@@ -118,7 +147,7 @@ describe("database compatibility evaluation", () => {
},
];
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 9,
@@ -137,27 +166,32 @@ describe("database compatibility evaluation", () => {
resumable: true,
reviewReasons,
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
});
it("compatibility: retains an earlier unstructured review when no structured reason can be reconstructed", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "Review an earlier compatibility change.",
});
it.each(["12", null])(
"compatibility: retains an earlier unstructured review with marker %s",
(acknowledgedVersion) => {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "Review an earlier compatibility change.",
});
expect(result.pause).toEqual({
resumable: true,
reasons: [
{
source: "legacy-review",
message: "Review an earlier compatibility change.",
resumable: true,
},
],
});
});
expect(result.pause).toEqual({
resumable: true,
reasons: [
{
source: "legacy-review",
message: "Review an earlier compatibility change.",
resumable: true,
},
],
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
}
);
it("compatibility: scopes the earlier review marker to the Vault", () => {
expect(legacyDatabaseCompatibilityVersionKey("Example Vault")).toBe("obsidian-live-sync-verExample Vault");
@@ -7,6 +7,9 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"⚠️ Initialise or overwrite the remote": "⚠️ Initialise or overwrite the remote",
"🔗 Join this device": "🔗 Join this device",
"⚙️ Apply settings only (advanced)": "⚙️ Apply settings only (advanced)",
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.":
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.",
"TURN configuration": "TURN configuration",
@@ -48,9 +48,7 @@
<Instruction>
<Question>{translateMessage("Please select your situation.")}</Question>
<Option
title={translateMessage(
"I am setting up a new server for the first time / I want to reset my existing server."
)}
title={translateMessage("⚠️ Initialise or overwrite the remote")}
bind:value={userType}
selectedValue={TYPE_NEW}
>
@@ -61,7 +59,7 @@
</InfoNote>
</Option>
<Option
title={translateMessage("My remote server is already set up. I want to join this device.")}
title={translateMessage("🔗 Join this device")}
bind:value={userType}
selectedValue={TYPE_EXISTING}
>
@@ -72,9 +70,7 @@
</InfoNote>
</Option>
<Option
title={translateMessage(
"The remote is already set up, and the configuration is compatible (or got compatible by this operation)."
)}
title={translateMessage("⚙️ Apply settings only (advanced)")}
bind:value={userType}
selectedValue={TYPE_COMPATIBLE_EXISTING}
>
@@ -57,4 +57,9 @@
textarea {
resize: none;
}
button {
min-width: 44px;
min-height: 44px;
}
</style>
+2 -3
View File
@@ -66,9 +66,8 @@ export class CompatibilityReviewController {
// An existing unconfigured Vault cannot replicate, so a database
// compatibility pause would only compete with onboarding and persist
// a misleading sync warning. Do not acknowledge the missing marker:
// activation on a later start must evaluate the same state again.
// Genuinely new Vaults still initialise their marker below.
// a misleading sync warning. Its next configured start evaluates any
// known compatibility reasons before initialising an absent marker.
if (settings.isConfigured !== true && migrationState?.isNewVault !== true) {
this.pause = undefined;
this.ui.clearReminder();
@@ -103,15 +103,43 @@ describe("compatibility review controller", () => {
fixture.settings.isConfigured = true;
await expect(fixture.controller.initialise()).resolves.toBe(true);
expect(fixture.controller.pendingPause?.reasons).toContainEqual({
source: "database-version",
state: "missing",
currentVersion: 12,
resumable: true,
});
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.settings.versionUpFlash).toBe("");
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.saveSettingData).not.toHaveBeenCalled();
});
it("starts a configured Vault with no device marker without a review or settings changes", async () => {
const fixture = createFixture({ marker: null });
const previousSettings = { ...fixture.settings };
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.settings).toEqual(previousSettings);
expect(fixture.saveSettingData).not.toHaveBeenCalled();
expect(fixture.ui.showSummary).not.toHaveBeenCalled();
expect(fixture.ui.showReminder).not.toHaveBeenCalled();
});
it("keeps an already persisted review when the device marker is absent", async () => {
const fixture = createFixture({ marker: null, versionUpFlash: COMPATIBILITY_PAUSE_SETTING_MESSAGE });
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
expect(fixture.controller.pendingPause?.reasons).toEqual([
{
source: "legacy-review",
message: COMPATIBILITY_PAUSE_SETTING_MESSAGE,
resumable: true,
},
]);
expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false);
expect(fixture.saveSettingData).toHaveBeenCalledOnce();
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
});
it("preserves preferences and advances the marker only after an upgrade review is resumed", async () => {
@@ -19,9 +19,6 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
if (reason.state === "downgrade") {
return `- This installation uses internal database version **${reason.currentVersion}**, but this device previously acknowledged newer version **${reason.acknowledgedVersion}**. An older installation must not resume synchronisation.`;
}
if (reason.state === "missing") {
return `- No previously acknowledged internal database version was found for this existing Vault. This can happen when a Vault is copied or restored, or when it is opened with a new Obsidian profile. This installation uses version **${reason.currentVersion}**. An empty local database does not mean that it is safe to resume automatically.`;
}
return `- The saved internal database version marker is invalid. This installation uses version **${reason.currentVersion}**.`;
}
if (reason.source === "settings-schema") {
@@ -23,13 +23,13 @@ const resumablePause: CompatibilityPause = {
};
describe("Obsidian compatibility review", () => {
it("explains why a configured Vault can be missing its device-local acknowledgement", async () => {
it("explains an invalid device-local acknowledgement", () => {
const pause: CompatibilityPause = {
resumable: true,
reasons: [
{
source: "database-version",
state: "missing",
state: "invalid",
currentVersion: 12,
resumable: true,
},
@@ -37,9 +37,8 @@ describe("Obsidian compatibility review", () => {
};
const details = compatibilityReviewDetailsMarkdown(pause);
expect(details).toContain("copied or restored");
expect(details).toContain("new Obsidian profile");
expect(details).toContain("does not mean that it is safe to resume automatically");
expect(details).toContain("saved internal database version marker is invalid");
expect(details).toContain("version **12**");
});
it("offers the generic resume action in a vertical action dialogue", async () => {
@@ -1,5 +1,7 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { registerSetupProtocolHandler, useSetupProtocolFeature } from "./setupProtocol";
import { encodeTimeBoundSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/common/types", () => {
return {
@@ -52,6 +54,35 @@ describe("setupObsidian/setupProtocol", () => {
expect(setupManager.decodeQR).not.toHaveBeenCalled();
});
it.each(["ephemeral", "persistent"] as const)(
"reconstructs the exact encrypted %s URI after protocol query decoding",
async (mode) => {
let protocolHandler: ((params: Record<string, string>) => Promise<void>) | undefined;
const host = {
services: {
API: {
registerProtocolHandler: vi.fn(
(_action: string, handler: (params: Record<string, string>) => Promise<void>) => {
protocolHandler = handler;
}
),
},
},
} as any;
const setupManager = { onUseSetupURI: vi.fn(async () => true), decodeQR: vi.fn() } as any;
registerSetupProtocolHandler(host, vi.fn(), setupManager);
const generated = await encodeTimeBoundSetupURI(DEFAULT_SETTINGS, "protocol-passphrase", { mode });
const decodedQuery = new URL(generated.uri.trim()).searchParams.get("settings");
expect(decodedQuery?.startsWith("%$")).toBe(true);
await protocolHandler!({ settings: decodedQuery! });
const reconstructed = setupManager.onUseSetupURI.mock.calls[0][1] as string;
expect(reconstructed.replace("mock-config://", "obsidian://setuplivesync?settings=")).toBe(
generated.uri.trim()
);
}
);
it("registerSetupProtocolHandler should route settingsQR payload to decodeQR", async () => {
let protocolHandler: ((params: Record<string, string>) => Promise<void>) | undefined;
const host = {
+85 -23
View File
@@ -1,7 +1,12 @@
import { LOG_LEVEL_NOTICE, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import {
encodeTimeBoundSetupURI,
getTimeBoundSetupURIUsableUntil,
isTimeBoundSetupURIUsableNow,
type TimeBoundSetupURIMode,
} from "@vrtmrz/livesync-commonlib/setup-uri";
import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
@@ -16,32 +21,89 @@ export async function askEncryptingPassphrase(host: SetupFeatureHost): Promise<s
);
}
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
const encryptingPassphrase = await askEncryptingPassphrase(host);
if (encryptingPassphrase === false) return;
const encryptedURI = await encodeSettingsToSetupURI(
host.services.setting.currentSettings(),
encryptingPassphrase,
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
true
);
if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
function formatWindowEnd(usableUntil: number): string {
return new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
}).format(new Date(usableUntil));
}
async function askSetupURIMode(
host: SetupFeatureHost
): Promise<{ mode: TimeBoundSetupURIMode; usableUntil: number | null } | false> {
let usableUntil: number | null = null;
try {
usableUntil = getTimeBoundSetupURIUsableUntil();
} catch {
// Compatible generation remains available when the device clock is invalid.
}
const timeBound = "Time-bound";
const compatible = "Compatible (no time limit)";
const cancel = "Cancel";
const buttons = usableUntil === null ? [compatible, cancel] : [timeBound, compatible, cancel];
const message =
usableUntil === null
? "Time-bound Setup URIs require a valid device clock. Compatible URIs have no time limit and work with older clients."
: `Time-bound Setup URIs can be opened until ${formatWindowEnd(usableUntil)}. This is the end of the current fixed seven-day UTC window, not seven days from now. Compatible URIs have no time limit and work with older clients.`;
const selected = await host.services.UI.confirm.askSelectStringDialogue(message, buttons, {
title: "Setup URI availability",
defaultAction: usableUntil === null ? compatible : timeBound,
});
if (selected === timeBound && usableUntil !== null) return { mode: "ephemeral", usableUntil };
if (selected === compatible) return { mode: "persistent", usableUntil: null };
return false;
}
async function generateAndCopySetupURI(
host: SetupFeatureHost,
log: LogFunction,
removeProperties: (keyof ObsidianLiveSyncSettings)[],
skipDefaultValue: boolean
) {
const passphrase = await askEncryptingPassphrase(host);
if (passphrase === false) return;
while (true) {
const choice = await askSetupURIMode(host);
if (choice === false) return;
let result;
try {
result = await encodeTimeBoundSetupURI(host.services.setting.currentSettings(), passphrase, {
mode: choice.mode,
removeProperties,
skipDefaultValue,
});
} catch (error) {
if (
choice.mode === "ephemeral" &&
error instanceof Error &&
error.message === "Setup URI window changed during generation"
) {
continue;
}
throw error;
}
if (result.usableUntil !== choice.usableUntil || !isTimeBoundSetupURIUsableNow(result.usableUntil)) {
continue;
}
if (await host.services.UI.promptCopyToClipboard("Setup URI", result.uri)) {
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
}
return;
}
}
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
await generateAndCopySetupURI(host, log, stripExtra ? ["pluginSyncExtendedSetting"] : [], true);
}
export async function copySetupURIFull(host: SetupFeatureHost, log: LogFunction) {
const encryptingPassphrase = await askEncryptingPassphrase(host);
if (encryptingPassphrase === false) return;
const encryptedURI = await encodeSettingsToSetupURI(
host.services.setting.currentSettings(),
encryptingPassphrase,
[],
false
);
if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
}
await generateAndCopySetupURI(host, log, [], false);
}
export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>) {
@@ -1,119 +1,203 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import {
encodeTimeBoundSetupURI,
getTimeBoundSetupURIUsableUntil,
isTimeBoundSetupURIUsableNow,
} from "@vrtmrz/livesync-commonlib/setup-uri";
import { askEncryptingPassphrase, copySetupURI, copySetupURIFull, useSetupURIFeature } from "./setupUri";
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
return {
encodeSettingsToSetupURI: vi.fn(),
};
});
vi.mock("@vrtmrz/livesync-commonlib/setup-uri", () => ({
encodeTimeBoundSetupURI: vi.fn(),
getTimeBoundSetupURIUsableUntil: vi.fn(),
isTimeBoundSetupURIUsableNow: vi.fn(),
}));
describe("setupObsidian/setupUri", () => {
const usableUntil = Date.parse("2026-10-01T00:00:00Z");
beforeEach(() => {
vi.mocked(getTimeBoundSetupURIUsableUntil).mockReturnValue(usableUntil);
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "obsidian://setup-time-bound ", usableUntil });
vi.mocked(isTimeBoundSetupURIUsableNow).mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
vi.resetAllMocks();
});
it("askEncryptingPassphrase should delegate to confirm.askString", async () => {
const askString = vi.fn(() => "secret");
const host = {
services: {
UI: {
confirm: {
askString,
},
},
},
} as any;
it("uses the existing password prompt", async () => {
const askString = vi.fn(async () => "secret");
const host = { services: { UI: { confirm: { askString } } } } as any;
const result = await askEncryptingPassphrase(host);
expect(result).toBe("secret");
expect(askString).toHaveBeenCalled();
await expect(askEncryptingPassphrase(host)).resolves.toBe("secret");
expect(askString).toHaveBeenCalledWith(
"Encrypt your settings",
"The passphrase to encrypt the setup URI",
"",
true
);
});
it("copySetupURI should return early when user cancels passphrase", async () => {
const promptCopyToClipboard = vi.fn();
const host = {
services: {
setting: {
currentSettings: vi.fn(() => ({ foo: "bar" })),
},
UI: {
confirm: {
askString: vi.fn(() => false),
},
promptCopyToClipboard,
},
},
} as any;
const log = vi.fn();
await copySetupURI(host, log);
expect(encodeSettingsToSetupURI).not.toHaveBeenCalled();
expect(promptCopyToClipboard).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
});
it("copySetupURI should encode with short mode by default", async () => {
const promptCopyToClipboard = vi.fn(() => true);
it("shows the exact Time-bound end at selection and uses it by default", async () => {
const askSelectStringDialogue = vi.fn(async () => "Time-bound");
const promptCopyToClipboard = vi.fn(async () => true);
const currentSettings = { pluginSyncExtendedSetting: true, x: 1 };
const host = {
services: {
setting: {
currentSettings: vi.fn(() => currentSettings),
},
setting: { currentSettings: vi.fn(() => currentSettings) },
UI: {
confirm: {
askString: vi.fn(() => "pass"),
},
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
promptCopyToClipboard,
},
},
} as any;
const log = vi.fn();
vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://value" as any);
await copySetupURI(host, log);
expect(encodeSettingsToSetupURI).toHaveBeenCalledWith(
currentSettings,
"pass",
["pluginSyncExtendedSetting"],
true
expect(askSelectStringDialogue).toHaveBeenCalledWith(
expect.stringContaining("2026"),
["Time-bound", "Compatible (no time limit)", "Cancel"],
{
title: "Setup URI availability",
defaultAction: "Time-bound",
}
);
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://value");
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith(currentSettings, "pass", {
mode: "ephemeral",
removeProperties: ["pluginSyncExtendedSetting"],
skipDefaultValue: true,
});
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "obsidian://setup-time-bound ");
expect(log).toHaveBeenCalled();
});
it("copySetupURIFull should encode with full mode", async () => {
const promptCopyToClipboard = vi.fn(() => true);
it("selects old-reader-compatible output without a time limit and preserves full export settings", async () => {
const promptCopyToClipboard = vi.fn(async () => true);
const currentSettings = { pluginSyncExtendedSetting: true, x: 1 };
const host = {
services: {
setting: {
currentSettings: vi.fn(() => currentSettings),
},
setting: { currentSettings: vi.fn(() => currentSettings) },
UI: {
confirm: {
askString: vi.fn(() => "pass-full"),
askString: vi.fn(async () => "pass"),
askSelectStringDialogue: vi.fn(async () => "Compatible (no time limit)"),
},
promptCopyToClipboard,
},
},
} as any;
const log = vi.fn();
vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://full" as any);
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({
uri: "obsidian://setup-compatible ",
usableUntil: null,
});
await copySetupURIFull(host, log);
expect(encodeSettingsToSetupURI).toHaveBeenCalledWith(currentSettings, "pass-full", [], false);
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://full");
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith(currentSettings, "pass", {
mode: "persistent",
removeProperties: [],
skipDefaultValue: false,
});
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "obsidian://setup-compatible ");
expect(log).toHaveBeenCalled();
});
it("keeps Compatible available when the clock is invalid", async () => {
vi.mocked(getTimeBoundSetupURIUsableUntil).mockImplementation(() => {
throw new Error("Invalid Setup URI clock");
});
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "compatible", usableUntil: null });
const askSelectStringDialogue = vi.fn(async () => "Compatible (no time limit)");
const host = {
services: {
setting: { currentSettings: vi.fn(() => ({})) },
UI: {
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
promptCopyToClipboard: vi.fn(async () => false),
},
},
} as any;
await copySetupURI(host, vi.fn());
expect(askSelectStringDialogue).toHaveBeenCalledWith(
expect.stringContaining("valid device clock"),
["Compatible (no time limit)", "Cancel"],
expect.objectContaining({ defaultAction: "Compatible (no time limit)" })
);
});
it("does not generate after password or mode cancellation", async () => {
const askString = vi.fn(async (): Promise<string | false> => false);
const askSelectStringDialogue = vi.fn(async () => "Cancel");
const promptCopyToClipboard = vi.fn();
const host = {
services: {
setting: { currentSettings: vi.fn(() => ({})) },
UI: { confirm: { askString, askSelectStringDialogue }, promptCopyToClipboard },
},
} as any;
await copySetupURI(host, vi.fn());
expect(askSelectStringDialogue).not.toHaveBeenCalled();
askString.mockResolvedValueOnce("pass");
await copySetupURI(host, vi.fn());
expect(encodeTimeBoundSetupURI).not.toHaveBeenCalled();
expect(promptCopyToClipboard).not.toHaveBeenCalled();
});
it("keeps an empty passphrase distinct from cancelling the existing prompt", async () => {
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "compatible", usableUntil: null });
const promptCopyToClipboard = vi.fn(async () => false);
const host = {
services: {
setting: { currentSettings: vi.fn(() => ({})) },
UI: {
confirm: {
askString: vi.fn(async () => ""),
askSelectStringDialogue: vi.fn(async () => "Compatible (no time limit)"),
},
promptCopyToClipboard,
},
},
} as any;
await copySetupURI(host, vi.fn());
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith({}, "", expect.objectContaining({ mode: "persistent" }));
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "compatible");
});
it("asks again if the window changes after selection", async () => {
const nextUntil = usableUntil + 604_800_000;
vi.mocked(getTimeBoundSetupURIUsableUntil).mockReturnValueOnce(usableUntil).mockReturnValueOnce(nextUntil);
vi.mocked(encodeTimeBoundSetupURI)
.mockResolvedValueOnce({ uri: "old-window", usableUntil: nextUntil })
.mockResolvedValueOnce({ uri: "new-window", usableUntil: nextUntil });
const askSelectStringDialogue = vi.fn(async () => "Time-bound");
const promptCopyToClipboard = vi.fn(async () => false);
const host = {
services: {
setting: { currentSettings: vi.fn(() => ({})) },
UI: {
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
promptCopyToClipboard,
},
},
} as any;
await copySetupURI(host, vi.fn());
expect(askSelectStringDialogue).toHaveBeenCalledTimes(2);
expect(promptCopyToClipboard).toHaveBeenCalledTimes(1);
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "new-window");
});
it("useSetupURIFeature should register onLoaded handler that wires commands and event", async () => {
const addHandler = vi.fn();
const addCommand = vi.fn();
+14 -1
View File
@@ -1,7 +1,7 @@
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
P2P_CHECK_APP_ID,
@@ -25,6 +25,7 @@ describe("P2P connection-check setup", () => {
expect(generated.target).toBe(target);
expect(generated.setupPassphrase).toMatch(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/);
expect(generated.setupURI).toMatch(/^obsidian:\/\/setuplivesync\?settings=/);
expect(generated.setupURIUsableUntil).toBeGreaterThan(Date.now());
expect(effective).toEqual(
expect.objectContaining({
remoteType: REMOTE_P2P,
@@ -94,6 +95,18 @@ describe("P2P connection-check setup", () => {
}
);
it("uses the current UTC window and rejects the generated URI at its boundary", async () => {
const clock = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-28T12:00:00Z"));
try {
const generated = await generateP2PCheckSetup("desktop");
expect(generated.setupURIUsableUntil).toBe(Date.parse("2026-10-01T00:00:00Z"));
clock.mockReturnValue(generated.setupURIUsableUntil);
await expect(decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase)).rejects.toThrow();
} finally {
clock.mockRestore();
}
});
it("creates independent rooms and secrets for separate checks", async () => {
const first = await generateP2PCheckSetup("desktop");
const second = await generateP2PCheckSetup("desktop");
@@ -169,3 +169,41 @@ Deno.test({
}
},
});
Deno.test({
name: "WebPeer: an imported device can still be checked after its Setup URI window ends",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(webPeerDist);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
try {
await page.goto(`${server.baseUrl}check.html`);
await page.getByRole("button", { name: "Prepare desktop check", exact: true }).click();
await page.getByAltText("Setup URI QR code for the desktop check", { exact: true }).waitFor();
// The target device has imported the URI; only the browser monitor is still pending.
await page.evaluate(() => {
const originalNow = Date.now;
Date.now = () => originalNow() + 8 * 24 * 60 * 60 * 1_000;
window.dispatchEvent(new Event("focus"));
});
await page.getByText(/This Setup URI is outside its time window/).first().waitFor();
assertEquals(await page.getByLabel("Setup URI", { exact: true }).count(), 0);
assertEquals(await page.getByAltText("Setup URI QR code for the desktop check").count(), 0);
assertEquals(
await page.getByRole("button", { name: "Start connection monitor", exact: true }).isEnabled(),
true
);
} finally {
await page.close();
}
} finally {
await browser.close();
await server.close();
}
},
});
+33 -6
View File
@@ -133,9 +133,9 @@ The Harness also measures ID generation with fixed in-memory data on desktop and
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, RustFS, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI and QR round trips, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, internal Metadata Doctor, and setting Markdown export in sequence. Start the local CouchDB, RustFS, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents.
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies that the marker is initialised without a compatibility dialogue, reminder, or runtime pause. It then creates a note in real Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the Metadata document and its Chunks.
The same workflow checks the two remote-activity status boundaries. It first holds a real CouchDB request at the selected fetch implementation and confirms that `🌐N` is visible while `📲` is absent. It then holds the real one-shot replication immediately before its replicator call, confirms that `📲` is visible while no physical request is active, releases it, and requires the finite and bounded activity counts to return to zero, the request and response counts to balance, and both indicators to disappear. Finally, it creates a remote-only chunk, holds the real on-demand fetch immediately before its remote call, makes the same logical active and idle assertions, and verifies that the fetched chunk is written into the local database. These gates make the active states deterministic without replacing the remote request or operation.
@@ -153,7 +153,7 @@ The ordinary workflow now checks that all three ID-configuration radio choices a
If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option, and the Security Seed reconnect workflow captures each significant application state. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory.
The two-Vault workflow performs the missing-marker review once for each isolated Vault. Later process launches reuse the same profile-backed acknowledgement, rather than seeding a replacement or repeatedly applying a decision for the first device. The Hidden File Sync scenario is narrower: it starts from an explicitly acknowledged marker because it tests consumer-owned hidden-file behaviour, JSON resolution, target filtering, and grouped mobile Notices rather than duplicating the compatibility workflow. After `app.emulateMobile(true)`, its fixture operations use the active DevTools renderer because Obsidian can remove desktop-only CLI commands in mobile mode.
The two-Vault workflow verifies that each isolated Vault initialises its missing marker without a compatibility pause. Later process launches reuse the same profile-backed acknowledgement. The Hidden File Sync scenario is narrower: it starts from an explicitly acknowledged marker because it tests consumer-owned hidden-file behaviour, JSON resolution, target filtering, and grouped mobile Notices rather than duplicating the compatibility workflow. After `app.emulateMobile(true)`, its fixture operations use the active DevTools renderer because Obsidian can remove desktop-only CLI commands in mobile mode.
The two-Vault workflow also covers independent ID derivation with two real Obsidian sessions: a note travels in each direction, both devices retain the same obfuscated document IDs, and identical content reuses the same Chunk IDs. Fresh devices with a different ID key or legacy ID configuration must be rejected by ordinary CouchDB replication before any remote document or checkpoint changes. Set `E2E_OBSIDIAN_ONLY_INDEPENDENT_IDS=true` to run that case without the other two-Vault scenarios.
@@ -185,7 +185,11 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
Set `E2E_OBSIDIAN_INDEPENDENT_IDS=true` to run the same upload with E2EE, Path Obfuscation, and a separately derived ID key. The scenario verifies the local document and Chunk ID shapes before the Journal transfer.
Set `E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER=true` when the local Object Storage fixture does not allow browser requests from Obsidian's renderer.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique Object Storage prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies the A-to-B note through explicit replication, then verifies that the B-to-A note arrives through `syncOnStart` after restarting the first device, without requesting manual replication. It captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique Object Storage prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies the A-to-B note through explicit replication, then verifies that the B-to-A note arrives through `syncOnStart` after restarting the first device, without requesting manual replication. It captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. The test requires the current version marker and absence of a compatibility pause after Fetch and after restarting the same Vault, without accepting a review automatically.
`test:e2e:obsidian:object-storage-qr-workflow` runs the same Object Storage round trip with QR settings on the second device. It takes the first device's settings, assigns a distinct database suffix in the QR fixture, encodes them with Commonlib's QR encoder, passes the payload to the real QR decoding entry point, and selects **Join this device** in the visible dialogue. Unlike the Setup URI, the QR payload includes a database suffix; explicitly choosing one makes the namespace change independent of Obsidian's initial defaults. Before Fetch begins, the scenario verifies that the imported namespace has its current compatibility marker without a pause. Fetch then selects the receiving device's own suffix when resetting the local database. The test verifies the marker and absence of a pause again after Fetch and after a natural restart. It covers the QR settings and setup flow, without requiring a camera or exercising operating-system URI dispatch.
`test:e2e:obsidian:object-storage-compatible-setup-uri-workflow` selects **Compatible (no time limit)** in the real generation dialogue and uses Persistent mode for the bootstrap tool. All three Object Storage sharing scenarios require the generated independent ID key to survive import and natural restarts on both devices, remain encrypted in local settings, and retain document and Chunk IDs during the bidirectional transfer. Before valid setup, they submit an incorrect passphrase and a URI generated in a past window, require the visible rejection, and verify unchanged runtime and persisted settings. Only an isolated fixture worker uses the past clock; Obsidian and the runner use real time.
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
@@ -197,6 +201,8 @@ Set `E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER=true` when the local Object Storage fixtur
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
`E2E_OBSIDIAN_ONLY_SETUP_URI_GENERATION=true npm run test:e2e:obsidian:focused -- dialog-mounts` runs the Setup URI generation slice in an isolated real Obsidian Vault without a CouchDB service. It checks the Time-bound choice and displayed end time, the generated URI's round trip, Compatible encryption with the original passphrase, and desktop and mobile dialogue layout. Unit and protocol tests cover the time-window boundary without changing the host clock.
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside the ordinary workflow because they require directory-aware rename handling.
During focused development, `E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION=true` runs an Issue #1168 check which renames `parent/test3` to `parent/Test3` through external `node:fs/promises.rename` while Vault A is open, and verifies that the note content, Metadata, and Chunk references are not logically deleted locally, remotely, or after restart. It accepts either case spelling on Vault B, so it does not provide directory rename support or exact case convergence between devices. The natural Obsidian event sequence and resulting database state are evidence for the selected build; an existing-version reproduction result must be reported separately from fixed-version safety evidence.
@@ -227,11 +233,13 @@ The workflow also opens the Customisation Sync dialogue. `--case=visibility` che
`test:e2e:obsidian:internal-metadata-migration` enables internal Metadata encryption through the settings UI without Rebuild. It checks unchanged plaintext and rewritten encrypted Hidden File Sync and Customisation Sync Metadata in CouchDB, stable document IDs, mismatch rejection on a second device, and file restoration after aligning settings. It then turns the preference OFF, runs Fast Fetch, and compares content loaded from both Metadata representations and their Chunks while retaining the remote feature declaration. These focused tests use the local CouchDB fixture and are outside `test:e2e:obsidian:local-suite`.
`test:e2e:obsidian:internal-metadata-doctor` reuses the migration fixture and enables encryption through the real Config Doctor dialogues. It checks declining the consultation, skipping the recommendation with a reminder, dismissing the current Doctor version, and accepting the recommendation through **Run Doctor** after dismissal. Each choice is checked against active and persisted settings, with natural restarts of the same Vault and profile verifying reminders and retained choices. A local database sentinel, start-up flag checks, unchanged remote documents, and renderer identity checks detect an unintended automatic Rebuild, Fetch, or restart. The accepted setting then follows the two-device migration and Fast Fetch checks above. This scenario requires CouchDB and is included in `test:e2e:obsidian:local-suite`; run it separately with `npm run test:e2e:obsidian:focused -- internal-metadata-doctor` after starting the CouchDB fixture.
`test:e2e:obsidian:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`, including both the plaintext ID key and its encrypted local representation.
`test:e2e:obsidian:upgrade-from-stable` is the release-acceptance upgrade workflow. It installs the exact published 0.25.83 artefacts into an isolated Vault, verifies their pinned SHA-256 values, and then replaces only the plug-in artefacts with the current target while retaining the same Vault and isolated Obsidian profile. The first run downloads the old release into the ignored `_testdata/releases` cache; every later run verifies the cached bytes before use.
The workflow first exercises a non-empty legacy settings document which has no `isConfigured` or file-name case value. It verifies that 0.25.83 treats a default-equivalent document as unconfigured. That release can persist the inferred boolean during a later, unrelated settings-save event, so the runner accepts either an absent value or the inferred `false` on disk, then restores the same minimal pre-flag document deliberately before installing 1.0. The target independently proves its direct migration: the Vault remains unconfigured instead of receiving new-Vault recommendations, case-insensitive handling becomes explicit, no compatibility pause or acknowledgement marker is created while onboarding remains pending, and a second 1.0 start is idempotent. The absent marker is deliberately deferred rather than accepted; a later configured start must evaluate it. This fixture rewrite is limited to the missing-flag boundary; the configured transport upgrades use only state created and saved by 0.25.83 itself.
The workflow first exercises a non-empty legacy settings document which has no `isConfigured` or file-name case value. It verifies that 0.25.83 treats a default-equivalent document as unconfigured. That release can persist the inferred boolean during a later, unrelated settings-save event, so the runner accepts either an absent value or the inferred `false` on disk, then restores the same minimal pre-flag document deliberately before installing 1.0. The target independently proves its direct migration: the Vault remains unconfigured instead of receiving new-Vault recommendations, case-insensitive handling becomes explicit, no compatibility pause or acknowledgement marker is created while onboarding remains pending, and a second 1.0 start is idempotent. The absent marker is deliberately deferred while onboarding is pending; a later configured start records the current version if no other review is required. This fixture rewrite is limited to the missing-flag boundary; the configured transport upgrades use only state created and saved by 0.25.83 itself.
For CouchDB and Object Storage, the workflow then configures 0.25.83 from its own defaults, saves the selected remote, and restarts that release with the same profile before creating history. This both verifies that the old settings persist and lets the old release initialise its replicator from the same saved state as an ordinary existing Vault. The runner waits for that release's asynchronously initialised persistent node identity, creates, edits, renames, and deletes notes, and synchronises each transition before installing the target. Every launch of the upgraded device uses the same isolated Obsidian profile. The session layer closes the renderer before its process-tree fallback, so Chromium persists the legacy compatibility marker naturally; the target must read and migrate that actual profile state to its current namespaced key. The final target restart likewise consumes the marker persisted by the preceding target session. The runner does not reconstruct that device's Vault data, plug-in settings, local database files, device-local state, or remote state. Before the target performs any synchronisation, it must retain the same Vault profile, local database, node identity, remote profile, local checkpoint, and remote milestone. The local node-info document is the identity source of truth; a transient replicator field is used only to confirm that the old asynchronous initialisation has completed. Its first synchronisation must be a no-op: CouchDB document revisions and `update_seq` must remain unchanged, while Object Storage must neither upload nor download journal bodies. The upgraded device then sends a new delta. A separate fresh 1.0 verifier starts from an explicit fixture containing settings and compatibility state for the current version, receives the complete surviving history, and returns another delta; it is not part of the migration assertion for legacy remote settings. The upgraded Vault receives that return journey and retains it across restart.
@@ -262,7 +270,26 @@ Or let the wrapper manage both fixtures:
npm run test:e2e:obsidian:local-suite:services
```
Useful environment variables:
### Combined setup and security regression checks
Build the current plug-in once, then run these focused scenarios sequentially with their documented fixtures:
| Coverage | Scenario or command |
| --- | --- |
| Time-bound URI, independent key, rejection, restart, and two-way Object Storage transfer | `npm run test:e2e:obsidian:object-storage-setup-uri-workflow` |
| Compatible URI with the same key and transfer checks | `npm run test:e2e:obsidian:object-storage-compatible-setup-uri-workflow` |
| QR import, changed database suffix, key persistence, and two-way transfer | `npm run test:e2e:obsidian:object-storage-qr-workflow` |
| Custom ID source, recovery code, encrypted local storage, and CouchDB Setup URI transfer | `E2E_OBSIDIAN_INDEPENDENT_IDS=true npm run test:e2e:obsidian:couchdb-manual-setup-workflow` |
| Matching IDs and rejection of incompatible document keys before remote writes | `E2E_OBSIDIAN_ONLY_INDEPENDENT_IDS=true npm run test:e2e:obsidian:two-vault-sync` |
| Doctor decline, reminder, dismissal, later acceptance, and mixed internal Metadata | `npm run test:e2e:obsidian:internal-metadata-doctor` |
The setup-tool contract suite also checks ID recovery and explicit legacy IDs in both URI modes. `dialog-mounts` covers the availability dialogue and setup choices on desktop and emulated mobile. These automated scenarios remove the need to repeat every decision path manually during BRAT acceptance.
BRAT acceptance validates the exact published artefacts: install or update through BRAT, cold-start Obsidian, and exchange one note in each direction. Build behaviour can be checked before publication using the exact reviewed build; record its identity and avoid repeating the same decision paths during BRAT acceptance.
A short physical-device check is optional when a specific concern remains about localised time text, input, clipboard interaction, or responsiveness. The camera and operating-system dispatch paths are unchanged by this integration and do not require routine revalidation. Emulated mobile establishes layout and interaction, but does not establish native device performance or verify the published installation path.
### Environment variables
- `OBSIDIAN_BINARY`: explicit Obsidian executable path.
- `OBSIDIAN_CLI`: explicit companion `obsidian-cli` executable path.
+22 -79
View File
@@ -6,7 +6,7 @@ import { type ObsidianLiveSyncSettings, VER } from "@vrtmrz/livesync-commonlib/c
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { CouchDbConfig } from "./couchdb.ts";
import type { ObjectStorageConfig } from "./objectStorage.ts";
import { captureObsidianDialogue, withObsidianPage } from "./ui.ts";
import { withObsidianPage } from "./ui.ts";
export type ConfiguredSettings = {
isConfigured: boolean;
@@ -52,11 +52,6 @@ export type CompatibilityMarkerWaitOptions = {
intervalMs?: number;
};
export type ResumeCompatibilityReviewOptions = {
verifyMissingDeviceMarkerExplanation?: boolean;
screenshotPrefix?: string;
};
export type ObsidianServiceContextContractResult = {
contextType: string;
eventResult: string[];
@@ -154,83 +149,31 @@ export async function assertE2eCompatibilityMarker(
return state;
}
export async function assertE2eCompatibilityReviewPending(
export async function assertE2eCompatibilityUnpaused(
cliBinary: string,
env: NodeJS.ProcessEnv
env: NodeJS.ProcessEnv,
port: number
): Promise<CompatibilityMarkerState> {
const state = await readE2eCompatibilityMarker(cliBinary, env);
if (state.serviceValue !== "" || state.rawStorageValue !== null || state.versionUpFlash === "") {
throw new Error(`The copied-Vault compatibility review was not pending: ${JSON.stringify(state)}`);
}
return state;
}
export async function resumeCompatibilityReview(
port: number,
options: ResumeCompatibilityReviewOptions = {}
): Promise<void> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
const title = "Synchronisation paused for compatibility review";
const summaryLocator = (page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) =>
page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: title }),
});
if (options.screenshotPrefix) {
const summaryScreenshot = await captureObsidianDialogue(
port,
`${options.screenshotPrefix}-summary.png`,
async (page) => {
await summaryLocator(page).waitFor({ state: "visible", timeout: timeoutMs });
}
);
console.log(`Compatibility review summary screenshot: ${summaryScreenshot}`);
}
if (options.verifyMissingDeviceMarkerExplanation === true) {
await withObsidianPage(port, async (page) => {
const summary = summaryLocator(page);
await summary.waitFor({ state: "visible", timeout: timeoutMs });
await summary.getByRole("button", { name: "Review compatibility details" }).click();
});
const detailsScreenshot = options.screenshotPrefix
? await captureObsidianDialogue(port, `${options.screenshotPrefix}-details.png`, async (page) => {
const details = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await details.waitFor({ state: "visible", timeout: timeoutMs });
await details.getByText("copied or restored", { exact: false }).waitFor({
state: "visible",
timeout: timeoutMs,
});
await details.getByText("new Obsidian profile", { exact: false }).waitFor({
state: "visible",
timeout: timeoutMs,
});
await details
.getByText("does not mean that it is safe to resume automatically", { exact: false })
.waitFor({
state: "visible",
timeout: timeoutMs,
});
})
: undefined;
if (detailsScreenshot) console.log(`Compatibility review details screenshot: ${detailsScreenshot}`);
await withObsidianPage(port, async (page) => {
const details = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await details.getByRole("button", { name: "Back to compatibility review" }).click();
await summaryLocator(page).waitFor({ state: "visible", timeout: timeoutMs });
});
}
const state = await assertE2eCompatibilityMarker(cliBinary, env);
assertEqual(state.versionUpFlash, "", "Compatibility review unexpectedly paused synchronisation.");
await withObsidianPage(port, async (page) => {
const summary = summaryLocator(page);
await summary.waitFor({ state: "visible", timeout: timeoutMs });
await summary.getByRole("button", { name: "Resume synchronisation" }).click();
await summary.waitFor({ state: "hidden", timeout: timeoutMs });
for (const candidate of page.context().pages()) {
assertEqual(
await candidate
.locator(".modal-container:visible")
.filter({ hasText: "Synchronisation paused for compatibility review" })
.count(),
0,
"An unexpected compatibility review dialogue appeared."
);
assertEqual(
await candidate.locator(".notice.livesync-compatibility-review-notice:visible").count(),
0,
"An unexpected compatibility review reminder appeared."
);
}
});
return state;
}
export function createE2eCouchDbPluginData(
+59 -2
View File
@@ -73,7 +73,8 @@ export async function enterSetupURI(
port: number,
mode: "new" | "existing",
artifact: SetupArtifact,
captures: SetupCaptureNames
captures: SetupCaptureNames,
rejectedArtifacts: readonly SetupArtifact[] = []
): Promise<string> {
await withObsidianPage(port, async (page) => {
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
@@ -101,6 +102,40 @@ export async function enterSetupURI(
const setup = modalByTitle(page, "Enter Setup URI");
await setup.waitFor({ state: "visible", timeout: uiTimeoutMs });
const settingsSnapshot = () =>
page.evaluate(async () => {
const obsidian = globalThis as typeof globalThis & {
app: {
plugins: {
plugins: Record<
string,
{
core: { services: { setting: { currentSettings(): unknown } } };
loadData(): Promise<unknown>;
}
>;
};
};
};
const plugin = obsidian.app.plugins.plugins["obsidian-livesync"];
return JSON.stringify({
current: plugin.core.services.setting.currentSettings(),
persisted: await plugin.loadData(),
});
});
const before = rejectedArtifacts.length > 0 ? await settingsSnapshot() : undefined;
for (const rejected of rejectedArtifacts) {
await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(rejected.setupURI);
await setup.locator('input[name="password"]').fill(rejected.setupPassphrase);
await setup.getByRole("button", { name: "Test Settings and Continue" }).click({ timeout: uiTimeoutMs });
await setup.getByText($msg("Failed to parse Setup-URI."), { exact: false }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await settingsSnapshot()) !== before) {
throw new Error("A rejected Setup URI changed the receiving device's settings.");
}
}
await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI);
await setup.locator('input[name="password"]').fill(artifact.setupPassphrase);
});
@@ -120,7 +155,8 @@ export async function enterSetupURI(
export async function generateSetupURIFromDevice(
port: number,
setupPassphrase: string,
captures: SetupCaptureNames
captures: SetupCaptureNames,
mode: "ephemeral" | "persistent" = "ephemeral"
): Promise<{ artifact: SetupArtifact; screenshots: string[] }> {
const opened = await withObsidianPage(port, async (page) => {
return await page.evaluate(
@@ -150,6 +186,18 @@ export async function generateSetupURIFromDevice(
const prompt = modalByTitle(page, promptTitle);
await prompt.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
await prompt.waitFor({ state: "hidden", timeout: uiTimeoutMs });
const choice = modalByTitle(page, "Setup URI availability");
await choice.waitFor({ state: "visible", timeout: uiTimeoutMs });
await choice.getByText("Time-bound Setup URIs can be opened until", { exact: false }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await choice
.getByRole("button", {
name: mode === "ephemeral" ? "Time-bound" : "Compatible (no time limit)",
exact: true,
})
.click({ timeout: uiTimeoutMs });
});
const resultTitle = "Your Setup URI is ready to be copied";
@@ -400,6 +448,15 @@ export async function finishInitialisation(
let readySince: number | undefined;
while (Date.now() < deadline) {
const resumeVisible = await withObsidianPage(port, async (page) => {
const alignedSettingsNotice = page.locator(".modal-container").filter({
hasText:
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
});
if (await alignedSettingsNotice.isVisible()) {
await alignedSettingsNotice
.getByRole("button", { name: "OK", exact: true })
.click({ timeout: uiTimeoutMs });
}
return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible();
}).catch(() => false);
if (resumeVisible) {
@@ -35,15 +35,13 @@ vi.mock("./pathAssertions.ts", () => ({
vi.mock("./liveSyncWorkflow.ts", () => ({
assertEqual: vi.fn(),
assertE2eCompatibilityMarker: vi.fn(async () => undefined),
assertE2eCompatibilityReviewPending: vi.fn(async () => undefined),
assertE2eCompatibilityUnpaused: vi.fn(async () => undefined),
configureCouchDb: vi.fn(async () => undefined),
createE2eCouchDbPluginData: vi.fn(() => ({})),
prepareRemote: vi.fn(async () => undefined),
pushLocalChanges: vi.fn(async () => {
throw new Error("simulated Obsidian CLI timeout");
}),
resumeCompatibilityReview: vi.fn(async () => undefined),
waitForLiveSyncCoreReady: vi.fn(async () => undefined),
waitForLocalDatabaseEntry: vi.fn(async () => ({ id: "note-id", children: [] })),
}));
@@ -15,7 +15,12 @@ import {
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { assertEqual, pushLocalChanges, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts";
import {
assertEqual,
assertE2eCompatibilityUnpaused,
pushLocalChanges,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
@@ -27,7 +32,6 @@ import {
finishInitialisation,
generateSetupURIFromDevice,
modalByTitle,
resumeCompatibilityReviewIfShown,
selectRadioOption,
continueWithoutRemoteSettings,
type SetupArtifact,
@@ -695,7 +699,7 @@ async function main(): Promise<void> {
screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures));
const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, session.remoteDebuggingPort);
assertEqual(state.activeConfigurationId !== "", true, "Manual CouchDB setup did not activate a profile.");
assertEqual(
state.remoteConfigurationCount,
@@ -731,7 +735,7 @@ async function main(): Promise<void> {
await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, e2eeRebuildCaptures)
);
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, session.remoteDebuggingPort);
await assertPersistedE2EE(vaultA, independentIdSource);
const rebuiltEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
@@ -767,7 +771,7 @@ async function main(): Promise<void> {
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing", captures));
screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort, captures)));
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, session.remoteDebuggingPort);
await assertPersistedE2EE(vaultB, independentIdSource);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForVaultFile(vaultB, notePath, noteContent);
@@ -793,7 +797,7 @@ async function main(): Promise<void> {
session = await startUnconfiguredSession(context, vaultA);
try {
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, session.remoteDebuggingPort);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForVaultFile(vaultA, returnNotePath, returnNoteContent);
if (independentIdSource) {
+2 -9
View File
@@ -11,12 +11,10 @@ import {
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityReviewPending,
assertE2eCompatibilityUnpaused,
configureCouchDb,
createE2eCouchDbPluginData,
prepareRemote,
resumeCompatibilityReview,
waitForLiveSyncCoreReady,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
@@ -113,12 +111,7 @@ async function main(): Promise<void> {
}),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await assertE2eCompatibilityReviewPending(cli.binary, session.cliEnv);
await resumeCompatibilityReview(session.remoteDebuggingPort, {
verifyMissingDeviceMarkerExplanation: true,
screenshotPrefix: "compatibility-review-copied-vault",
});
await assertE2eCompatibilityMarker(cli.binary, session.cliEnv);
await assertE2eCompatibilityUnpaused(cli.binary, session.cliEnv, session.remoteDebuggingPort);
const configured = await configureCouchDb(cli.binary, session.cliEnv, {
uri: couchDb.uri,
+132 -2
View File
@@ -1,5 +1,7 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
import { $msg } from "../../../src/common/translation.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
@@ -53,7 +55,7 @@ type ObsidianVaultFile = {
};
type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
commands?: { commands?: Record<string, unknown>; executeCommandById(commandId: string): boolean };
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
vault?: {
delete(file: ObsidianVaultFile, force: boolean): Promise<void>;
@@ -564,7 +566,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
);
const resetNotice = compatibility.locator(".sls-info-note-notice").filter({
hasText:
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.",
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
});
await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs });
await resetNotice
@@ -656,6 +658,116 @@ async function verifySetupUriDialogue(mode: DialogueMode): Promise<string> {
return screenshotPath;
}
async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<string> {
const passphrase = "dialogue-test-passphrase";
const port = obsidianRemoteDebuggingPort();
const modalByTitle = (page: import("playwright").Page, title: string) =>
page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: title }),
});
const openAndEnterPassphrase = async () => {
const opened = await withObsidianPage(port, async (page) => {
await page.waitForFunction(
(commandId) => {
const app = (globalThis as ObsidianTestGlobal).app;
return Boolean(
app?.plugins?.plugins["obsidian-livesync"]?.core?.settings.isConfigured &&
app.commands?.commands?.[commandId]
);
},
"obsidian-livesync:livesync-copysetupuri",
{ timeout: uiTimeoutMs }
);
return await page.evaluate(
(commandId) =>
(globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:livesync-copysetupuri"
);
});
if (!opened) throw new Error("The Setup URI generation command was not registered.");
await withObsidianPage(port, async (page) => {
const prompt = modalByTitle(page, "Encrypt your settings");
await prompt.waitFor({ state: "visible", timeout: uiTimeoutMs });
await prompt.locator('input[type="password"]').fill(passphrase);
await prompt.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
});
};
const selectMode = async (selected: "Time-bound" | "Compatible (no time limit)") => {
if (selected === "Time-bound") {
await captureObsidianDialogue(
port,
`setup-uri-availability${mode === "mobile" ? "-mobile" : ""}.png`,
async (page) => {
const choice = modalByTitle(page, "Setup URI availability");
await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
);
}
await withObsidianPage(port, async (page) => {
const choice = modalByTitle(page, "Setup URI availability");
await choice.waitFor({ state: "visible", timeout: uiTimeoutMs });
await choice.getByText("Time-bound Setup URIs can be opened until", { exact: false }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({
state: "visible", timeout: uiTimeoutMs,
});
if (mode === "mobile") await assertMobileDialogueLayout(page, choice, "Setup URI availability dialogue");
await choice.getByRole("button", { name: selected, exact: true }).click({ timeout: uiTimeoutMs });
});
};
const getResultURI = async () =>
await withObsidianPage(port, async (page) => {
const result = modalByTitle(page, "Your Setup URI is ready to be copied");
await result.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (mode === "mobile") await assertMobileDialogueLayout(page, result, "Generated Setup URI dialogue");
return await result.locator("textarea[readonly]").inputValue();
});
const closeResult = async () => {
await withObsidianPage(port, async (page) => {
const result = modalByTitle(page, "Your Setup URI is ready to be copied");
await result.getByRole("button", { name: $msg("Ok"), exact: true }).click({ timeout: uiTimeoutMs });
await result.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
};
await openAndEnterPassphrase();
await selectMode("Time-bound");
const uri = await getResultURI();
if (!uri.startsWith("obsidian://setuplivesync?settings=")) {
throw new Error("The generation dialogues did not produce a Setup URI.");
}
const decoded = await decodeSettingsFromSetupURI(uri, passphrase);
if (!decoded || !decoded.isConfigured) throw new Error("The generated Time-bound URI could not be opened.");
const screenshot = await captureObsidianDialogue(
port,
`generated-setup-uri${mode === "mobile" ? "-mobile" : ""}.png`,
async (page) => {
const result = modalByTitle(page, "Your Setup URI is ready to be copied");
await result.locator("textarea[readonly]").waitFor({ state: "visible", timeout: uiTimeoutMs });
}
);
await closeResult();
if (mode === "desktop") {
await openAndEnterPassphrase();
await selectMode("Compatible (no time limit)");
const compatibleURI = await getResultURI();
const encrypted = new URL(compatibleURI).searchParams.get("settings");
if (!encrypted?.startsWith("%$")) throw new Error("Compatible changed the encrypted URI format.");
const oldFormatSettings = JSON.parse(await decryptString(encrypted, passphrase)) as Record<string, unknown>;
if (oldFormatSettings.isConfigured !== true) {
throw new Error("Compatible did not retain the original passphrase encryption format.");
}
await closeResult();
}
return screenshot;
}
async function verifyCompatibleMismatchAutoAdjustment(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate((stateKey) => {
@@ -1191,6 +1303,7 @@ async function main(): Promise<void> {
},
{
notifyThresholdOfRemoteStorageSize: -1,
versionUpFlash: "Review an earlier compatibility change before synchronisation resumes.",
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
@@ -1228,6 +1341,19 @@ async function main(): Promise<void> {
throw error;
}
if (process.env.E2E_OBSIDIAN_ONLY_SETUP_URI_GENERATION === "true") {
const desktopScreenshot = await verifyGenerateSetupUriDialogue("desktop");
console.log(`Desktop Setup URI generation passed. Screenshot: ${desktopScreenshot}`);
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
try {
const mobileScreenshot = await verifyGenerateSetupUriDialogue("mobile");
console.log(`Mobile Setup URI generation passed. Screenshot: ${mobileScreenshot}`);
} finally {
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
}
return;
}
const remoteSizeScreenshots = await verifyRemoteSizeNoticeAndDialogue();
console.log(
`Compatibility review actions were stacked vertically, and the remote-size startup notice opened an untimed review dialogue successfully. Screenshots: ${remoteSizeScreenshots.compatibilityReview}, ${remoteSizeScreenshots.notice}, ${remoteSizeScreenshots.dialogue}`
@@ -1245,6 +1371,8 @@ async function main(): Promise<void> {
);
const setupUriScreenshot = await verifySetupUriDialogue("desktop");
console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`);
const generatedSetupUriScreenshot = await verifyGenerateSetupUriDialogue("desktop");
console.log(`Time-bound and Compatible generation passed. Screenshot: ${generatedSetupUriScreenshot}`);
await verifyCompatibleAlignmentSettingDefault();
console.log("The undefined compatible-setting preference is displayed with its effective enabled default.");
const mismatchScreenshots = await verifyConfigurationMismatchDialogues();
@@ -1278,6 +1406,8 @@ async function main(): Promise<void> {
console.log(
`Mobile Setup URI dialogue passed viewport, safe-area, and touch-target checks. Screenshot: ${mobileSetupUriScreenshot}`
);
const mobileGeneratedSetupUriScreenshot = await verifyGenerateSetupUriDialogue("mobile");
console.log(`Mobile Time-bound generation passed. Screenshot: ${mobileGeneratedSetupUriScreenshot}`);
} finally {
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
}
@@ -1,5 +1,10 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import {
FlagFilesHumanReadable,
FlagFilesOriginal,
} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
import { VERSIONING_DOCID, type LoadedEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { ENCRYPTED_INTERNAL_METADATA_FEATURE } from "@vrtmrz/livesync-commonlib/replication";
@@ -28,6 +33,9 @@ import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
const useDoctor = process.argv.includes("--doctor");
const doctorTitle = "Self-hosted LiveSync Config Doctor";
const enableWithoutRebuild = "Enable without rebuilding — update every other device first";
const hiddenPaths = [".metadata-migration/retained.json", ".metadata-migration/rewritten.json"];
const customPaths = [".obsidian/snippets/retained-metadata.css", ".obsidian/snippets/rewritten-metadata.css"];
const paths = [...hiddenPaths, ...customPaths];
@@ -71,7 +79,11 @@ async function main(): Promise<void> {
);
};
const start = async (vault: TemporaryVault, device: string) => {
const settings = { ...optionSettings, deviceAndVaultName: device };
const settings = {
...optionSettings,
deviceAndVaultName: device,
doctorProcessedVersion: DoctorRegulation.version,
};
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
@@ -132,6 +144,123 @@ async function main(): Promise<void> {
"The encrypted internal Metadata declaration was not retained."
);
};
const enableWithDoctor = async () => {
const previousVersion = "1.0.0";
const sentinelId = "_local/e2e-config-doctor";
await evaluate(`await core.services.setting.applyPartial({doctorProcessedVersion:${JSON.stringify(previousVersion)}},true);
await core.localDatabase.localDatabase.put({_id:${JSON.stringify(sentinelId)},value:'preserved'});
return JSON.stringify(true);`);
const restart = async () => {
await session!.app.stop();
session = undefined;
// Retain the same data.json, profile, and local database on natural start-up.
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: source });
return await evaluate<number>("return JSON.stringify(performance.timeOrigin);");
};
const choose = async (title: string, choice: string) => {
await withObsidianPage(session!.remoteDebuggingPort, async (page) => {
const dialog = await waitForVisibleObsidianDialogue(page, title);
await dialog.getByRole("button", { name: choice, exact: true }).click();
await dialog.waitFor({ state: "hidden" });
});
};
const assertState = async (enabled: boolean, version: string, timeOrigin: number) => {
await waitForLiveSyncCoreReady(cliBinary, session!.cliEnv);
const expected = JSON.stringify([enabled, version]);
const active = await evaluate<[boolean, string]>(
"return JSON.stringify([core.settings.encryptInternalMetadata,core.settings.doctorProcessedVersion]);"
);
assertEqual(JSON.stringify(active), expected, "Doctor left unexpected active settings.");
const settingsPath = join(session!.install.pluginDir, "data.json");
let saved: { encryptInternalMetadata?: boolean; doctorProcessedVersion?: string } = {};
const saveDeadline = Date.now() + 10_000;
do {
saved = JSON.parse(await readFile(settingsPath, "utf8"));
if (JSON.stringify([saved.encryptInternalMetadata, saved.doctorProcessedVersion]) === expected) break;
await new Promise((resolve) => setTimeout(resolve, 100));
} while (Date.now() < saveDeadline);
assertEqual(
JSON.stringify([saved.encryptInternalMetadata, saved.doctorProcessedVersion]),
expected,
"Doctor did not preserve the expected settings on disk."
);
assertEqual(
await evaluate(
`return JSON.stringify((await core.localDatabase.localDatabase.get(${JSON.stringify(sentinelId)})).value);`
),
"preserved",
"Doctor replaced the local database."
);
assertEqual(
await evaluate("return JSON.stringify(performance.timeOrigin);"),
timeOrigin,
"Doctor unexpectedly restarted Obsidian."
);
const flags: string[] = [...Object.values(FlagFilesOriginal), ...Object.values(FlagFilesHumanReadable)];
assertEqual(
(await readdir(source.path)).some((name) => flags.includes(name)),
false,
"Doctor scheduled a Rebuild, Fetch, or suspended start-up."
);
await withObsidianPage(session!.remoteDebuggingPort, async (page) => {
for (const candidate of page.context().pages()) {
assertEqual(
await candidate.locator(".modal-container:visible").count(),
0,
"Doctor left an unexpected dialogue open."
);
}
});
};
let timeOrigin = await restart();
await choose(doctorTitle, "No");
await assertState(false, previousVersion, timeOrigin);
console.log("Declining Doctor leaves encryption OFF and permits another consultation after restart.");
timeOrigin = await restart();
await choose(doctorTitle, "Yes");
await choose("Fix issue 1/1", "Leave it as is");
await choose("Almost done!", "Yes");
await assertState(false, previousVersion, timeOrigin);
console.log("Skipping the Metadata recommendation and requesting a reminder preserves the previous marker.");
timeOrigin = await restart();
await choose(doctorTitle, "No, and do not ask again until the next release");
await assertState(false, DoctorRegulation.version, timeOrigin);
timeOrigin = await restart();
await assertState(false, DoctorRegulation.version, timeOrigin);
console.log(
"Dismissing this Doctor version keeps encryption OFF and suppresses the next start-up consultation."
);
await withObsidianPage(session!.remoteDebuggingPort, async (page) => {
const navigator = await openLiveSyncSettings(page);
const hatch = await navigator.openPage("Hatch");
await hatch.getByRole("button", { name: "Run Doctor", exact: true }).click();
});
await choose(doctorTitle, "Yes");
await withObsidianPage(session!.remoteDebuggingPort, async (page) => {
const dialog = await waitForVisibleObsidianDialogue(page, "Fix issue 1/1");
for (const text of [
"Encrypt internal file Properties",
"manually rebuild the remote database",
"update every synchronising client before enabling it",
]) {
await dialog.getByText(text, { exact: false }).first().waitFor({ state: "visible" });
}
});
assertEqual(
await evaluate("return JSON.stringify(core.settings.encryptInternalMetadata);"),
false,
"Doctor enabled encryption before acceptance."
);
await choose("Fix issue 1/1", enableWithoutRebuild);
await assertState(true, DoctorRegulation.version, timeOrigin);
timeOrigin = await restart();
await assertState(true, DoctorRegulation.version, timeOrigin);
console.log("Manual Doctor acceptance persists across restart without automatic Rebuild, Fetch, or restart.");
};
try {
await assertCouchDbReachable(couchDb);
@@ -153,31 +282,38 @@ async function main(): Promise<void> {
"The original database was not generation 12."
);
await withObsidianPage(session!.remoteDebuggingPort, async (page) => {
const navigator = await openLiveSyncSettings(page);
const remotePage = await navigator.openPage("Remote Configuration");
await remotePage
.locator(".setting-item")
.filter({
has: navigator.page.getByText("Configure E2EE", { exact: true }),
})
.getByRole("button", { name: "Configure", exact: true })
.click();
const dialog = await waitForVisibleObsidianDialogue(navigator.page, "End-to-End Encryption");
await dialog.getByLabel("Encrypt internal file Properties", { exact: true }).check();
await dialog.getByRole("button", { name: "Proceed", exact: true }).click();
const warning = await waitForVisibleObsidianDialogue(navigator.page, "Encrypt internal file Properties");
await warning
.getByRole("button", {
name: "Enable without rebuilding — update every other device first",
exact: true,
})
.click();
});
if (useDoctor) {
await enableWithDoctor();
} else {
await withObsidianPage(session!.remoteDebuggingPort, async (page) => {
const navigator = await openLiveSyncSettings(page);
const remotePage = await navigator.openPage("Remote Configuration");
await remotePage
.locator(".setting-item")
.filter({
has: navigator.page.getByText("Configure E2EE", { exact: true }),
})
.getByRole("button", { name: "Configure", exact: true })
.click();
const dialog = await waitForVisibleObsidianDialogue(navigator.page, "End-to-End Encryption");
await dialog.getByLabel("Encrypt internal file Properties", { exact: true }).check();
await dialog.getByRole("button", { name: "Proceed", exact: true }).click();
const warning = await waitForVisibleObsidianDialogue(
navigator.page,
"Encrypt internal file Properties"
);
await warning
.getByRole("button", {
name: enableWithoutRebuild,
exact: true,
})
.click();
});
}
assertEqual(
await evaluate(`app.setting.close(); return JSON.stringify(core.settings.encryptInternalMetadata);`),
true,
"The setting dialogue did not enable encryption."
"The dialogue did not enable encryption."
);
for (let index = 0; index < entries.length; index++) {
assertEqual(
@@ -211,7 +347,7 @@ async function main(): Promise<void> {
}
await assertDeclaration();
console.log(
"The settings UI enabled encryption without Rebuild; unchanged and encrypted Metadata coexist with stable IDs."
`${useDoctor ? "Doctor" : "The settings UI"} enabled encryption without Rebuild; unchanged and encrypted Metadata coexist with stable IDs.`
);
await session!.app.stop();
session = undefined;
+9
View File
@@ -35,6 +35,14 @@ const testSteps: Step[] = [
name: "Object Storage Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
},
{
name: "Object Storage Compatible Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-compatible-setup-uri-workflow"],
},
{
name: "Object Storage QR workflow",
args: ["run", "test:e2e:obsidian:object-storage-qr-workflow"],
},
{
name: "Object Storage Custom HTTP Handler Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-custom-http-handler-setup-uri-workflow"],
@@ -45,6 +53,7 @@ const testSteps: Step[] = [
{ name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] },
{ name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] },
{ name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] },
{ name: "internal Metadata Doctor", args: ["run", "test:e2e:obsidian:internal-metadata-doctor"] },
{ name: "setting Markdown export", args: ["run", "test:e2e:obsidian:setting-markdown-export"] },
];
@@ -3,10 +3,15 @@ import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { Worker } from "node:worker_threads";
import { encodeSettingsToQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityUnpaused,
pushLocalChanges,
type ConfiguredSettings,
waitForLiveSyncCoreReady,
@@ -29,8 +34,10 @@ import {
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
resumeCompatibilityReviewIfShown,
continueWithoutRemoteSettings,
captureGuideDialogue,
modalByTitle,
selectRadioOption,
type SetupArtifact,
type SetupCaptureNames,
} from "../runner/setupUri.ts";
@@ -46,9 +53,18 @@ process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const execFileAsync = promisify(execFile);
const useCustomRequestHandler = process.argv.includes("--custom-http-handler");
const captures: SetupCaptureNames = useCustomRequestHandler
? { scenario: "object-storage-custom-http-handler-setup-uri", guide: "object-storage-custom-http-handler-setup" }
: { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
const useQRCode = process.argv.includes("--qr");
const uriMode = process.argv.includes("--compatible") ? "persistent" : "ephemeral";
const captures: SetupCaptureNames = useQRCode
? { scenario: "object-storage-qr", guide: "object-storage-qr-setup" }
: uriMode === "persistent"
? { scenario: "object-storage-compatible-uri", guide: "object-storage-compatible-setup" }
: useCustomRequestHandler
? {
scenario: "object-storage-custom-http-handler-setup-uri",
guide: "object-storage-custom-http-handler-setup",
}
: { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
const noteFromFirst = "E2E/object-storage/from-first.md";
const noteFromSecond = "E2E/object-storage/from-second.md";
const firstContent =
@@ -114,12 +130,63 @@ async function generateBootstrapSetupURI(
...(useCustomRequestHandler ? { use_custom_request_handler: "true" } : {}),
passphrase: randomBytes(24).toString("base64url"),
uri_passphrase: setupPassphrase,
uri_mode: uriMode,
});
const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings="));
if (!setupURI) throw new Error("The public Setup URI generator did not emit an Object Storage Setup URI.");
return { setupURI, setupPassphrase };
}
async function expiredSetupURI(settings: ObsidianLiveSyncSettings, setupPassphrase: string): Promise<SetupArtifact> {
// Only this fixture worker uses a past clock; Obsidian and the runner keep real time.
const worker = new Worker(
`
const { parentPort, workerData } = require('node:worker_threads');
Date.now = () => Date.UTC(2024, 0, 1);
import('@vrtmrz/livesync-commonlib/setup-uri').then(async ({ encodeTimeBoundSetupURI }) => {
const result = await encodeTimeBoundSetupURI(workerData.settings, workerData.setupPassphrase);
parentPort.postMessage(result.uri);
});
`,
{ eval: true, workerData: { settings, setupPassphrase } }
);
try {
const setupURI = await new Promise<string>((resolve, reject) => {
worker.once("message", resolve);
worker.once("error", reject);
worker.once("exit", (code) => reject(new Error(`The expired URI fixture exited with ${code}.`)));
});
return { setupURI, setupPassphrase };
} finally {
await worker.terminate();
}
}
async function assertIndependentIdKey(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault,
expectedKey: string
): Promise<void> {
const matches = await evalObsidianJson<boolean>(
context.cliBinary,
`(() => {
const settings = app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();
return JSON.stringify(settings.encrypt && settings.usePathObfuscation &&
settings.idDerivationVersion === 1 && settings.idDerivationKey === ${JSON.stringify(expectedKey)});
})()`,
session.cliEnv
);
assertEqual(matches, true, "The device did not retain the shared independent ID key and Path Obfuscation.");
const raw = await readFile(join(vault.path, ".obsidian/plugins/obsidian-livesync/data.json"), "utf8");
const saved = JSON.parse(raw) as Record<string, unknown>;
assertEqual(saved.idDerivationVersion, 1, "The independent ID version was not saved.");
assertEqual(saved.idDerivationKey, "", "The ID key was saved in plain text.");
if (!saved.encryptedIdDerivationKey || raw.includes(expectedKey)) {
throw new Error("The shared ID key was not encrypted in local settings.");
}
}
async function startSession(
context: RunnerContext,
vault: TemporaryVault,
@@ -249,6 +316,37 @@ async function captureNote(port: number, path: string, text: string, filename: s
return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first());
}
async function importQRCode(port: number, qrData: string): Promise<string> {
await withObsidianPage(port, async (page) => {
await page.evaluate((data) => {
const obsidian = globalThis as typeof globalThis & {
app: {
plugins: {
plugins: Record<
string,
{ core: { modules: { decodeQR?: (qr: string) => Promise<unknown> }[] } }
>;
};
};
};
const setup = obsidian.app.plugins.plugins["obsidian-livesync"].core.modules.find(
(module) => typeof module.decodeQR === "function"
);
if (!setup?.decodeQR) throw new Error("The QR settings decoder is unavailable.");
// The decoder remains pending while the real setup dialogues run.
void setup.decodeQR(data);
}, qrData);
});
const title = "Mostly Complete: Decision Required";
const screenshot = await captureGuideDialogue(port, `guide-${captures.guide}-join-choice.png`, title);
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
await selectRadioOption(modal, "🔗 Join this device");
await modal.getByRole("button", { name: "Proceed to the next step.", exact: true }).click();
});
return screenshot;
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -257,6 +355,18 @@ async function main(): Promise<void> {
const objectStorage = await loadObjectStorageConfig();
const bucketPrefix = makeUniqueBucketPrefix("setup-uri-workflow");
const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix, useCustomRequestHandler);
const bootstrapSettings = await decodeSettingsFromSetupURI(
bootstrapArtifact.setupURI,
bootstrapArtifact.setupPassphrase
);
if (!bootstrapSettings || bootstrapSettings.idDerivationVersion !== 1 || !bootstrapSettings.idDerivationKey) {
throw new Error("The public Setup URI generator did not configure an independent ID key.");
}
const idKey = bootstrapSettings.idDerivationKey;
const rejectedArtifacts = [
{ ...bootstrapArtifact, setupPassphrase: "incorrect-setup-passphrase" },
await expiredSetupURI(bootstrapSettings as ObsidianLiveSyncSettings, bootstrapArtifact.setupPassphrase),
];
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const [portA, portB] = sessionPorts();
@@ -268,13 +378,14 @@ async function main(): Promise<void> {
console.log(`Temporary Object Storage target: ${objectStorage.bucket}/${bucketPrefix}`);
const sessionA = await startSession(context, vaultA, portA);
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures));
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures, rejectedArtifacts));
screenshots.push(await captureAndStartInitialisation(portA, "new", captures));
screenshots.push(await confirmRebuild(portA, captures));
screenshots.push(await continueWithoutRemoteSettings(portA, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
await assertE2eCompatibilityUnpaused(context.cliBinary, sessionA.cliEnv, portA);
await assertIndependentIdKey(context, sessionA, vaultA, idKey);
assertEqual(
firstState.endpoint,
objectStorage.endpoint,
@@ -297,13 +408,38 @@ async function main(): Promise<void> {
);
await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent);
const firstEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionA.cliEnv, noteFromFirst);
if (
!/^f:[0-9a-f]{64}$/u.test(firstEntry.id) ||
firstEntry.children.length === 0 ||
firstEntry.children.some((id) => !/^h:\+[0-9a-f]{64}$/u.test(id))
) {
throw new Error("The source note did not use independent document and Chunk IDs.");
}
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
await waitForObjectStorageData(objectStorage, bucketPrefix);
const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures);
const generated = await generateSetupURIFromDevice(
portA,
randomBytes(24).toString("base64url"),
captures,
uriMode
);
if (generated.artifact.setupURI === bootstrapArtifact.setupURI) {
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
}
screenshots.push(...generated.screenshots);
const qrSettings = useQRCode
? await evalObsidianJson<ObsidianLiveSyncSettings>(
context.cliBinary,
"JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings())",
sessionA.cliEnv
)
: undefined;
if (qrSettings) {
// Represent QR settings from a device with a different database
// suffix; isolated Obsidian profiles can share the default suffix.
qrSettings.additionalSuffixOfDatabaseName = "qr-source";
}
const startupState = await configureMigratedStartupScheduling(context.cliBinary, sessionA.cliEnv);
assertEqual(startupState.liveSync, true, "The first device did not persist its Continuous setting.");
assertEqual(startupState.syncOnStart, true, "The first device did not persist syncOnStart.");
@@ -325,11 +461,42 @@ async function main(): Promise<void> {
await stopSession(context, sessionA);
const sessionB = await startSession(context, vaultB, portB);
screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures));
const initialMarker = await assertE2eCompatibilityUnpaused(context.cliBinary, sessionB.cliEnv, portB);
if (qrSettings) {
assertEqual(
initialMarker.additionalSuffix === `-${qrSettings.additionalSuffixOfDatabaseName}`,
false,
"The QR fixture must start with distinct source and receiver database suffixes."
);
}
screenshots.push(
qrSettings
? await importQRCode(portB, encodeSettingsToQRCodeData(qrSettings))
: await enterSetupURI(portB, "existing", generated.artifact, captures)
);
screenshots.push(await captureAndStartInitialisation(portB, "existing", captures));
if (qrSettings) {
// Inspect the imported namespace before Fetch resets the local
// database and restores this device's own suffix.
await withObsidianPage(portB, async (page) => {
await modalByTitle(page, "Data retrieval scheduled").waitFor({ state: "visible", timeout: 30000 });
});
const importedMarker = await assertE2eCompatibilityUnpaused(context.cliBinary, sessionB.cliEnv, portB);
assertEqual(
importedMarker.expectedStorageKey === initialMarker.expectedStorageKey,
false,
"The QR fixture did not exercise a changed device-local namespace after settings import."
);
assertEqual(
importedMarker.additionalSuffix,
`-${qrSettings.additionalSuffixOfDatabaseName}`,
"The second device did not import the QR source's database suffix."
);
}
screenshots.push(...(await confirmFastFetch(portB, captures)));
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
await resumeCompatibilityReviewIfShown(portB);
const fetchedMarker = await assertE2eCompatibilityUnpaused(context.cliBinary, sessionB.cliEnv, portB);
await assertIndependentIdKey(context, sessionB, vaultB, idKey);
assertEqual(
secondState.endpoint,
objectStorage.endpoint,
@@ -347,6 +514,13 @@ async function main(): Promise<void> {
);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await waitForPathContent(vaultB, noteFromFirst, firstContent);
const importedEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, noteFromFirst);
assertEqual(importedEntry.id, firstEntry.id, "Import changed the obfuscated document ID.");
assertEqual(
JSON.stringify(importedEntry.children),
JSON.stringify(firstEntry.children),
"Import changed the Chunk IDs."
);
screenshots.push(
await captureNote(
portB,
@@ -357,16 +531,44 @@ async function main(): Promise<void> {
);
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
const secondEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, noteFromSecond);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await stopSession(context, sessionB);
const returningSessionB = await startSession(context, vaultB, portB);
await waitForLiveSyncCoreReady(context.cliBinary, returningSessionB.cliEnv);
await assertIndependentIdKey(context, returningSessionB, vaultB, idKey);
const restartedMarker = await assertE2eCompatibilityUnpaused(
context.cliBinary,
returningSessionB.cliEnv,
portB
);
assertEqual(
restartedMarker.expectedStorageKey,
fetchedMarker.expectedStorageKey,
"Restart changed the device-local namespace selected by Fetch."
);
await waitForPathContent(vaultB, noteFromFirst, firstContent);
await stopSession(context, returningSessionB);
const returningSessionA = await startSession(context, vaultA, portA);
await waitForLiveSyncCoreReady(context.cliBinary, returningSessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
await assertE2eCompatibilityUnpaused(context.cliBinary, returningSessionA.cliEnv, portA);
await assertIndependentIdKey(context, returningSessionA, vaultA, idKey);
// Deliberately omit manual replication here. Object Storage reports
// Continuous as not applicable, so startup scheduling must honour the
// retained syncOnStart setting by running an unattended OneShot.
await waitForPathContent(vaultA, noteFromSecond, secondContent);
const returnedEntry = await waitForLocalDatabaseEntry(
context.cliBinary,
returningSessionA.cliEnv,
noteFromSecond
);
assertEqual(returnedEntry.id, secondEntry.id, "The return journey changed the obfuscated document ID.");
assertEqual(
JSON.stringify(returnedEntry.children),
JSON.stringify(secondEntry.children),
"The return journey changed the Chunk IDs."
);
screenshots.push(
await captureNote(
portA,
@@ -377,10 +579,37 @@ async function main(): Promise<void> {
);
console.log(
`Object Storage Setup URI and two-device roundtrip succeeded with the ${
`Object Storage ${useQRCode ? "QR" : "Setup URI"} and two-device roundtrip succeeded with the ${
useCustomRequestHandler ? "Custom HTTP Handler" : "default HTTP handler"
}. Screenshots: ${screenshots.join(", ")}`
);
} catch (error) {
for (const session of context.activeSessions) {
await captureObsidianPage(session.remoteDebuggingPort, `${captures.scenario}-failure.png`, async (page) => {
console.error(
"Visible dialogue titles:",
await page.locator(".modal-container:visible .modal-title").allTextContents()
);
console.error(
"Initialisation state:",
await evalObsidianJson(
context.cliBinary,
`(()=>{
const core=app.plugins.plugins['obsidian-livesync'].core;
const settings=core.services.setting.currentSettings();
return JSON.stringify({configured:settings.isConfigured,
databaseReady:core.services.database.isDatabaseReady(),appReady:core.services.appLifecycle.isReady(),
suspended:core.services.appLifecycle.isSuspended(),versionUpFlash:settings.versionUpFlash,
activeConfigurationId:settings.activeConfigurationId,
remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length});})()`,
session.cliEnv
)
);
}).catch((diagnosticError: unknown) =>
console.error("Could not capture initialisation failure:", diagnosticError)
);
}
throw error;
} finally {
await stopSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
@@ -33,6 +33,9 @@ type FeatureState = {
version: number | null;
features: string[];
hasActiveReplicator: boolean;
syncStatus: string | null;
continuousTaskActive: boolean;
liveSync: boolean;
};
async function readFeatureState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<FeatureState> {
@@ -43,10 +46,14 @@ async function readFeatureState(cliBinary: string, env: NodeJS.ProcessEnv): Prom
"const core=app.plugins.plugins['obsidian-livesync'].core;",
`const id=${JSON.stringify(VERSIONING_DOCID)};`,
"const info=await core.localDatabase.getRaw(id).catch(()=>null);",
"const replicator=core.services.replicator.getActiveReplicator();",
"return JSON.stringify({",
"version:typeof info?.version==='number'?info.version:null,",
"features:Array.isArray(info?.used_features)?info.used_features:[],",
"hasActiveReplicator:!!core.services.replicator.getActiveReplicator(),",
"hasActiveReplicator:!!replicator,",
"syncStatus:replicator?.syncStatus??null,",
"continuousTaskActive:!!replicator?.continuousTask,",
"liveSync:core.settings.liveSync,",
"});",
"})()",
].join(""),
@@ -135,7 +142,12 @@ async function main(): Promise<void> {
);
if (start.status !== "completed")
throw new Error(`Continuous replication did not start: ${JSON.stringify(start)}`);
await waitForState(cli.binary, session.cliEnv, (state) => state.hasActiveReplicator, "an active Replicator");
await waitForState(
cli.binary,
session.cliEnv,
(state) => state.hasActiveReplicator && state.continuousTaskActive && state.syncStatus === "PAUSED",
"a caught-up continuous Replicator"
);
await putCouchDbDocument(couchDb, dbName, {
...initialVersion,
@@ -434,6 +434,7 @@ async function main(): Promise<void> {
doctorProcessedVersion: DoctorRegulation.version,
settingVersion: CURRENT_SETTING_VERSION,
isConfigured: true,
versionUpFlash: "Review an earlier compatibility change before synchronisation resumes.",
additionalSuffixOfDatabaseName: "",
enableDebugTools: true,
notifyThresholdOfRemoteStorageSize: 0,
+3
View File
@@ -22,6 +22,8 @@ const focusedScenarios = new Set([
"cli-to-obsidian-sync",
"minio-upload",
"object-storage-setup-uri-workflow",
"object-storage-compatible-setup-uri-workflow",
"object-storage-qr-workflow",
"object-storage-custom-http-handler-setup-uri-workflow",
"p2p-setup-uri-workflow",
"partial-startup-file-failure",
@@ -34,6 +36,7 @@ const focusedScenarios = new Set([
"hidden-file-snippet-sync",
"customisation-sync",
"received-change-readiness",
"internal-metadata-doctor",
"setting-markdown-export",
"upgrade-from-stable",
]);
+2 -14
View File
@@ -20,14 +20,12 @@ import { discoverObsidianCli, requireObsidianBinary } from "../runner/environmen
import { waitForExactCaseOnlyRename } from "../runner/pathAssertions.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityReviewPending,
assertE2eCompatibilityUnpaused,
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
resumeCompatibilityReview,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
@@ -66,7 +64,6 @@ type RunnerContext = {
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
reviewedVaults: Set<string>;
activeSessions: Set<ObsidianLiveSyncSession>;
};
@@ -457,7 +454,6 @@ async function startConfiguredSession(
password: context.couchDb.password,
dbName: context.dbName,
};
const reviewAlreadyCompleted = context.reviewedVaults.has(vault.path);
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
@@ -468,12 +464,7 @@ async function startConfiguredSession(
context.activeSessions.add(session);
try {
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
if (!reviewAlreadyCompleted) {
await assertE2eCompatibilityReviewPending(context.cliBinary, session.cliEnv);
await resumeCompatibilityReview(session.remoteDebuggingPort);
}
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
if (!reviewAlreadyCompleted) context.reviewedVaults.add(vault.path);
await assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, session.remoteDebuggingPort);
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, overrides);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
@@ -1573,7 +1564,6 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
couchDb,
dbName,
reviewedVaults: new Set(),
activeSessions: new Set(),
};
const encryptedContext: RunnerContext = {
@@ -1581,7 +1571,6 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
couchDb,
dbName: encryptedDbName,
reviewedVaults: new Set(),
activeSessions: new Set(),
};
const independentContext: RunnerContext = {
@@ -1589,7 +1578,6 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
couchDb,
dbName: independentDbName,
reviewedVaults: new Set(),
activeSessions: new Set(),
};
+26
View File
@@ -53,6 +53,32 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
- Dialogues show generated QR codes, key pairs, and database sizes again.
- We can now use updated Spanish translations for settings and messages. (#1212)
### Setup
#### New Feature
- We can now share a Setup URI with a displayed time limit, or choose **Compatible** for reuse without a time limit.
- **Time-bound** uses the current fixed seven-day UTC window, so the displayed end may be less than seven days away. Compatible retains the existing URI format; receiving devices still need to support the shared settings.
- The time condition applies when opening the URI. It does not revoke imported credentials or prevent reuse after rolling the device clock back.
#### Improved
- We can now distinguish the three Setup URI and QR code choices by their short labels and icons: initialise or overwrite the remote, join this device, or apply settings only.
#### Fixed
- We can now add a device or open a copied Vault without a compatibility pause solely because its device-local version record is absent.
- Existing version or settings incompatibilities still require review. A pause already saved by an earlier release still needs one explicit resume action.
### Acknowledgements
Thank you for your contributions!
- [@kimjansheden](https://github.com/kimjansheden) ([#1219](https://github.com/vrtmrz/obsidian-livesync/pull/1219))
- [@Immick](https://github.com/Immick) ([#1195](https://github.com/vrtmrz/obsidian-livesync/pull/1195), [#1196](https://github.com/vrtmrz/obsidian-livesync/pull/1196))
- [@bolikcraft](https://github.com/bolikcraft) ([#1187](https://github.com/vrtmrz/obsidian-livesync/pull/1187))
- [@speedy-axolotl](https://github.com/speedy-axolotl) ([#1212](https://github.com/vrtmrz/obsidian-livesync/pull/1212))
## 1.0.32
27th September, 2026
+2 -2
View File
@@ -1,5 +1,5 @@
// Keep CouchDB database-version negotiation isolated from Setup URI generation.
// The exact release must match utils/livesync-commonlib-version.ts; the setup
// tool suite checks every static specifier before release.
export { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.1.32/compat/pouchdb/negotiation";
export { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.1.32/compat/pouchdb/pouchdb-browser";
export { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.1.34/compat/pouchdb/negotiation";
export { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.1.34/compat/pouchdb/pouchdb-browser";
+3 -3
View File
@@ -1,7 +1,7 @@
{
"version": "5",
"specifiers": {
"npm:@vrtmrz/livesync-commonlib@0.1.32": "0.1.32"
"npm:@vrtmrz/livesync-commonlib@0.1.34": "0.1.34"
},
"npm": {
"@aws-sdk/checksums@3.1000.18": {
@@ -284,8 +284,8 @@
"@trystero-p2p/core"
]
},
"@vrtmrz/livesync-commonlib@0.1.32": {
"integrity": "sha512-gzjhd7bg+DKHhd/24WGxBM7557wsw3HJkc0YjKll1n8/8vuhqPnlLuZmM33fj+4bv9nGFUnDlnoVowpQrTns6w==",
"@vrtmrz/livesync-commonlib@0.1.34": {
"integrity": "sha512-EeVpeFg3W43cpN+x0BZvFj9fN+eQFil9vohvmLLV1z/8bCTT0Hsq/bfRV59oL0qFNggPetkwyCZUusdfjajNOw==",
"dependencies": [
"@aws-sdk/client-s3",
"@smithy/fetch-http-handler",
+1 -1
View File
@@ -2,4 +2,4 @@
// Commonlib registry release. Static npm specifiers cannot interpolate this
// value, so livesync-commonlib-version.test.ts verifies the domain-specific
// facades against it.
export const LIVESYNC_COMMONLIB_VERSION = "0.1.32";
export const LIVESYNC_COMMONLIB_VERSION = "0.1.34";
+2
View File
@@ -47,6 +47,8 @@ deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=
If `uri_passphrase` is omitted, the tool generates and prints a cryptographically random one. Store the Setup URI and its passphrase separately. The `passphrase` value protects synchronised Vault data and must also be stored safely.
The generator defaults to `uri_mode=ephemeral`. The URI opens only during the current fixed seven-day UTC window, and the tool prints its exact end time. It may have less than seven days remaining when generated. For an indefinitely reusable URI, set `uri_mode=persistent` before running the command. Persistent uses the existing encrypted format, which older clients that already support that format can read. The time condition controls opening the URI; it does not revoke settings or credentials after import.
### Object Storage
```sh
+110 -35
View File
@@ -20,6 +20,14 @@ Deno.test("generates an Object Storage Setup URI with a selected S3 profile", as
passphrase: "vault-secret",
uri_passphrase: "setup-secret",
});
assert(
generated.mode === "ephemeral",
"the default Setup URI mode was not Ephemeral",
);
assert(
generated.usableUntil !== null && generated.usableUntil > Date.now(),
"the Ephemeral Setup URI did not report its usable end time",
);
const decoded = await decodeSettingsFromSetupURI(
generated.setupURI,
generated.setupPassphrase,
@@ -32,11 +40,13 @@ Deno.test("generates an Object Storage Setup URI with a selected S3 profile", as
"the generator did not return an ID recovery code",
);
assert(
(effective as typeof effective & { idDerivationVersion?: number }).idDerivationVersion === 1,
(effective as typeof effective & { idDerivationVersion?: number })
.idDerivationVersion === 1,
"the Setup URI did not enable independent IDs",
);
assert(
(effective as typeof effective & { idDerivationKey?: string }).idDerivationKey ===
(effective as typeof effective & { idDerivationKey?: string })
.idDerivationKey ===
recoveryCode.slice("sls-id-v1:".length),
"the Setup URI did not contain the generated ID key",
);
@@ -89,7 +99,8 @@ Deno.test("generates a random-room P2P Setup URI without copying a device identi
assert(decoded, "Commonlib could not decode the P2P Setup URI");
const effective = { ...DEFAULT_SETTINGS, ...decoded };
assert(
(effective as typeof effective & { idDerivationKey?: string }).idDerivationKey ===
(effective as typeof effective & { idDerivationKey?: string })
.idDerivationKey ===
generated.idRecoveryCode?.slice("sls-id-v1:".length),
"the P2P Setup URI did not contain the generated ID key",
);
@@ -141,45 +152,109 @@ Deno.test("generates a random-room P2P Setup URI without copying a device identi
);
});
Deno.test("reuses the ID key from a recovery code and permits explicit legacy IDs", async () => {
const environment = {
Deno.test("generates a Persistent Setup URI on explicit request", async () => {
const generated = await generateSetupURI({
remote_type: "p2p",
passphrase: "vault-secret",
uri_passphrase: "setup-secret",
};
const first = await generateSetupURI(environment);
const second = await generateSetupURI({ ...environment, id_recovery_code: first.idRecoveryCode });
const independentlyGenerated = await generateSetupURI(environment);
assert(second.idRecoveryCode === first.idRecoveryCode, "the recovery code changed on repeat generation");
assert(independentlyGenerated.idRecoveryCode !== first.idRecoveryCode, "the default ID key was reused");
const repeatedSettings = await decodeSettingsFromSetupURI(second.setupURI, second.setupPassphrase);
assert(repeatedSettings, "the repeated Setup URI could not be decoded");
uri_mode: "persistent",
});
assert(generated.mode === "persistent", "the explicit mode was not retained");
assert(
(repeatedSettings as typeof repeatedSettings & { idDerivationKey?: string }).idDerivationKey ===
first.idRecoveryCode?.slice("sls-id-v1:".length),
"the recovery code did not restore the original ID key",
generated.usableUntil === null,
"Persistent unexpectedly has a time condition",
);
const decoded = await decodeSettingsFromSetupURI(
generated.setupURI,
generated.setupPassphrase,
);
assert(decoded, "the Persistent Setup URI could not be opened");
});
const legacy = await generateSetupURI({ ...environment, id_mode: "legacy" });
const decoded = await decodeSettingsFromSetupURI(legacy.setupURI, legacy.setupPassphrase);
assert(decoded, "the legacy Setup URI could not be decoded");
assert(legacy.idRecoveryCode === undefined, "legacy mode returned an ID recovery code");
assert(
(decoded as typeof decoded & { idDerivationVersion?: number }).idDerivationVersion !== 1,
"legacy mode enabled independent IDs",
);
Deno.test("rejects an unknown Setup URI mode", async () => {
let rejected = false;
try {
await generateSetupURI({ ...environment, id_recovery_code: "sls-id-v1:wrong" });
} catch {
rejected = true;
await generateSetupURI({ uri_mode: "later" });
} catch (error) {
rejected = error instanceof Error &&
error.message === "uri_mode must be ephemeral or persistent";
}
assert(rejected, "an invalid recovery code was accepted");
rejected = false;
try {
await generateSetupURI({ ...environment, id_mode: "legacy", id_recovery_code: first.idRecoveryCode });
} catch {
rejected = true;
}
assert(rejected, "legacy mode silently ignored a recovery code");
assert(rejected, "the generator accepted an unknown Setup URI mode");
});
for (const mode of ["ephemeral", "persistent"] as const) {
Deno.test(`preserves ID recovery and explicit legacy IDs in ${mode} URIs`, async () => {
const environment = {
remote_type: "p2p",
uri_mode: mode,
passphrase: "vault-secret",
uri_passphrase: "setup-secret",
};
const first = await generateSetupURI(environment);
const second = await generateSetupURI({
...environment,
id_recovery_code: first.idRecoveryCode,
});
const independentlyGenerated = await generateSetupURI(environment);
assert(
second.idRecoveryCode === first.idRecoveryCode,
"the recovery code changed on repeat generation",
);
assert(
independentlyGenerated.idRecoveryCode !== first.idRecoveryCode,
"the default ID key was reused",
);
const repeatedSettings = await decodeSettingsFromSetupURI(
second.setupURI,
second.setupPassphrase,
);
assert(repeatedSettings, "the repeated Setup URI could not be decoded");
assert(
(repeatedSettings as typeof repeatedSettings & {
idDerivationKey?: string;
}).idDerivationKey ===
first.idRecoveryCode?.slice("sls-id-v1:".length),
"the recovery code did not restore the original ID key",
);
const legacy = await generateSetupURI({
...environment,
id_mode: "legacy",
});
const decoded = await decodeSettingsFromSetupURI(
legacy.setupURI,
legacy.setupPassphrase,
);
assert(decoded, "the legacy Setup URI could not be decoded");
assert(
legacy.idRecoveryCode === undefined,
"legacy mode returned an ID recovery code",
);
assert(
(decoded as typeof decoded & { idDerivationVersion?: number })
.idDerivationVersion !== 1,
"legacy mode enabled independent IDs",
);
let rejected = false;
try {
await generateSetupURI({
...environment,
id_recovery_code: "sls-id-v1:wrong",
});
} catch {
rejected = true;
}
assert(rejected, "an invalid recovery code was accepted");
rejected = false;
try {
await generateSetupURI({
...environment,
id_mode: "legacy",
id_recovery_code: first.idRecoveryCode,
});
} catch {
rejected = true;
}
assert(rejected, "legacy mode silently ignored a recovery code");
});
}
+67 -11
View File
@@ -1,12 +1,14 @@
import {
createNewVaultSettings,
encodeSettingsToSetupURI,
encodeTimeBoundSetupURI,
generateP2PRoomId,
isTimeBoundSetupURIUsableNow,
type ObsidianLiveSyncSettings,
P2P_DEFAULT_SETTINGS,
PREFERRED_BASE,
PREFERRED_JOURNAL_SYNC,
PREFERRED_SETTING_SELF_HOSTED,
type TimeBoundSetupURIMode,
upsertRemoteConfigurationInPlace,
} from "./livesync-commonlib.ts";
@@ -19,6 +21,8 @@ export interface GeneratedSetupURI {
remoteType: SetupRemoteType;
setupURI: string;
setupPassphrase: string;
mode: TimeBoundSetupURIMode;
usableUntil: number | null;
idRecoveryCode?: string;
}
@@ -27,7 +31,9 @@ const ID_RECOVERY_CODE_PATTERN = /^sls-id-v1:([0-9a-f]{64})$/u;
function generateRandomIdKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
function configureIdDerivation(
@@ -40,13 +46,17 @@ function configureIdDerivation(
}
const suppliedCode = environment.id_recovery_code?.trim();
if (mode === "legacy") {
if (suppliedCode) throw new Error("id_recovery_code cannot be used with id_mode=legacy");
if (suppliedCode) {
throw new Error("id_recovery_code cannot be used with id_mode=legacy");
}
return undefined;
}
const key = suppliedCode
? ID_RECOVERY_CODE_PATTERN.exec(suppliedCode)?.[1]
: generateRandomIdKey();
if (!key) throw new Error("id_recovery_code must be a valid sls-id-v1 recovery code");
if (!key) {
throw new Error("id_recovery_code must be a valid sls-id-v1 recovery code");
}
Object.assign(settings, { idDerivationVersion: 1, idDerivationKey: key });
return `${ID_RECOVERY_CODE_PREFIX}${key}`;
}
@@ -177,6 +187,14 @@ function parseRemoteType(
throw new Error("remote_type must be couchdb, s3, or p2p");
}
function parseSetupURIMode(
environment: SetupGeneratorEnvironment,
): TimeBoundSetupURIMode {
const mode = environment.uri_mode?.trim().toLowerCase() || "ephemeral";
if (mode === "ephemeral" || mode === "persistent") return mode;
throw new Error("uri_mode must be ephemeral or persistent");
}
export function createSetupSettings(
environment: SetupGeneratorEnvironment,
): { remoteType: SetupRemoteType; settings: ObsidianLiveSyncSettings } {
@@ -195,20 +213,56 @@ export async function generateSetupURI(
): Promise<GeneratedSetupURI> {
const setupPassphrase = environment.uri_passphrase?.trim() ||
generateSecret();
const mode = parseSetupURIMode(environment);
const { remoteType, settings } = createSetupSettings(environment);
const idRecoveryCode = configureIdDerivation(settings, environment);
const setupURI = await encodeSettingsToSetupURI(settings, setupPassphrase, [
"pluginSyncExtendedSetting",
"doNotUseFixedRevisionForChunks",
], true);
return { remoteType, setupURI: setupURI.trim(), setupPassphrase, idRecoveryCode };
const { uri, usableUntil } = await encodeTimeBoundSetupURI(
settings,
setupPassphrase,
{
mode,
removeProperties: [
"pluginSyncExtendedSetting",
"doNotUseFixedRevisionForChunks",
],
skipDefaultValue: true,
},
);
if (!isTimeBoundSetupURIUsableNow(usableUntil)) {
throw new Error("Setup URI time window changed during generation");
}
return {
remoteType,
setupURI: uri.trim(),
setupPassphrase,
mode,
usableUntil,
idRecoveryCode,
};
}
export async function runSetupURIGenerator(
environment: SetupGeneratorEnvironment = Deno.env.toObject(),
): Promise<void> {
const generated = await generateSetupURI(environment);
let generated = await generateSetupURI(environment);
if (!isTimeBoundSetupURIUsableNow(generated.usableUntil)) {
generated = await generateSetupURI(environment);
}
if (!isTimeBoundSetupURIUsableNow(generated.usableUntil)) {
throw new Error("Setup URI time window changed before it could be shown");
}
console.log(`\nGenerated ${generated.remoteType} Setup URI.`);
if (generated.usableUntil === null) {
console.log(
"Persistent: no time condition. Older clients can open this format.",
);
} else {
console.log(
`Ephemeral: usable until ${
new Date(generated.usableUntil).toISOString()
} (UTC).`,
);
}
console.log(
"Your passphrase for the Setup URI is:",
generated.setupPassphrase,
@@ -216,7 +270,9 @@ export async function runSetupURIGenerator(
console.log("This passphrase is never shown again, so store it safely.");
if (generated.idRecoveryCode) {
console.log("ID recovery code:", generated.idRecoveryCode);
console.log("Use id_recovery_code with this value and reuse the same remote settings when generating another Setup URI for the same Vault.");
console.log(
"Use id_recovery_code with this value and reuse the same remote settings when generating another Setup URI for the same Vault.",
);
}
console.log(generated.setupURI);
}
+8 -6
View File
@@ -3,10 +3,12 @@
// does not load the PouchDB browser adapter.
export {
decodeSettingsFromSetupURI,
encodeSettingsToSetupURI,
} from "npm:@vrtmrz/livesync-commonlib@0.1.32/compat/API/processSetting";
export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.32/compat/common/utils";
export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.32/remote-configurations";
encodeTimeBoundSetupURI,
isTimeBoundSetupURIUsableNow,
} from "npm:@vrtmrz/livesync-commonlib@0.1.34/setup-uri";
export type { TimeBoundSetupURIMode } from "npm:@vrtmrz/livesync-commonlib@0.1.34/setup-uri";
export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.34/compat/common/utils";
export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.34/remote-configurations";
export {
createNewVaultSettings,
DEFAULT_SETTINGS,
@@ -14,5 +16,5 @@ export {
PREFERRED_BASE,
PREFERRED_JOURNAL_SYNC,
PREFERRED_SETTING_SELF_HOSTED,
} from "npm:@vrtmrz/livesync-commonlib@0.1.32/settings";
export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.1.32/settings";
} from "npm:@vrtmrz/livesync-commonlib@0.1.34/settings";
export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.1.34/settings";