mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-01 06:52:30 +00:00
Prepare time-bound Setup URI integration for Commonlib release
Add Time-bound and Compatible generation to the Obsidian dialogue, browser peer check, and setup tools, with boundary and compatibility coverage. Show the fixed window end before sharing a Time-bound URI. This review branch depends on unpublished @vrtmrz/livesync-commonlib 0.1.32-next.0. The root npm pin and Deno lockfile remain unchanged until that candidate is available; a fresh install of this commit is not yet expected to build.
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
---
|
||||
date: 2026-09-28
|
||||
commonlib-version: "0.1.31"
|
||||
feasibility-probe-version: "0.1.27"
|
||||
self-hosted-livesync-version: "1.0.30"
|
||||
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 is based on Commonlib 0.1.31.
|
||||
The 0.1.32-next.0 candidate has passed local package and downstream checks;
|
||||
publication, exact dependency pins, the independent Deno lockfile update, and
|
||||
mobile performance validation remain 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 architectural blocker. The 0.1.31-based Commonlib
|
||||
candidate passes its type, unit, boundary, release-process, and packed-package
|
||||
gates. LiveSync passes source checks, the unit suite, the Obsidian plug-in
|
||||
build, and the WebPeer and CLI builds against that local tarball. The complete
|
||||
unit suite passed with subprocess permissions needed by its CLI installer
|
||||
fixtures. Focused real-Obsidian generation checks passed on desktop and
|
||||
emulated mobile, including the displayed Time-bound end, Compatible output,
|
||||
and mobile touch targets. 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 that has already imported the URI.
|
||||
The Deno generator passed against the same local packed
|
||||
candidate through a temporary import map. These results do not establish a
|
||||
mobile performance bound or replace validation of the published package and
|
||||
its lockfiles. 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
|
||||
);
|
||||
});
|
||||
@@ -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 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
|
||||
|
||||
|
||||
@@ -192,10 +192,13 @@ deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.co
|
||||
|
||||
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.
|
||||
obsidian://setuplivesync?settings=%5B%22tm2DpsOE74nJAryprZO2M93wF%2Fvg.......4b26ed33230729%22%5D
|
||||
|
||||
@@ -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. After importing it 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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,4 +57,9 @@
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -178,6 +178,8 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`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.
|
||||
|
||||
@@ -150,6 +150,12 @@ 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: "Time-bound", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const resultTitle = "Your Setup URI is ready to be copied";
|
||||
|
||||
@@ -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>;
|
||||
@@ -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) => {
|
||||
@@ -1228,6 +1340,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 +1370,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 +1405,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,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.0-rc.4/compat/pouchdb/negotiation";
|
||||
export { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/pouchdb/pouchdb-browser";
|
||||
export { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.1.32-next.0/compat/pouchdb/negotiation";
|
||||
export { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.1.32-next.0/compat/pouchdb/pouchdb-browser";
|
||||
|
||||
@@ -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.0-rc.4";
|
||||
export const LIVESYNC_COMMONLIB_VERSION = "0.1.32-next.0";
|
||||
|
||||
@@ -45,6 +45,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
|
||||
|
||||
@@ -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,
|
||||
@@ -121,3 +129,33 @@ Deno.test("generates a random-room P2P Setup URI without copying a device identi
|
||||
"the selected profile was not a P2P connection URI",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
uri_mode: "persistent",
|
||||
});
|
||||
assert(generated.mode === "persistent", "the explicit mode was not retained");
|
||||
assert(
|
||||
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");
|
||||
});
|
||||
|
||||
Deno.test("rejects an unknown Setup URI mode", async () => {
|
||||
let rejected = false;
|
||||
try {
|
||||
await generateSetupURI({ uri_mode: "later" });
|
||||
} catch (error) {
|
||||
rejected = error instanceof Error &&
|
||||
error.message === "uri_mode must be ephemeral or persistent";
|
||||
}
|
||||
assert(rejected, "the generator accepted an unknown Setup URI mode");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
function requireValue(
|
||||
@@ -147,6 +151,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 } {
|
||||
@@ -165,19 +177,54 @@ export async function generateSetupURI(
|
||||
): Promise<GeneratedSetupURI> {
|
||||
const setupPassphrase = environment.uri_passphrase?.trim() ||
|
||||
generateSecret();
|
||||
const mode = parseSetupURIMode(environment);
|
||||
const { remoteType, settings } = createSetupSettings(environment);
|
||||
const setupURI = await encodeSettingsToSetupURI(settings, setupPassphrase, [
|
||||
"pluginSyncExtendedSetting",
|
||||
"doNotUseFixedRevisionForChunks",
|
||||
], true);
|
||||
return { remoteType, setupURI: setupURI.trim(), setupPassphrase };
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
// does not load the PouchDB browser adapter.
|
||||
export {
|
||||
decodeSettingsFromSetupURI,
|
||||
encodeSettingsToSetupURI,
|
||||
} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/API/processSetting";
|
||||
export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/common/utils";
|
||||
export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/remote-configurations";
|
||||
encodeTimeBoundSetupURI,
|
||||
isTimeBoundSetupURIUsableNow,
|
||||
} from "npm:@vrtmrz/livesync-commonlib@0.1.32-next.0/setup-uri";
|
||||
export type { TimeBoundSetupURIMode } from "npm:@vrtmrz/livesync-commonlib@0.1.32-next.0/setup-uri";
|
||||
export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.32-next.0/compat/common/utils";
|
||||
export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.32-next.0/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.0-rc.4/settings";
|
||||
export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings";
|
||||
} from "npm:@vrtmrz/livesync-commonlib@0.1.32-next.0/settings";
|
||||
export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.1.32-next.0/settings";
|
||||
|
||||
Reference in New Issue
Block a user