Compare commits

..
31 changed files with 3141 additions and 26 deletions
+66 -4
View File
@@ -58,7 +58,7 @@ A current Setup URI retains its remote profiles, display names, and separate mai
Step-by-step setup for Self-hosted LiveSync. You can setup Self-hosted LiveSync manually with Minimal setting items.
Completing manual CouchDB, Object Storage, or P2P setup creates the corresponding remote profile without replacing profiles which are already saved. CouchDB and Object Storage setup select the new profile as the main remote. P2P setup selects it for P2P use and, when the wizard is enabling LiveSync, also selects it as the main remote. A descriptive display name is generated and can be changed later.
Completing manual CouchDB, Object Storage, WebDAV, PostgREST, or P2P setup creates the corresponding remote profile without replacing profiles which are already saved. CouchDB, Object Storage, WebDAV, and PostgREST setup select the new profile as the main remote. P2P setup selects it for P2P use and, when the wizard is enabling LiveSync, also selects it as the main remote. A descriptive display name is generated and can be changed later.
#### Enable LiveSync
@@ -171,7 +171,7 @@ Show verbose log. Please enable when you report the logs
### 1. Connection settings
Self-hosted LiveSync stores multiple remote connection profiles under **Connection settings****Saved connections**. Each profile represents a CouchDB database, an Object Storage connection, or a P2P configuration, and several profiles can be kept in one Vault.
Self-hosted LiveSync stores multiple remote connection profiles under **Connection settings****Saved connections**. Each profile represents a CouchDB database, a Journal storage connection, or a P2P configuration, and several profiles can be kept in one Vault.
Each profile has an opaque identifier and a presentation name. The name does not need to be unique and is not used to select the profile. The main remote and the P2P remote are selected independently, so code and settings imports must preserve both selections rather than relying on a special identifier such as `default`.
@@ -317,7 +317,69 @@ Adaptive Journal can download a complete immutable Pack (`whole-pack`) or reques
#### Apply Settings
### 6. CouchDB
### 6. WebDAV Journal
WebDAV Journal is an experimental remote provider configured when adding (``) or editing (`🔧`) a saved connection profile. Use a dedicated WebDAV collection or prefix rather than mixing Journal objects with unrelated files.
#### WebDAV connection
Setting key: webDAVactiveConnectionURI
The saved `sls+webdav` value contains the HTTP or HTTPS endpoint, optional username and password, collection prefix, custom headers, and internal-API preference. The setup dialogue parses and serialises this value; do not edit it manually or share it as plain text. Only HTTPS endpoints work on Obsidian Mobile.
**Use internal API** routes requests through Obsidian when browser-compatible requests are blocked by CORS. It is a compatibility option with different behaviour from standard browser fetch.
#### Journal data format
Setting key: journalFormat
`opaque-v1` is the compatible default. `adaptive-v1` uses authenticated immutable Commit Bundles and Packs under a separate remote representation. Existing Opaque Journal data is neither migrated nor read as Adaptive data. Changing formats requires an explicit remote Rebuild or a separate collection prefix.
#### Expected repository ID
Setting key: expectedRepositoryId
This optional base64url identity pins a trusted Adaptive repository. A trusted Setup URI can supply it. Leave it blank only when creating a repository or intentionally trusting the first compatible Adaptive repository reached.
#### Pack retrieval
Setting key: packReadPolicy
Complete Pack retrieval (`whole-pack`) is the portable, throughput-oriented default. Range retrieval (`range`) can reduce transferred bytes, but the endpoint must pass the exact byte-range check. Range support is optional and does not affect the safety of complete Pack retrieval.
#### Endpoint safety check
Adaptive setup writes, reads, lists, and removes disposable objects under a random reserved probe prefix. It checks binary fidelity, read-after-write and delete visibility, complete listing of the probe objects, and conditional creation which does not replace an existing object. Exact HTTP byte-range behaviour is reported separately as optional.
The dialogue reports required operations and Range support independently. Authentication, permission, availability, and invalid-response failures remain explicit failures rather than being reported as unsupported features. A format mismatch requires a remote Rebuild or restoration of the matching profile format.
Onboarding accepts the profile only after the selected policy passes. Adding or editing a profile in Settings also offers **Save without connecting**, which preserves a locally valid but unverified profile.
### 7. PostgREST Journal
PostgREST Journal is an experimental, Adaptive-only provider. It uses the packaged PostgREST RPC schema rather than exposing synchronisation tables or acting as a CouchDB replication endpoint.
#### PostgREST connection
Setting key: postgrestActiveConnectionURI
The saved `sls+postgrest` value contains the HTTP or HTTPS endpoint, exposed schema, Vault ID, Vault credential, optional client-safe API key, and internal-API preference. A trusted database administrator provisions the Vault ID and credential by following the SQL package instructions. Do not enter a database credential, Supabase secret key, or `service_role` JWT in a client profile.
Only HTTPS endpoints work on Obsidian Mobile. **Use internal API** routes requests through Obsidian when browser-compatible requests are blocked by CORS.
#### Adaptive format and repository identity
PostgREST always uses `adaptive-v1` with native Chunk rows and does not expose Opaque Journal, object Pack, Catalogue, Delta, or Range-retrieval options. Existing data in another Journal format is not read or migrated. Changing the wire or SQL format requires detection and a remote Rebuild.
Setting key: expectedRepositoryId
The optional expected repository ID pins a trusted Adaptive repository in the same way as other Adaptive Journal providers.
#### Server capability check
The setup dialogue verifies the installed RPC operations, Vault authentication, and exact binary behaviour through the provider-neutral Journal inspection boundary. Onboarding accepts the profile only after this check succeeds. Settings can preserve a locally valid profile without connecting so that an unavailable endpoint can be corrected later.
### 8. CouchDB
These settings are configured within the CouchDB Setup dialogue when adding (``) or editing (`🔧`) a CouchDB connection profile.
@@ -411,7 +473,7 @@ This optional check reads the CouchDB server configuration through Obsidian's in
#### Apply Settings
### 7. Peer-to-Peer (P2P) Synchronisation
### 8. Peer-to-Peer (P2P) Synchronisation
#### Enable P2P Synchronisation
+4
View File
@@ -46,6 +46,7 @@
"test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli",
"test:e2e:cli:adaptive-s3": "npm run test:e2e:adaptive-s3 --workspace self-hosted-livesync-cli",
"test:e2e:cli:adaptive-webdav": "npm run test:e2e:adaptive-webdav --workspace self-hosted-livesync-cli",
"test:e2e:cli:adaptive-postgrest": "npm run test:e2e:adaptive-postgrest --workspace self-hosted-livesync-cli",
"test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli",
"test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts",
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage",
@@ -62,6 +63,7 @@
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
"test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts",
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
"test:e2e:obsidian:remote-setup-providers": "tsx test/e2e-obsidian/scripts/remote-setup-providers.ts",
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
@@ -96,6 +98,8 @@
"test:docker-s3:start": "npm run test:docker-s3:up && sleep 3 && npm run test:docker-s3:init",
"test:docker-s3:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/minio-stop.sh",
"test:docker-s3:stop": "npm run test:docker-s3:down",
"test:docker-postgrest:start": "npx dotenv-cli -e .env -e .test.env -- deno run --env-file=src/apps/cli/testdeno/.test.env -A --no-check src/apps/cli/testdeno/manage-postgrest-fixture.ts start",
"test:docker-postgrest:stop": "npx dotenv-cli -e .env -e .test.env -- deno run --env-file=src/apps/cli/testdeno/.test.env -A --no-check src/apps/cli/testdeno/manage-postgrest-fixture.ts stop",
"test:docker-all:up": "npm run test:docker-couchdb:up ; npm run test:docker-s3:up",
"test:docker-all:init": "npm run test:docker-couchdb:init ; npm run test:docker-s3:init",
"test:docker-all:down": "npm run test:docker-couchdb:down ; npm run test:docker-s3:down",
+2
View File
@@ -26,6 +26,8 @@
"test:e2e:adaptive-s3": "deno task --cwd testdeno test:adaptive-journal-s3",
"pretest:e2e:adaptive-webdav": "npm run build",
"test:e2e:adaptive-webdav": "deno task --cwd testdeno test:adaptive-journal-webdav",
"pretest:e2e:adaptive-postgrest": "npm run build",
"test:e2e:adaptive-postgrest": "deno task --cwd testdeno test:adaptive-journal-postgrest",
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
"test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh",
"pretest:e2e:all": "npm run build",
+2 -1
View File
@@ -36,7 +36,8 @@
"test:e2e-matrix:minio-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc0' test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts",
"test:adaptive-journal-s3": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-s3.ts",
"test:adaptive-journal-webdav": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-webdav.ts"
"test:adaptive-journal-webdav": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-webdav.ts",
"test:adaptive-journal-postgrest": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-postgrest.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.13",
@@ -0,0 +1,12 @@
do $$
begin
if not exists (select 1 from pg_roles where rolname = 'livesync_postgrest_anon') then
create role livesync_postgrest_anon nologin;
end if;
if not exists (select 1 from pg_roles where rolname = 'livesync_postgrest_authenticator') then
create role livesync_postgrest_authenticator noinherit login password 'integration-password';
end if;
end
$$;
grant livesync_postgrest_anon to livesync_postgrest_authenticator;
@@ -0,0 +1,6 @@
insert into livesync_private.adaptive_vaults (vault_id, credential_digest)
values (
'adaptive-cli-vault-01',
livesync_private.sha256(convert_to('adaptive-cli-vault-credential-0000000000001', 'UTF8'))
)
on conflict (vault_id) do update set credential_digest = excluded.credential_digest;
+9 -7
View File
@@ -40,13 +40,15 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array {
return out;
}
export function redactCliSensitiveText(value: string): string {
return value
.replace(/(obsidian:\/\/setuplivesync\?settings=)[^\s"']+/gu, "$1<redacted>")
.replace(/(sls\+[^:\s]+:\/\/)[^/?#@\s]*@/gu, "$1<redacted>@");
}
export function formatTeeCommand(args: string[]): string {
const redactArgument = (argument: string): string => {
if (argument.startsWith(SETUP_URI_PREFIX)) {
return `${SETUP_URI_PREFIX}<redacted>`;
}
return argument.replace(/^(sls\+[^:]+:\/\/)[^/?#@]*@/u, "$1<redacted>@");
};
const redactArgument = (argument: string): string =>
argument.startsWith(SETUP_URI_PREFIX) ? `${SETUP_URI_PREFIX}<redacted>` : redactCliSensitiveText(argument);
return ["node", CLI_DIST, ...args.map(redactArgument)].map((part) => JSON.stringify(part)).join(" ");
}
@@ -64,7 +66,7 @@ export function createLineTeeWriter(
writer(enc.encode(`[CLI tee pid=${pid}:${streamName}]\n`));
headerWritten = true;
}
writer(enc.encode(`[CLI tee pid=${pid}:${streamName}] ${line}\n`));
writer(enc.encode(`[CLI tee pid=${pid}:${streamName}] ${redactCliSensitiveText(line)}\n`));
};
const flush = (final = false) => {
+281 -10
View File
@@ -6,6 +6,8 @@
* available — including Windows — without needing bash.
*/
import { join } from "@std/path";
type DockerInvoker = {
bin: string;
prefix: string[];
@@ -15,6 +17,7 @@ type DockerInvoker = {
let dockerInvokerPromise: Promise<DockerInvoker> | null = null;
const DOCKER_TEE = Deno.env.get("LIVESYNC_DOCKER_TEE") === "1" || Deno.env.get("LIVESYNC_TEST_TEE") === "1";
const trackedContainers = new Set<string>();
const trackedNetworks = new Set<string>();
const CLEANUP_SIGNALS: Deno.Signal[] = ["SIGINT", "SIGTERM"];
let signalCleanupHandlersInstalled = false;
let signalCleanupInProgress = false;
@@ -62,17 +65,28 @@ async function collectStream(
return out;
}
async function runCommand(bin: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
async function runCommand(
bin: string,
args: string[],
stdinData?: Uint8Array
): Promise<{ code: number; stdout: string; stderr: string }> {
try {
const child = new Deno.Command(bin, {
args,
stdin: "null",
stdin: stdinData ? "piped" : "null",
stdout: "piped",
stderr: "piped",
}).spawn();
const stdoutPromise = collectStream(child.stdout, DOCKER_TEE ? (chunk) => Deno.stdout.writeSync(chunk) : null);
const stderrPromise = collectStream(child.stderr, DOCKER_TEE ? (chunk) => Deno.stderr.writeSync(chunk) : null);
const [status, stdout, stderr] = await Promise.all([child.status, stdoutPromise, stderrPromise]);
const stdinPromise = stdinData
? (async () => {
const writer = child.stdin.getWriter();
await writer.write(stdinData);
await writer.close();
})()
: Promise.resolve();
const [status, stdout, stderr] = await Promise.all([child.status, stdoutPromise, stderrPromise, stdinPromise]);
const dec = new TextDecoder();
const result = {
code: status.code,
@@ -163,6 +177,20 @@ async function getDockerInvoker(): Promise<DockerInvoker> {
}
async function docker(...args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
return await dockerCommand(undefined, ...args);
}
async function dockerWithInput(
input: Uint8Array,
...args: string[]
): Promise<{ code: number; stdout: string; stderr: string }> {
return await dockerCommand(input, ...args);
}
async function dockerCommand(
input: Uint8Array | undefined,
...args: string[]
): Promise<{ code: number; stdout: string; stderr: string }> {
const invoker = await getDockerInvoker();
// Either:
@@ -176,7 +204,7 @@ async function docker(...args: string[]): Promise<{ code: number; stdout: string
: args
: [...invoker.prefix, ...args];
const r = await runCommand(invoker.bin, finalArgs);
const r = await runCommand(invoker.bin, finalArgs, input);
return { code: r.code, stdout: r.stdout, stderr: r.stderr };
}
@@ -195,12 +223,21 @@ async function stopAndRemoveContainer(container: string): Promise<void> {
async function cleanupTrackedContainers(reason: string): Promise<void> {
const names = [...trackedContainers];
if (names.length === 0) return;
if (names.length > 0) {
console.warn(`[WARN] cleaning up tracked containers on ${reason}: ${names.join(", ")}`);
for (const container of names.reverse()) {
await stopAndRemoveContainer(container);
trackedContainers.delete(container);
}
}
console.warn(`[WARN] cleaning up tracked containers on ${reason}: ${names.join(", ")}`);
for (const container of names.reverse()) {
await stopAndRemoveContainer(container);
trackedContainers.delete(container);
const networks = [...trackedNetworks];
if (networks.length > 0) {
console.warn(`[WARN] cleaning up tracked networks on ${reason}: ${networks.join(", ")}`);
for (const network of networks.reverse()) {
await docker("network", "rm", network).catch(() => {});
trackedNetworks.delete(network);
}
}
}
@@ -250,7 +287,19 @@ function trackContainer(container: string): void {
function untrackContainer(container: string): void {
trackedContainers.delete(container);
if (trackedContainers.size === 0) {
if (trackedContainers.size === 0 && trackedNetworks.size === 0) {
removeSignalCleanupHandlers();
}
}
function trackNetwork(network: string): void {
ensureSignalCleanupHandlers();
trackedNetworks.add(network);
}
function untrackNetwork(network: string): void {
trackedNetworks.delete(network);
if (trackedContainers.size === 0 && trackedNetworks.size === 0) {
removeSignalCleanupHandlers();
}
}
@@ -332,6 +381,14 @@ const MINIO_MC_IMAGE = "minio/mc:RELEASE.2025-04-16T18-13-26Z";
const WEBDAV_CONTAINER = "webdav-test";
const WEBDAV_IMAGE = "httpd:2.4.68";
const POSTGRES_CONTAINER = "postgrest-postgres-test";
const POSTGRES_IMAGE = "postgres:15-alpine";
const POSTGREST_CONTAINER = "postgrest-test";
const POSTGREST_IMAGE = "postgrest/postgrest:v14.16";
const POSTGREST_NETWORK = "postgrest-test";
const POSTGREST_DATABASE_PASSWORD = "integration-password";
const POSTGREST_VAULT_ID = "adaptive-cli-vault-01";
const POSTGREST_VAULT_CREDENTIAL = "adaptive-cli-vault-credential-0000000000001";
const WEBDAV_HTTPD_CONFIG = `ServerRoot "/usr/local/apache2"
Listen 80
@@ -765,6 +822,220 @@ export async function readWebDAVObjectText(collectionEndpoint: string, key: stri
return await response.text();
}
// ---------------------------------------------------------------------------
// PostgREST
// ---------------------------------------------------------------------------
export interface PostgRESTFixture {
endpoint: string;
vaultCredential: string;
vaultId: string;
}
export interface PostgRESTAdaptiveRowCounts {
chunks: number;
commits: number;
manifests: number;
writers: number;
}
async function waitForPostgres(): Promise<void> {
let consecutiveReadyChecks = 0;
for (let attempt = 0; attempt < 60; attempt += 1) {
const result = await docker("exec", POSTGRES_CONTAINER, "pg_isready", "-U", "postgres");
if (result.code === 0) {
consecutiveReadyChecks += 1;
if (consecutiveReadyChecks >= 3) return;
} else {
consecutiveReadyChecks = 0;
}
await sleep(500);
}
throw new Error("PostgreSQL did not become ready in time");
}
async function applyPostgresSql(sql: string): Promise<void> {
const result = await dockerWithInput(
new TextEncoder().encode(sql),
"exec",
"-i",
POSTGRES_CONTAINER,
"psql",
"-v",
"ON_ERROR_STOP=1",
"-U",
"postgres",
"-d",
"postgres"
);
if (result.code !== 0) {
throw new Error(`Could not initialise PostgreSQL for PostgREST: ${result.stderr.trim()}`);
}
}
async function waitForPostgREST(fixture: PostgRESTFixture): Promise<void> {
const capabilityUrl = `${fixture.endpoint.replace(/\/+$/u, "")}/rpc/livesync_adaptive_capabilities`;
for (let attempt = 0; attempt < 30; attempt += 1) {
try {
const response = await fetch(capabilityUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Accept-Profile": "livesync_api",
"Content-Profile": "livesync_api",
"Content-Type": "application/json",
"X-LiveSync-Vault-Credential": fixture.vaultCredential,
"X-LiveSync-Vault-ID": fixture.vaultId,
},
body: "{}",
signal: AbortSignal.timeout(3000),
});
await response.body?.cancel().catch(() => {});
if (response.ok) return;
} catch {
// PostgREST or its schema cache is still starting.
}
await sleep(500);
}
throw new Error(`PostgREST did not become ready: ${fixture.endpoint}`);
}
export async function stopPostgREST(): Promise<void> {
await stopAndRemoveContainer(POSTGREST_CONTAINER);
untrackContainer(POSTGREST_CONTAINER);
await stopAndRemoveContainer(POSTGRES_CONTAINER);
untrackContainer(POSTGRES_CONTAINER);
await docker("network", "rm", POSTGREST_NETWORK).catch(() => {});
untrackNetwork(POSTGREST_NETWORK);
}
export async function startPostgREST(endpoint: string): Promise<PostgRESTFixture> {
const endpointUrl = new URL(endpoint);
if (
endpointUrl.protocol !== "http:" ||
(endpointUrl.hostname !== "127.0.0.1" && endpointUrl.hostname !== "localhost")
) {
throw new Error(`Managed PostgREST requires a local HTTP endpoint, received: ${endpoint}`);
}
if (endpointUrl.pathname !== "/" || endpointUrl.search || endpointUrl.hash) {
throw new Error(`Managed PostgREST requires a root endpoint without query parameters, received: ${endpoint}`);
}
const fixture: PostgRESTFixture = {
endpoint: endpoint.replace(/\/+$/u, ""),
vaultCredential: POSTGREST_VAULT_CREDENTIAL,
vaultId: POSTGREST_VAULT_ID,
};
const fixtureDirectory = join(import.meta.dirname!, "..", "fixtures", "postgrest");
const commonlibSqlPath = join(
import.meta.dirname!,
"..",
"..",
"..",
"..",
"..",
"node_modules",
"@vrtmrz",
"livesync-commonlib",
"sql",
"postgrest",
"adaptive_journal_v1.sql"
);
console.log("[INFO] stopping leftover PostgREST test services if present");
await stopPostgREST().catch(() => {});
try {
await dockerOrFail("network", "create", POSTGREST_NETWORK);
trackNetwork(POSTGREST_NETWORK);
console.log("[INFO] starting PostgreSQL test container");
await dockerOrFail(
"run",
"-d",
"--name",
POSTGRES_CONTAINER,
"--network",
POSTGREST_NETWORK,
"--network-alias",
"postgres",
"-e",
`POSTGRES_PASSWORD=${POSTGREST_DATABASE_PASSWORD}`,
POSTGRES_IMAGE
);
trackContainer(POSTGRES_CONTAINER);
await waitForPostgres();
for (const sqlPath of [
join(fixtureDirectory, "00_roles.sql"),
commonlibSqlPath,
join(fixtureDirectory, "02_vault.sql"),
]) {
await applyPostgresSql(await Deno.readTextFile(sqlPath));
}
console.log("[INFO] starting PostgREST test container");
await dockerOrFail(
"run",
"-d",
"--name",
POSTGREST_CONTAINER,
"--network",
POSTGREST_NETWORK,
"-p",
`${endpointUrl.port || "80"}:3000`,
"-e",
"PGRST_DB_ANON_ROLE=livesync_postgrest_anon",
"-e",
"PGRST_DB_SCHEMAS=livesync_api",
"-e",
`PGRST_DB_URI=postgres://livesync_postgrest_authenticator:${POSTGREST_DATABASE_PASSWORD}@postgres:5432/postgres`,
"-e",
"PGRST_SERVER_PORT=3000",
POSTGREST_IMAGE
);
trackContainer(POSTGREST_CONTAINER);
await waitForPostgREST(fixture);
return fixture;
} catch (error) {
await stopPostgREST().catch(() => {});
throw error;
}
}
export async function readPostgRESTAdaptiveRowCounts(vaultId: string): Promise<PostgRESTAdaptiveRowCounts> {
if (!/^[A-Za-z0-9_-]{16,128}$/u.test(vaultId)) {
throw new Error(`Invalid PostgREST test Vault ID: ${vaultId}`);
}
const query = `select
(select count(*) from livesync_private.adaptive_v1_manifests where vault_id = '${vaultId}'),
(select count(*) from livesync_private.adaptive_v1_chunks where vault_id = '${vaultId}'),
(select count(*) from livesync_private.adaptive_v1_writers where vault_id = '${vaultId}'),
(select count(*) from livesync_private.adaptive_v1_commits where vault_id = '${vaultId}')`;
const output = await dockerOrFail(
"exec",
POSTGRES_CONTAINER,
"psql",
"-At",
"-F",
",",
"-U",
"postgres",
"-d",
"postgres",
"-c",
query
);
const [manifests, chunks, writers, commits] = output
.trim()
.split(",")
.map((value) => Number(value));
if (![manifests, chunks, writers, commits].every(Number.isSafeInteger)) {
throw new Error(`Could not parse PostgREST row counts: ${output}`);
}
return { chunks, commits, manifests, writers };
}
// ---------------------------------------------------------------------------
// P2P relay (strfry)
// ---------------------------------------------------------------------------
+5 -1
View File
@@ -127,7 +127,7 @@ export async function applyCouchdbSettings(
export async function applyRemoteSyncSettings(
settingsFile: string,
options: {
remoteType: "COUCHDB" | "MINIO" | "WEBDAV";
remoteType: "COUCHDB" | "MINIO" | "POSTGREST" | "WEBDAV";
couchdbUri?: string;
couchdbUser?: string;
couchdbPassword?: string;
@@ -136,6 +136,7 @@ export async function applyRemoteSyncSettings(
minioEndpoint?: string;
minioAccessKey?: string;
minioSecretKey?: string;
postgRESTConnectionURI?: string;
webDAVConnectionURI?: string;
encrypt?: boolean;
passphrase?: string;
@@ -162,6 +163,9 @@ export async function applyRemoteSyncSettings(
data.secretKey = options.minioSecretKey;
data.region = "auto";
data.forcePathStyle = true;
} else if (options.remoteType === "POSTGREST") {
data.remoteType = "POSTGREST";
data.postgrestActiveConnectionURI = options.postgRESTConnectionURI;
} else {
data.remoteType = "WEBDAV";
data.webDAVactiveConnectionURI = options.webDAVConnectionURI;
@@ -0,0 +1,26 @@
import { startPostgREST, stopPostgREST } from "./helpers/docker.ts";
const action = Deno.args[0];
const endpoint = (
Deno.env.get("POSTGREST_ENDPOINT") ??
Deno.env.get("postgrestEndpoint") ??
"http://127.0.0.1:3001"
).replace(/\/+$/u, "");
try {
if (action === "start") {
await startPostgREST(endpoint);
} else if (action === "stop") {
await stopPostgREST();
} else {
throw new Error("Usage: manage-postgrest-fixture.ts <start|stop>");
}
} catch (error) {
if (action === "start") await stopPostgREST().catch(() => undefined);
console.error(error instanceof Error ? error.stack : error);
Deno.exit(1);
}
// The one-shot command intentionally leaves successful services running after
// 'start'; the matching 'stop' command owns their removal.
Deno.exit(0);
+1
View File
@@ -13,6 +13,7 @@ const TASKS = [
"test:e2e-matrix:minio-enc1",
"test:adaptive-journal-s3",
"test:adaptive-journal-webdav",
"test:adaptive-journal-postgrest",
] as const;
for (const [index, task] of TASKS.entries()) {
@@ -0,0 +1,190 @@
import { assert, assertEquals } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import {
assertFilesEqual,
redactCliSensitiveText,
runCli,
runCliOrFail,
runCliWithInputOrFail,
sanitiseCatStdout,
} from "./helpers/cli.ts";
import { applyRemoteSyncSettings, generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts";
import { type PostgRESTFixture, startPostgREST, stopPostgREST } from "./helpers/docker.ts";
const BINARY_TEST_BYTES = 2 * 1024 * 1024;
function deterministicBytes(length: number, seed: number): Uint8Array {
const bytes = new Uint8Array(length);
let state = seed;
for (let index = 0; index < bytes.byteLength; index += 1) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
bytes[index] = state & 0xff;
}
return bytes;
}
function postgRESTConnectionURI(fixture: PostgRESTFixture): string {
const endpointUrl = new URL(fixture.endpoint);
const proxyUrl = new URL(`https://${endpointUrl.host}${endpointUrl.pathname}`);
proxyUrl.username = fixture.vaultId;
proxyUrl.password = fixture.vaultCredential;
if (endpointUrl.protocol === "http:") proxyUrl.searchParams.set("insecure", "true");
return `sls+postgrest:${proxyUrl.toString().slice("https:".length)}`;
}
function remoteIdFromListing(listing: string): string {
const line = listing
.split(/\r?\n/u)
.find((candidate) => candidate.includes("\tPostgREST E2E\t") || candidate.includes("\tPostgREST "));
const id = line?.split("\t", 1)[0];
if (!id) throw new Error(`PostgREST remote profile was not listed:\n${listing}`);
return id;
}
function externalFixture(endpoint: string): PostgRESTFixture {
return {
endpoint,
vaultCredential:
Deno.env.get("POSTGREST_VAULT_CREDENTIAL") ??
Deno.env.get("postgrestVaultCredential") ??
"adaptive-cli-vault-credential-0000000000001",
vaultId: Deno.env.get("POSTGREST_VAULT_ID") ?? Deno.env.get("postgrestVaultId") ?? "adaptive-cli-vault-01",
};
}
Deno.test("CLI tee output redacts a PostgREST Vault credential", () => {
assertEquals(
redactCliSensitiveText(
"exported sls+postgrest://adaptive-cli-vault-01:adaptive-cli-vault-credential@example.test/rest/v1"
),
"exported sls+postgrest://<redacted>@example.test/rest/v1"
);
});
Deno.test("e2e: two CLI vaults synchronise through Adaptive Journal PostgREST", async () => {
const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const endpoint = (
Deno.env.get("POSTGREST_ENDPOINT") ??
Deno.env.get("postgrestEndpoint") ??
"http://127.0.0.1:3001"
).replace(/\/+$/u, "");
const vaultPassphrase = "adaptive-journal-postgrest-cli-e2ee";
const setupPassphrase = "adaptive-journal-postgrest-cli-setup";
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
const fixture = shouldStartDocker ? await startPostgREST(endpoint) : externalFixture(endpoint);
const connectionURI = postgRESTConnectionURI(fixture);
await using workDir = await TempDir.create("livesync-cli-adaptive-journal-postgrest");
const vaultA = workDir.join("vault-a");
const vaultB = workDir.join("vault-b");
const settingsA = workDir.join("settings-a.json");
const settingsB = workDir.join("settings-b.json");
const binarySourceA = workDir.join("source-a.bin");
const binarySourceB = workDir.join("source-b.bin");
const binaryDestinationA = workDir.join("destination-a.bin");
const binaryDestinationB = workDir.join("destination-b.bin");
await Deno.mkdir(vaultA, { recursive: true });
await Deno.mkdir(vaultB, { recursive: true });
try {
await initSettingsFile(settingsA);
await applyRemoteSyncSettings(settingsA, {
remoteType: "POSTGREST",
postgRESTConnectionURI: connectionURI,
encrypt: true,
passphrase: vaultPassphrase,
enableCompression: false,
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
});
const addedRemote = await runCliOrFail(
vaultA,
"--settings",
settingsA,
"remote-add",
"PostgREST E2E",
connectionURI
);
const remoteId = addedRemote.trim().split("\t", 1)[0];
assert(remoteId, `remote-add did not return a profile ID:\n${addedRemote}`);
await runCliOrFail(vaultA, "--settings", settingsA, "remote-activate", remoteId);
const textPath = "adaptive/text.md";
const binaryPath = "adaptive/data.bin";
await runCliWithInputOrFail(`created-by-a-${suffix}\n`, vaultA, "--settings", settingsA, "put", textPath);
await Deno.writeFile(binarySourceA, deterministicBytes(BINARY_TEST_BYTES, 0x1a2b3c4d));
await runCliOrFail(vaultA, "--settings", settingsA, "push", binarySourceA, binaryPath);
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
const remoteListing = await runCliOrFail(vaultA, "--settings", settingsA, "remote-ls");
assertEquals(remoteIdFromListing(remoteListing), remoteId);
assert(
remoteListing
.split(/\r?\n/u)
.some((line) => line.startsWith(`${remoteId}\t`) && line.includes("\tactive\t")),
`Activated PostgREST profile was not listed as active:\n${remoteListing}`
);
const exportedConnection = (
await runCliOrFail(vaultA, "--settings", settingsA, "remote-export", remoteId)
).trim();
assert(exportedConnection.startsWith("sls+postgrest://"));
assert(exportedConnection.includes("journalFormat=adaptive-v1"));
assert(!exportedConnection.includes("packReadPolicy="));
const setupURI = await generateSetupUriFromSettings(settingsA, setupPassphrase, true, vaultPassphrase);
await initSettingsFile(settingsB);
await runCliWithInputOrFail(`${setupPassphrase}\n`, vaultB, "--settings", settingsB, "setup", setupURI);
const settingsAfterSetup = JSON.parse(await Deno.readTextFile(settingsB)) as {
encryptedPassphrase?: string;
postgrestActiveConnectionURI?: string;
};
assert(
typeof settingsAfterSetup.encryptedPassphrase === "string" &&
settingsAfterSetup.encryptedPassphrase.length > 0,
"setup did not persist the encrypted Vault passphrase"
);
assertEquals(settingsAfterSetup.postgrestActiveConnectionURI, connectionURI);
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
assertEquals(
sanitiseCatStdout(await runCliOrFail(vaultB, "--settings", settingsB, "cat", textPath)).trimEnd(),
`created-by-a-${suffix}`
);
await runCliOrFail(vaultB, "--settings", settingsB, "pull", binaryPath, binaryDestinationB);
await assertFilesEqual(binarySourceA, binaryDestinationB, "Adaptive Journal PostgREST transfer differs");
await runCliWithInputOrFail(`updated-by-b-${suffix}\n`, vaultB, "--settings", settingsB, "put", textPath);
await Deno.writeFile(binarySourceB, deterministicBytes(BINARY_TEST_BYTES, 0x5e6f7788));
await runCliOrFail(vaultB, "--settings", settingsB, "push", binarySourceB, binaryPath);
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
assertEquals(
sanitiseCatStdout(await runCliOrFail(vaultA, "--settings", settingsA, "cat", textPath)).trimEnd(),
`updated-by-b-${suffix}`
);
await runCliOrFail(vaultA, "--settings", settingsA, "pull", binaryPath, binaryDestinationA);
await assertFilesEqual(binarySourceB, binaryDestinationA, "Adaptive Journal PostgREST return transfer differs");
await runCliOrFail(vaultA, "--settings", settingsA, "rm", binaryPath);
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
const deleted = await runCli(vaultB, "--settings", settingsB, "cat", binaryPath);
assert(deleted.code !== 0, `Deleted binary remained readable:\n${deleted.combined}`);
const statusOutput = await runCliOrFail(vaultA, "--settings", settingsA, "remote-status", remoteId);
const statusJsonStart = statusOutput.indexOf("{");
assert(statusJsonStart >= 0, `PostgREST remote status did not contain JSON:\n${statusOutput}`);
const status = JSON.parse(statusOutput.slice(statusJsonStart)) as { estimatedSize?: unknown };
assert(
typeof status.estimatedSize === "number" && status.estimatedSize > 0,
`PostgREST remote status did not report a positive size: ${JSON.stringify(status)}`
);
} finally {
if (shouldStartDocker && !keepDocker) {
await stopPostgREST().catch(() => {});
}
}
});
@@ -49,6 +49,91 @@ export const liveSyncProvisionalEnglishMessages = {
"Connect to existing database and continue": "Connect to existing database and continue",
"Test connection and save": "Test connection and save",
"Save without connecting": "Save without connecting",
"WebDAV Journal Configuration": "WebDAV Journal Configuration",
"Configure a dedicated WebDAV collection for Journal synchronisation. Opaque Journal needs ordinary WebDAV access. Adaptive Journal additionally runs an endpoint safety check before the profile is accepted.":
"Configure a dedicated WebDAV collection for Journal synchronisation. Opaque Journal needs ordinary WebDAV access. Adaptive Journal additionally runs an endpoint safety check before the profile is accepted.",
"Collection prefix": "Collection prefix",
"Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work.":
"Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work.",
"Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch.":
"Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch.",
"Enter a complete HTTP or HTTPS endpoint without a query string or fragment.":
"Enter a complete HTTP or HTTPS endpoint without a query string or fragment.",
"Use HTTP Range requests": "Use HTTP Range requests",
"Complete Pack reads favour throughput and are the portable default. Range reads can reduce transferred bytes, but this endpoint must pass the exact byte-range check.":
"Complete Pack reads favour throughput and are the portable default. Range reads can reduce transferred bytes, but this endpoint must pass the exact byte-range check.",
"The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data.":
"The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data.",
"Required Adaptive operations are supported by this WebDAV endpoint.":
"Required Adaptive operations are supported by this WebDAV endpoint.",
"The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.":
"The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.",
"The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).":
"The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).",
"Required Adaptive operations were not checked.": "Required Adaptive operations were not checked.",
"Exact HTTP byte-range retrieval is supported.": "Exact HTTP byte-range retrieval is supported.",
"HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.":
"HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.",
"HTTP byte-range retrieval was not checked because the required safety check did not complete.":
"HTTP byte-range retrieval was not checked because the required safety check did not complete.",
"The selected WebDAV Journal policy is not supported by this endpoint.":
"The selected WebDAV Journal policy is not supported by this endpoint.",
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.":
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.",
"This build cannot inspect WebDAV Journal capabilities.": "This build cannot inspect WebDAV Journal capabilities.",
"Invalid WebDAV settings: ${REASON}": "Invalid WebDAV settings: ${REASON}",
"The saved connection contains credentials and custom headers. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string.":
"The saved connection contains credentials and custom headers. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string.",
"WebDAV Journal": "WebDAV Journal",
"Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check.":
"Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check.",
"Continue to WebDAV setup": "Continue to WebDAV setup",
"PostgREST Journal Configuration": "PostgREST Journal Configuration",
"Connect to the packaged, Adaptive-only PostgREST RPC contract. This experimental provider is not a CouchDB endpoint and does not expose synchronisation tables directly.":
"Connect to the packaged, Adaptive-only PostgREST RPC contract. This experimental provider is not a CouchDB endpoint and does not expose synchronisation tables directly.",
"Enter a complete HTTP or HTTPS PostgREST endpoint without database credentials, a query string, or a fragment.":
"Enter a complete HTTP or HTTPS PostgREST endpoint without database credentials, a query string, or a fragment.",
"Vault ID": "Vault ID",
"Vault credential": "Vault credential",
"Enter the provisioned Vault credential": "Enter the provisioned Vault credential",
"A trusted database administrator obtains both values once from livesync_private.provision_adaptive_vault(). PostgreSQL retains only a verifier for the credential.":
"A trusted database administrator obtains both values once from livesync_private.provision_adaptive_vault(). PostgreSQL retains only a verifier for the credential.",
"Exposed schema": "Exposed schema",
"Client API key (optional)": "Client API key (optional)",
"Supabase publishable key, if required": "Supabase publishable key, if required",
"Use only a publishable or equivalent client-safe API key. Never enter a Supabase secret key, service_role JWT, or database credential.":
"Use only a publishable or equivalent client-safe API key. Never enter a Supabase secret key, service_role JWT, or database credential.",
"Supply a valid endpoint, PostgreSQL schema identifier, provisioned Vault ID, and Vault credential.":
"Supply a valid endpoint, PostgreSQL schema identifier, provisioned Vault ID, and Vault credential.",
"PostgREST stores only Adaptive Journal records. It cannot read Opaque Journal data, and format changes require a remote Rebuild rather than an in-place migration.":
"PostgREST stores only Adaptive Journal records. It cannot read Opaque Journal data, and format changes require a remote Rebuild rather than an in-place migration.",
"The required PostgREST RPC operations and binary semantics were verified.":
"The required PostgREST RPC operations and binary semantics were verified.",
"The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.":
"The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.",
"The PostgREST SQL contract is unavailable or incompatible with this build.":
"The PostgREST SQL contract is unavailable or incompatible with this build.",
"This build cannot inspect PostgREST Journal capabilities.":
"This build cannot inspect PostgREST Journal capabilities.",
"Invalid PostgREST settings: ${REASON}": "Invalid PostgREST settings: ${REASON}",
"The saved connection contains the Vault credential and optional API key. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string.":
"The saved connection contains the Vault credential and optional API key. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string.",
"PostgREST Journal": "PostgREST Journal",
"Store Adaptive Journal records through the packaged PostgREST SQL contract. This experimental provider requires a provisioned Vault credential, and onboarding requires a successful server capability check.":
"Store Adaptive Journal records through the packaged PostgREST SQL contract. This experimental provider requires a provisioned Vault credential, and onboarding requires a successful server capability check.",
"Continue to PostgREST setup": "Continue to PostgREST setup",
"Check PostgREST server": "Check PostgREST server",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection or server SQL is corrected.":
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection or server SQL is corrected.",
"Expected repository ID": "Expected repository ID",
"This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached.":
"This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached.",
"Run endpoint safety check": "Run endpoint safety check",
"Test WebDAV connection": "Test WebDAV connection",
"Continue with verified settings": "Continue with verified settings",
"Save verified settings": "Save verified settings",
"WebDAV access and the selected Journal format were verified.":
"WebDAV access and the selected Journal format were verified.",
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
"CouchDB validates the database name when you connect. The name must not be empty.":
"CouchDB validates the database name when you connect. The name must not be empty.",
@@ -47,6 +47,15 @@ function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): strin
return defaultRemoteProviderRegistry.serialise(configuration);
}
function describeRemoteConfiguration(uri: string): string {
try {
const configuration = defaultRemoteProviderRegistry.parse(uri);
return defaultRemoteProviderRegistry.suggestName(configuration);
} catch {
return "";
}
}
function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
button.setButtonText(emoji);
button.setTooltip(tooltip, { delay: 10, placement: "top" });
@@ -259,7 +268,7 @@ export function paneRemoteConfig(
for (const config of Object.values(configs)) {
const row = new Setting(listContainer)
.setName(config.name)
.setDesc(config.uri.split("@").pop() || ""); // Show host part for privacy
.setDesc(describeRemoteConfiguration(config.uri));
if (config.id === this.editingSettings.activeConfigurationId) {
row.nameEl.addClass("sls-active-remote-name");
@@ -1,4 +1,10 @@
import { pickBucketSyncSettings, pickCouchDBSyncSettings, pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import {
pickBucketSyncSettings,
pickCouchDBSyncSettings,
pickP2PSyncSettings,
pickPostgRESTSyncSettings,
pickWebDAVSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
// Keep the setting dialogue buffer aligned with the current core settings before persisting other dirty keys.
@@ -11,7 +17,15 @@ export function syncActivatedRemoteSettings(
remoteType: source.remoteType,
activeConfigurationId: source.activeConfigurationId,
...pickBucketSyncSettings(source),
...pickWebDAVSyncSettings(source),
...pickPostgRESTSyncSettings(source),
...pickCouchDBSyncSettings(source),
...pickP2PSyncSettings(source),
// Provider pickers share these fields. Keep the active profile's protocol after
// collecting provider-specific connection fields so that one provider cannot
// normalise another provider's Journal policy.
expectedRepositoryId: source.expectedRepositoryId ?? "",
journalFormat: source.journalFormat ?? "opaque-v1",
packReadPolicy: source.packReadPolicy ?? "whole-pack",
});
}
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_POSTGREST,
REMOTE_WEBDAV,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer";
describe("syncActivatedRemoteSettings", () => {
@@ -86,4 +92,65 @@ describe("syncActivatedRemoteSettings", () => {
expect(target.couchDB_PASSWORD).toBe("current-pass");
expect(target.couchDB_DBNAME).toBe("current-db");
});
it("should copy the active WebDAV connection and Adaptive protocol into the editing buffer", () => {
const target = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
activeConfigurationId: "old-remote",
webDAVactiveConnectionURI: "sls+webdav://stale.invalid/",
expectedRepositoryId: "",
journalFormat: "opaque-v1" as const,
packReadPolicy: "whole-pack" as const,
};
const source = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_WEBDAV,
activeConfigurationId: "remote-webdav",
webDAVactiveConnectionURI: "sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1" as const,
packReadPolicy: "range" as const,
};
syncActivatedRemoteSettings(target, source);
expect(target.remoteType).toBe(REMOTE_WEBDAV);
expect(target.activeConfigurationId).toBe("remote-webdav");
expect(target.webDAVactiveConnectionURI).toBe(source.webDAVactiveConnectionURI);
expect(target.expectedRepositoryId).toBe(source.expectedRepositoryId);
expect(target.journalFormat).toBe("adaptive-v1");
expect(target.packReadPolicy).toBe("range");
});
it("should copy the active PostgREST connection and fixed Adaptive protocol into the editing buffer", () => {
const target = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
activeConfigurationId: "old-remote",
postgrestActiveConnectionURI: "",
expectedRepositoryId: "",
journalFormat: "opaque-v1" as const,
packReadPolicy: "range" as const,
};
const source = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_POSTGREST,
activeConfigurationId: "remote-postgrest",
postgrestActiveConnectionURI:
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1" as const,
packReadPolicy: "whole-pack" as const,
};
syncActivatedRemoteSettings(target, source);
expect(target.remoteType).toBe(REMOTE_POSTGREST);
expect(target.activeConfigurationId).toBe("remote-postgrest");
expect(target.postgrestActiveConnectionURI).toBe(source.postgrestActiveConnectionURI);
expect(target.expectedRepositoryId).toBe(source.expectedRepositoryId);
expect(target.journalFormat).toBe("adaptive-v1");
expect(target.packReadPolicy).toBe("whole-pack");
});
});
+30
View File
@@ -284,6 +284,36 @@ export class SetupManager extends AbstractModule {
return await this.onRemoteManualSetup("s3", userMode, currentSetting, activate);
}
/**
* Handles manual setup for WebDAV Journal storage.
* @param userMode
* @param currentSetting
* @param activate Whether to activate WebDAV as the main remote type
* @returns Promise that resolves to true if setup completed successfully, false otherwise
*/
async onWebDAVManualSetup(
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
return await this.onRemoteManualSetup("webdav", userMode, currentSetting, activate);
}
/**
* Handles manual setup for Adaptive Journal storage through PostgREST.
* @param userMode
* @param currentSetting
* @param activate Whether to activate PostgREST as the main remote type
* @returns Promise that resolves to true if setup completed successfully, false otherwise
*/
async onPostgRESTManualSetup(
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
return await this.onRemoteManualSetup("postgrest", userMode, currentSetting, activate);
}
/**
* Handles manual setup for P2P
* @param userMode
@@ -4,11 +4,14 @@ import {
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
REMOTE_POSTGREST,
REMOTE_WEBDAV,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} }));
@@ -21,6 +24,8 @@ vi.mock("./SetupWizard/dialogs/OutroAskUserMode.svelte", () => ({ default: {} })
vi.mock("./SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteWebDAV.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemotePostgREST.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
@@ -306,6 +311,44 @@ describe("SetupManager", () => {
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
});
it("preserves Adaptive WebDAV fields imported through a Setup URI profile", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
const imported = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
remoteConfigurations: {
webdav: {
id: "webdav",
name: "WebDAV notes",
uri:
"sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F" +
`&journalFormat=adaptive-v1&expectedRepositoryId=${repositoryId}&packReadPolicy=range`,
isEncrypted: false,
},
},
activeConfigurationId: "webdav",
} as ObsidianLiveSyncSettings;
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(imported)
.mockResolvedValueOnce("compatible-existing-user");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://webdav-settings");
const current = setting.currentSettings();
expect(current.activeConfigurationId).toBe("webdav");
const parsed = ConnectionStringParser.parse(current.remoteConfigurations.webdav.uri);
expect(parsed).toMatchObject({
type: "webdav",
settings: {
webDAVactiveConnectionURI: "sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F",
expectedRepositoryId: repositoryId,
journalFormat: "adaptive-v1",
packReadPolicy: "range",
},
});
});
it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
@@ -451,6 +494,8 @@ describe("SetupManager", () => {
expect.arrayContaining([
expect.objectContaining({ type: "couchdb" }),
expect.objectContaining({ type: "s3" }),
expect.objectContaining({ type: "webdav" }),
expect.objectContaining({ type: "postgrest" }),
expect.objectContaining({ type: "p2p" }),
])
);
@@ -516,6 +561,223 @@ describe("SetupManager", () => {
expect(activeProfile?.uri).not.toContain("expectedRepositoryId=");
});
it("adds and activates a manually configured WebDAV profile without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
isConfigured: true,
remoteConfigurations: {
existing: {
id: "existing",
name: "Existing remote",
uri: "sls+http://old:secret@old.example/?db=old",
isEncrypted: false,
},
},
activeConfigurationId: "existing",
};
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
webDAVactiveConnectionURI:
"sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice?prefix=notes%2F",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1",
packReadPolicy: "range",
})
.mockResolvedValueOnce(true);
await manager.onWebDAVManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.remoteType).toBe(REMOTE_WEBDAV);
expect(current.remoteConfigurations.existing).toBeDefined();
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.name).toBe("WebDAV dav.example");
expect(activeProfile?.uri).toContain("sls+webdav://alice:secret@dav.example");
expect(activeProfile?.uri).toContain("journalFormat=adaptive-v1");
expect(activeProfile?.uri).toContain("packReadPolicy=range");
expect(activeProfile?.uri).toContain("expectedRepositoryId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
it("preselects a repository identity during fresh Adaptive WebDAV onboarding", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
webDAVactiveConnectionURI:
"sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice?prefix=notes%2F",
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onWebDAVManualSetup(UserMode.NewUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.expectedRepositoryId).toMatch(/^[A-Za-z0-9_-]{43}$/u);
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.uri).toContain(`expectedRepositoryId=${current.expectedRepositoryId}`);
});
it("leaves an existing-device Adaptive WebDAV attachment on trust on first use", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
webDAVactiveConnectionURI:
"sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice?prefix=notes%2F",
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onWebDAVManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.expectedRepositoryId).toBe("");
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.uri).not.toContain("expectedRepositoryId=");
});
it.each([
[UserMode.NewUser, "onboarding"],
[UserMode.ExistingUser, "onboarding"],
[UserMode.Update, "settings"],
] as const)("passes the %s WebDAV verification policy to the manual setup dialogue", async (userMode, mode) => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
vi.spyOn(manager, "onOnboard").mockResolvedValue(false);
await manager.onWebDAVManualSetup(userMode, setting.currentSettings());
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), {
settings: setting.currentSettings(),
mode,
});
});
it("adds and activates a manually configured PostgREST profile without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
isConfigured: true,
remoteConfigurations: {
existing: {
id: "existing",
name: "Existing remote",
uri: "sls+http://old:secret@old.example/?db=old",
isEncrypted: false,
},
},
activeConfigurationId: "existing",
};
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
postgrestActiveConnectionURI:
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onPostgRESTManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.remoteType).toBe(REMOTE_POSTGREST);
expect(current.remoteConfigurations.existing).toBeDefined();
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.name).toBe("PostgREST project.example");
expect(activeProfile?.uri).toContain("sls+postgrest://vault-id-00000001:vault-credential@project.example");
expect(activeProfile?.uri).toContain("journalFormat=adaptive-v1");
expect(activeProfile?.uri).toContain("expectedRepositoryId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
expect(ConnectionStringParser.parse(activeProfile?.uri ?? "")).toMatchObject({
type: "postgrest",
settings: { packReadPolicy: "whole-pack" },
});
});
it("preselects a repository identity during fresh Adaptive PostgREST onboarding", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
postgrestActiveConnectionURI:
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onPostgRESTManualSetup(UserMode.NewUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.expectedRepositoryId).toMatch(/^[A-Za-z0-9_-]{43}$/u);
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.uri).toContain(`expectedRepositoryId=${current.expectedRepositoryId}`);
});
it("leaves an existing-device Adaptive PostgREST attachment on trust on first use", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
postgrestActiveConnectionURI:
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onPostgRESTManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.expectedRepositoryId).toBe("");
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.uri).not.toContain("expectedRepositoryId=");
});
it.each([
[UserMode.NewUser, "onboarding"],
[UserMode.ExistingUser, "onboarding"],
[UserMode.Update, "settings"],
] as const)("passes the %s PostgREST verification policy to the manual setup dialogue", async (userMode, mode) => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
vi.spyOn(manager, "onOnboard").mockResolvedValue(false);
await manager.onPostgRESTManualSetup(userMode, setting.currentSettings());
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), {
settings: setting.currentSettings(),
mode,
});
});
it("routes a manual PostgREST selection through the registered setup provider", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce("postgrest")
.mockResolvedValueOnce({
postgrestActiveConnectionURI:
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onSelectServer(setting.currentSettings(), UserMode.NewUser);
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(2, expect.anything(), {
settings: expect.anything(),
mode: "onboarding",
});
expect(setting.currentSettings().remoteType).toBe(REMOTE_POSTGREST);
});
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
@@ -5,11 +5,17 @@ import { $msg as translateMessage } from "@/common/translation";
import SetupRemoteBucket from "./dialogs/SetupRemoteBucket.svelte";
import SetupRemoteCouchDB from "./dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteP2P from "./dialogs/SetupRemoteP2P.svelte";
import SetupRemotePostgREST from "./dialogs/SetupRemotePostgREST.svelte";
import SetupRemoteWebDAV from "./dialogs/SetupRemoteWebDAV.svelte";
import type {
SetupRemoteBucketResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteCouchDBResultType,
SetupRemoteP2PResultType,
SetupRemotePostgRESTInitialData,
SetupRemotePostgRESTResultType,
SetupRemoteWebDAVInitialData,
SetupRemoteWebDAVResultType,
} from "./dialogs/setupDialogTypes";
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
@@ -91,10 +97,66 @@ export function useP2PRemoteSetup(
return registry.register(descriptor);
}
export function useWebDAVRemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"webdav">> = {
type: "webdav",
choice: () => ({
title: translateMessage("WebDAV Journal"),
description: translateMessage(
"Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check."
),
proceedTitle: translateMessage("Continue to WebDAV setup"),
}),
open: async ({ dialogManager, intent, settings }) => {
const result = await dialogManager.openWithExplicitCancel<
SetupRemoteWebDAVResultType,
SetupRemoteWebDAVInitialData
>(SetupRemoteWebDAV, {
settings,
mode: intent === "settings" ? "settings" : "onboarding",
});
return result === "cancelled" ? result : { type: "webdav", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function usePostgRESTRemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"postgrest">> = {
type: "postgrest",
choice: () => ({
title: translateMessage("PostgREST Journal"),
description: translateMessage(
"Store Adaptive Journal records through the packaged PostgREST SQL contract. This experimental provider requires a provisioned Vault credential, and onboarding requires a successful server capability check."
),
proceedTitle: translateMessage("Continue to PostgREST setup"),
}),
open: async ({ dialogManager, intent, settings }) => {
const result = await dialogManager.openWithExplicitCancel<
SetupRemotePostgRESTResultType,
SetupRemotePostgRESTInitialData
>(SetupRemotePostgREST, {
settings,
mode: intent === "settings" ? "settings" : "onboarding",
});
return result === "cancelled" ? result : { type: "postgrest", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function createBuiltInRemoteSetupRegistry(): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const registry = new RemoteSetupRegistry<BuiltInRemoteConfiguration>();
useCouchDBRemoteSetup(registry);
useS3RemoteSetup(registry);
useWebDAVRemoteSetup(registry);
usePostgRESTRemoteSetup(registry);
useP2PRemoteSetup(registry);
return registry;
}
@@ -0,0 +1,380 @@
<script lang="ts">
import { onMount } from "svelte";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import {
DEFAULT_SETTINGS,
PREFERRED_JOURNAL_SYNC,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REMOTE_POSTGREST,
isJournalStorageConnectionInspector,
type JournalStorageConnectivityResult,
type PostgRESTSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
import {
TYPE_CANCELLED,
type PostgRESTSetupMode,
type SetupRemotePostgRESTInitialData,
type SetupRemotePostgRESTResultType,
} from "./setupDialogTypes";
import {
postgRESTJournalFormFromSettings,
postgRESTSyncSettingsFromForm,
type PostgRESTJournalForm,
} from "./postgRESTJournalSettings";
import { $msg as translateMessage } from "@/common/translation";
let syncSetting = $state<PostgRESTJournalForm>(
postgRESTJournalFormFromSettings({
postgrestActiveConnectionURI: "",
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
);
let setupMode = $state<PostgRESTSetupMode>("settings");
let error = $state("");
let processing = $state(false);
let inspection = $state<JournalStorageConnectivityResult | undefined>();
let inspectionFingerprint = $state("");
let inspectedSettings = $state<PostgRESTSyncSetting | undefined>();
type Props = GuestDialogProps<SetupRemotePostgRESTResultType, SetupRemotePostgRESTInitialData>;
const { setResult, getInitialData }: Props = $props();
const context = getDialogContext();
onMount(() => {
const initialData = getInitialData?.();
if (!initialData) return;
setupMode = initialData.mode;
try {
Object.assign(syncSetting, postgRESTJournalFormFromSettings(initialData.settings));
} catch (ex) {
error = translateMessage("Invalid PostgREST settings: ${REASON}", {
REASON: ex instanceof Error ? ex.message : `${ex}`,
});
}
});
const isEndpointInsecure = $derived.by(() => syncSetting.endpoint.trim().toLowerCase().startsWith("http://"));
const isEndpointValid = $derived.by(() => {
try {
const endpoint = new URL(syncSetting.endpoint.trim());
return (
(endpoint.protocol === "http:" || endpoint.protocol === "https:") &&
endpoint.username === "" &&
endpoint.password === "" &&
endpoint.search === "" &&
endpoint.hash === ""
);
} catch {
return false;
}
});
const isSchemaValid = $derived(/^[A-Za-z_][A-Za-z0-9_]*$/u.test(syncSetting.schema.trim()));
const isVaultIdValid = $derived(/^[A-Za-z0-9_-]{16,128}$/u.test(syncSetting.vaultId.trim()));
const isVaultCredentialValid = $derived(
syncSetting.vaultCredential.length > 0 && new TextEncoder().encode(syncSetting.vaultCredential).byteLength <= 512
);
const isConnectionValid = $derived(
isEndpointValid && isSchemaValid && isVaultIdValid && isVaultCredentialValid
);
const hasInvalidInput = $derived.by(
() =>
(syncSetting.endpoint.trim() !== "" ||
syncSetting.vaultId.trim() !== "" ||
syncSetting.vaultCredential !== "") &&
!isConnectionValid
);
const formFingerprint = $derived.by(() => JSON.stringify(syncSetting));
const inspectionIsCurrent = $derived(
inspection !== undefined && inspectionFingerprint !== "" && inspectionFingerprint === formFingerprint
);
const requiredCapability = $derived.by(() => {
if (!inspectionIsCurrent) return undefined;
return inspection?.adaptiveCapabilities?.required;
});
function generateSetting(postgRESTSettings: PostgRESTSyncSetting): ObsidianLiveSyncSettings {
return {
...DEFAULT_SETTINGS,
...PREFERRED_JOURNAL_SYNC,
remoteType: REMOTE_POSTGREST,
...postgRESTSettings,
};
}
function explainUnavailable(result: JournalStorageConnectivityResult): string {
if (result.remoteFormat !== undefined && result.remoteFormat !== "empty" && result.remoteFormat !== "adaptive-v1") {
return translateMessage(
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.",
{ REMOTE_FORMAT: result.remoteFormat, SELECTED_FORMAT: "adaptive-v1" }
);
}
const required = result.adaptiveCapabilities?.required;
if (required?.status === "unsupported") {
return translateMessage("The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.", {
CAPABILITIES: required.missing.join(", "),
});
}
if (required?.status === "failed") {
return translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
CATEGORY: required.failure.category,
RETRY: required.failure.retry,
});
}
return translateMessage("The PostgREST SQL contract is unavailable or incompatible with this build.");
}
async function inspectConnection(trialRemoteSetting: ObsidianLiveSyncSettings, testedFingerprint: string) {
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) {
throw new Error(translateMessage("Failed to create replicator instance."));
}
if (!isJournalStorageConnectionInspector(replicator)) {
throw new Error(translateMessage("This build cannot inspect PostgREST Journal capabilities."));
}
const result = await replicator.inspectJournalStorageConnection(trialRemoteSetting);
inspection = result;
inspectionFingerprint = testedFingerprint;
return result;
}
async function checkConnection() {
error = "";
inspection = undefined;
inspectionFingerprint = "";
inspectedSettings = undefined;
processing = true;
try {
const testedFingerprint = formFingerprint;
const candidate = postgRESTSyncSettingsFromForm(syncSetting);
const trialRemoteSetting = generateSetting(candidate);
const result = await inspectConnection(trialRemoteSetting, testedFingerprint);
if (testedFingerprint !== formFingerprint) return;
if (!result.available) {
error = explainUnavailable(result);
return;
}
inspectedSettings = candidate;
} catch (ex) {
error = translateMessage("Error during connection test: ${reason}", {
reason: ex instanceof Error ? ex.message : `${ex}`,
});
} finally {
processing = false;
}
}
function commitVerified() {
if (!inspectionIsCurrent || !inspection?.available || !inspectedSettings) return;
setResult(inspectedSettings);
}
function commit() {
error = "";
if (!isConnectionValid) return;
try {
setResult(postgRESTSyncSettingsFromForm(syncSetting));
} catch (ex) {
error = translateMessage("Invalid PostgREST settings: ${REASON}", {
REASON: ex instanceof Error ? ex.message : `${ex}`,
});
}
}
</script>
<DialogHeader title={translateMessage("PostgREST Journal Configuration")} />
<Guidance>
{translateMessage(
"Connect to the packaged, Adaptive-only PostgREST RPC contract. This experimental provider is not a CouchDB endpoint and does not expose synchronisation tables directly."
)}
</Guidance>
<InputRow label={translateMessage("Endpoint URL")}>
<input
type="text"
name="postgrest-endpoint"
placeholder="https://project.example/rest/v1"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
pattern="^https?://.+"
bind:value={syncSetting.endpoint}
/>
</InputRow>
<InfoNote warning visible={isEndpointInsecure}>
{translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")}
</InfoNote>
<InfoNote error visible={syncSetting.endpoint.trim() !== "" && !isEndpointValid}>
{translateMessage(
"Enter a complete HTTP or HTTPS PostgREST endpoint without database credentials, a query string, or a fragment."
)}
</InfoNote>
<InputRow label={translateMessage("Vault ID")}>
<input
type="text"
name="postgrest-vault-id"
placeholder="provisioned-vault-id"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
bind:value={syncSetting.vaultId}
/>
</InputRow>
<InputRow label={translateMessage("Vault credential")}>
<Password
name="postgrest-vault-credential"
placeholder={translateMessage("Enter the provisioned Vault credential")}
required
bind:value={syncSetting.vaultCredential}
/>
</InputRow>
<InfoNote>
{translateMessage(
"A trusted database administrator obtains both values once from livesync_private.provision_adaptive_vault(). PostgreSQL retains only a verifier for the credential."
)}
</InfoNote>
<InputRow label={translateMessage("Exposed schema")}>
<input
type="text"
name="postgrest-schema"
placeholder="livesync_api"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
bind:value={syncSetting.schema}
/>
</InputRow>
<InputRow label={translateMessage("Client API key (optional)")}>
<Password
name="postgrest-api-key"
placeholder={translateMessage("Supabase publishable key, if required")}
bind:value={syncSetting.apiKey}
/>
</InputRow>
<InfoNote caution>
{translateMessage(
"Use only a publishable or equivalent client-safe API key. Never enter a Supabase secret key, service_role JWT, or database credential."
)}
</InfoNote>
<InfoNote error visible={hasInvalidInput}>
{translateMessage(
"Supply a valid endpoint, PostgreSQL schema identifier, provisioned Vault ID, and Vault credential."
)}
</InfoNote>
<InputRow label={translateMessage("Use internal API")}>
<input type="checkbox" name="postgrest-use-internal-api" bind:checked={syncSetting.useCustomRequestHandler} />
</InputRow>
<InfoNote>
{translateMessage(
"Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch."
)}
</InfoNote>
<ExtraItems title={translateMessage("Advanced Settings")}>
<InputRow label={translateMessage("Expected repository ID")}>
<input
type="text"
name="postgrest-expected-repository-id"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.expectedRepositoryId}
/>
</InputRow>
<InfoNote>
{translateMessage(
"This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached."
)}
</InfoNote>
</ExtraItems>
<InfoNote warning>
{translateMessage(
"PostgREST stores only Adaptive Journal records. It cannot read Opaque Journal data, and format changes require a remote Rebuild rather than an in-place migration."
)}
</InfoNote>
{#if requiredCapability?.status === "verified"}
<InfoNote notice>
{translateMessage("The required PostgREST RPC operations and binary semantics were verified.")}
</InfoNote>
{:else if requiredCapability?.status === "unsupported"}
<InfoNote error>
{translateMessage("The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.", {
CAPABILITIES: requiredCapability.missing.join(", "),
})}
</InfoNote>
{:else if requiredCapability?.status === "failed"}
<InfoNote error>
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
CATEGORY: requiredCapability.failure.category,
RETRY: requiredCapability.failure.retry,
})}
</InfoNote>
{:else if inspectionIsCurrent}
<InfoNote warning>{translateMessage("Required Adaptive operations were not checked.")}</InfoNote>
{/if}
<InfoNote>
{translateMessage(
"The saved connection contains the Vault credential and optional API key. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string."
)}
</InfoNote>
<InfoNote error visible={error !== ""}>{error}</InfoNote>
{#if processing}
{translateMessage("Checking connection... Please wait.")}
{:else}
<UserDecisions>
{#if inspectionIsCurrent && inspection?.available && inspectedSettings}
<Decision
title={setupMode === "settings"
? translateMessage("Save verified settings")
: translateMessage("Continue with verified settings")}
important
commit={() => commitVerified()}
/>
<Decision
title={translateMessage("Check PostgREST server")}
disabled={!isConnectionValid}
commit={() => checkConnection()}
/>
{:else}
<Decision
title={translateMessage("Check PostgREST server")}
important
disabled={!isConnectionValid}
commit={() => checkConnection()}
/>
{/if}
{#if setupMode === "settings"}
<InfoNote warning>
{translateMessage(
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection or server SQL is corrected."
)}
</InfoNote>
<Decision
title={translateMessage("Save without connecting")}
disabled={!isConnectionValid}
commit={() => commit()}
/>
{/if}
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{/if}
@@ -0,0 +1,403 @@
<script lang="ts">
import { onMount } from "svelte";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import {
DEFAULT_SETTINGS,
PREFERRED_JOURNAL_SYNC,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REMOTE_WEBDAV,
isJournalStorageConnectionInspector,
type JournalStorageConnectivityResult,
type WebDAVSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
import {
TYPE_CANCELLED,
type SetupRemoteWebDAVInitialData,
type SetupRemoteWebDAVResultType,
type WebDAVSetupMode,
} from "./setupDialogTypes";
import {
summariseAdaptiveCapabilityInspection,
webDAVJournalFormFromSettings,
webDAVSyncSettingsFromForm,
type WebDAVJournalForm,
} from "./webDAVJournalSettings";
import { $msg as translateMessage } from "@/common/translation";
let syncSetting = $state<WebDAVJournalForm>(
webDAVJournalFormFromSettings({
webDAVactiveConnectionURI: "",
expectedRepositoryId: "",
journalFormat: "opaque-v1",
packReadPolicy: "whole-pack",
})
);
let setupMode = $state<WebDAVSetupMode>("settings");
let error = $state("");
let processing = $state(false);
let inspection = $state<JournalStorageConnectivityResult | undefined>();
let inspectionFingerprint = $state("");
let inspectedSettings = $state<WebDAVSyncSetting | undefined>();
type Props = GuestDialogProps<SetupRemoteWebDAVResultType, SetupRemoteWebDAVInitialData>;
const { setResult, getInitialData }: Props = $props();
const context = getDialogContext();
onMount(() => {
const initialData = getInitialData?.();
if (!initialData) return;
setupMode = initialData.mode;
try {
Object.assign(syncSetting, webDAVJournalFormFromSettings(initialData.settings));
} catch (ex) {
error = translateMessage("Invalid WebDAV settings: ${REASON}", {
REASON: ex instanceof Error ? ex.message : `${ex}`,
});
}
});
const isAdaptive = $derived(syncSetting.journalFormat === "adaptive-v1");
const isEndpointInsecure = $derived.by(() => syncSetting.endpoint.trim().toLowerCase().startsWith("http://"));
const isEndpointValid = $derived.by(() => {
try {
const endpoint = new URL(syncSetting.endpoint.trim());
return (
(endpoint.protocol === "http:" || endpoint.protocol === "https:") &&
endpoint.search === "" &&
endpoint.hash === ""
);
} catch {
return false;
}
});
const isEndpointInvalid = $derived(syncSetting.endpoint.trim() !== "" && !isEndpointValid);
const formFingerprint = $derived.by(() => JSON.stringify(syncSetting));
const inspectionIsCurrent = $derived(
inspection !== undefined && inspectionFingerprint !== "" && inspectionFingerprint === formFingerprint
);
const adaptiveSummary = $derived.by(() => {
if (!inspectionIsCurrent || !inspection?.adaptiveCapabilities) return undefined;
return summariseAdaptiveCapabilityInspection(inspection.adaptiveCapabilities);
});
function generateSetting(webDAVSettings: WebDAVSyncSetting): ObsidianLiveSyncSettings {
return {
...DEFAULT_SETTINGS,
...PREFERRED_JOURNAL_SYNC,
remoteType: REMOTE_WEBDAV,
...webDAVSettings,
};
}
function explainUnavailable(result: JournalStorageConnectivityResult): string {
if (
result.remoteFormat !== undefined &&
result.remoteFormat !== "empty" &&
result.remoteFormat !== syncSetting.journalFormat
) {
return translateMessage(
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.",
{
REMOTE_FORMAT: result.remoteFormat,
SELECTED_FORMAT: syncSetting.journalFormat,
}
);
}
return translateMessage("The selected WebDAV Journal policy is not supported by this endpoint.");
}
async function inspectConnection(trialRemoteSetting: ObsidianLiveSyncSettings, testedFingerprint: string) {
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) {
throw new Error(translateMessage("Failed to create replicator instance."));
}
if (!isJournalStorageConnectionInspector(replicator)) {
throw new Error(translateMessage("This build cannot inspect WebDAV Journal capabilities."));
}
const result = await replicator.inspectJournalStorageConnection(trialRemoteSetting);
inspection = result;
inspectionFingerprint = testedFingerprint;
return result;
}
async function checkConnection() {
error = "";
inspection = undefined;
inspectionFingerprint = "";
inspectedSettings = undefined;
processing = true;
try {
const testedFingerprint = formFingerprint;
const candidate = webDAVSyncSettingsFromForm(syncSetting);
const trialRemoteSetting = generateSetting(candidate);
const result = await inspectConnection(trialRemoteSetting, testedFingerprint);
if (testedFingerprint !== formFingerprint) return;
if (!result.available) {
error = explainUnavailable(result);
return;
}
inspectedSettings = candidate;
} catch (ex) {
error = translateMessage("Error during connection test: ${reason}", {
reason: ex instanceof Error ? ex.message : `${ex}`,
});
} finally {
processing = false;
}
}
function commitVerified() {
if (!inspectionIsCurrent || !inspection?.available || !inspectedSettings) return;
setResult(inspectedSettings);
}
function commit() {
error = "";
try {
setResult(webDAVSyncSettingsFromForm(syncSetting));
} catch (ex) {
error = translateMessage("Invalid WebDAV settings: ${REASON}", {
REASON: ex instanceof Error ? ex.message : `${ex}`,
});
}
}
</script>
<DialogHeader title={translateMessage("WebDAV Journal Configuration")} />
<Guidance>
{translateMessage(
"Configure a dedicated WebDAV collection for Journal synchronisation. Opaque Journal needs ordinary WebDAV access. Adaptive Journal additionally runs an endpoint safety check before the profile is accepted."
)}
</Guidance>
<InputRow label={translateMessage("Endpoint URL")}>
<input
type="text"
name="webdav-endpoint"
placeholder="https://dav.example/remote.php/dav/files/alice"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
pattern="^https?://.+"
bind:value={syncSetting.endpoint}
/>
</InputRow>
<InfoNote warning visible={isEndpointInsecure}>
{translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")}
</InfoNote>
<InfoNote error visible={isEndpointInvalid}>
{translateMessage("Enter a complete HTTP or HTTPS endpoint without a query string or fragment.")}
</InfoNote>
<InputRow label={translateMessage("Username")}>
<input
type="text"
name="webdav-username"
placeholder={translateMessage("Enter your username")}
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.username}
/>
</InputRow>
<InputRow label={translateMessage("Password")}>
<Password
name="webdav-password"
placeholder={translateMessage("Enter your password")}
bind:value={syncSetting.password}
/>
</InputRow>
<InputRow label={translateMessage("Collection prefix")}>
<input
type="text"
name="webdav-prefix"
placeholder="livesync-journal/"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.prefix}
/>
</InputRow>
<InfoNote>
{translateMessage(
"Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work."
)}
</InfoNote>
<InputRow label={translateMessage("Use internal API")}>
<input type="checkbox" name="webdav-use-internal-api" bind:checked={syncSetting.useCustomRequestHandler} />
</InputRow>
<InfoNote>
{translateMessage(
"Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch."
)}
</InfoNote>
<ExtraItems title={translateMessage("Advanced Settings")}>
<InputRow label={translateMessage("Journal data format")}>
<select name="webdav-journal-format" bind:value={syncSetting.journalFormat}>
<option value="opaque-v1">{translateMessage("Opaque Journal (current format)")}</option>
<option value="adaptive-v1">{translateMessage("Adaptive Journal (experimental)")}</option>
</select>
</InputRow>
<InfoNote warning visible={isAdaptive}>
{translateMessage(
"Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats."
)}
</InfoNote>
{#if isAdaptive}
<InputRow label={translateMessage("Expected repository ID")}>
<input
type="text"
name="webdav-expected-repository-id"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.expectedRepositoryId}
/>
</InputRow>
<InfoNote>
{translateMessage(
"This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached."
)}
</InfoNote>
<InputRow label={translateMessage("Pack retrieval")}>
<select name="webdav-pack-read-policy" bind:value={syncSetting.packReadPolicy}>
<option value="whole-pack">{translateMessage("Download complete Packs")}</option>
<option value="range">{translateMessage("Use HTTP Range requests")}</option>
</select>
</InputRow>
<InfoNote>
{translateMessage(
"Complete Pack reads favour throughput and are the portable default. Range reads can reduce transferred bytes, but this endpoint must pass the exact byte-range check."
)}
</InfoNote>
<InfoNote caution>
{translateMessage(
"The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data."
)}
</InfoNote>
{/if}
<InputRow label={translateMessage("Custom Headers")}>
<textarea
name="webdav-custom-headers"
placeholder="e.g., x-example-header: value"
bind:value={syncSetting.customHeaders}
autocapitalize="off"
spellcheck="false"
rows="4"
></textarea>
</InputRow>
</ExtraItems>
{#if adaptiveSummary}
{#if adaptiveSummary.required.kind === "verified"}
<InfoNote notice>
{translateMessage("Required Adaptive operations are supported by this WebDAV endpoint.")}
</InfoNote>
{:else if adaptiveSummary.required.kind === "unsupported"}
<InfoNote error>
{translateMessage("The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.", {
CAPABILITIES: adaptiveSummary.required.missing.join(", "),
})}
</InfoNote>
{:else if adaptiveSummary.required.kind === "failed"}
<InfoNote error>
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
CATEGORY: adaptiveSummary.required.category,
RETRY: adaptiveSummary.required.retry,
})}
</InfoNote>
{:else}
<InfoNote warning>{translateMessage("Required Adaptive operations were not checked.")}</InfoNote>
{/if}
{#if adaptiveSummary.byteRange.kind === "verified"}
<InfoNote notice>{translateMessage("Exact HTTP byte-range retrieval is supported.")}</InfoNote>
{:else if adaptiveSummary.byteRange.kind === "unsupported"}
<InfoNote warning>
{translateMessage("HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.")}
</InfoNote>
{:else if adaptiveSummary.byteRange.kind === "failed"}
<InfoNote warning>
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
CATEGORY: adaptiveSummary.byteRange.category,
RETRY: adaptiveSummary.byteRange.retry,
})}
</InfoNote>
{:else}
<InfoNote warning>
{translateMessage(
"HTTP byte-range retrieval was not checked because the required safety check did not complete."
)}
</InfoNote>
{/if}
{/if}
{#if inspectionIsCurrent && inspection?.available && !isAdaptive}
<InfoNote notice>
{translateMessage("WebDAV access and the selected Journal format were verified.")}
</InfoNote>
{/if}
<InfoNote>
{translateMessage(
"The saved connection contains credentials and custom headers. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string."
)}
</InfoNote>
<InfoNote error visible={error !== ""}>{error}</InfoNote>
{#if processing}
{translateMessage("Checking connection... Please wait.")}
{:else}
<UserDecisions>
{#if inspectionIsCurrent && inspection?.available && inspectedSettings}
<Decision
title={setupMode === "settings"
? translateMessage("Save verified settings")
: translateMessage("Continue with verified settings")}
important
commit={() => commitVerified()}
/>
<Decision
title={isAdaptive
? translateMessage("Run endpoint safety check")
: translateMessage("Test WebDAV connection")}
disabled={!isEndpointValid}
commit={() => checkConnection()}
/>
{:else}
<Decision
title={isAdaptive
? translateMessage("Run endpoint safety check")
: translateMessage("Test WebDAV connection")}
important
disabled={!isEndpointValid}
commit={() => checkConnection()}
/>
{/if}
{#if setupMode === "settings"}
<InfoNote warning>
{translateMessage(
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
)}
</InfoNote>
<Decision
title={translateMessage("Save without connecting")}
disabled={!isEndpointValid}
commit={() => commit()}
/>
{/if}
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{/if}
@@ -0,0 +1,65 @@
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REMOTE_POSTGREST,
journalProtocolConfigurationForSettings,
parsePostgRESTConnectionURI,
serialisePostgRESTConnectionURI,
type PostgRESTConnection,
type PostgRESTSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
export type PostgRESTJournalForm = PostgRESTConnection & {
expectedRepositoryId: string;
};
const emptyPostgRESTConnection: PostgRESTConnection = {
apiKey: "",
endpoint: "",
schema: "livesync_api",
useCustomRequestHandler: false,
vaultCredential: "",
vaultId: "",
};
function resolveProtocol(settings: PostgRESTSyncSetting) {
return journalProtocolConfigurationForSettings({
...DEFAULT_SETTINGS,
remoteType: REMOTE_POSTGREST,
...settings,
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
});
}
export function postgRESTJournalFormFromSettings(settings: PostgRESTSyncSetting): PostgRESTJournalForm {
const activeConnectionURI = settings.postgrestActiveConnectionURI.trim();
const connection = activeConnectionURI
? parsePostgRESTConnectionURI(activeConnectionURI)
: emptyPostgRESTConnection;
const protocol = resolveProtocol({
...settings,
expectedRepositoryId: activeConnectionURI ? settings.expectedRepositoryId : "",
});
return {
...connection,
expectedRepositoryId: protocol.expectedRepositoryId,
};
}
export function postgRESTSyncSettingsFromForm(form: PostgRESTJournalForm): PostgRESTSyncSetting {
const settings: PostgRESTSyncSetting = {
postgrestActiveConnectionURI: serialisePostgRESTConnectionURI({
apiKey: form.apiKey.trim(),
endpoint: form.endpoint.trim(),
schema: form.schema.trim(),
useCustomRequestHandler: form.useCustomRequestHandler,
vaultCredential: form.vaultCredential,
vaultId: form.vaultId.trim(),
}),
expectedRepositoryId: form.expectedRepositoryId.trim(),
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
};
resolveProtocol(settings);
return settings;
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { serialisePostgRESTConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
import { postgRESTJournalFormFromSettings, postgRESTSyncSettingsFromForm } from "./postgRESTJournalSettings.ts";
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
describe("PostgREST Journal settings", () => {
it("round-trips client connection fields with the fixed Adaptive protocol", () => {
const settings = {
postgrestActiveConnectionURI: serialisePostgRESTConnectionURI({
apiKey: "publishable-key",
endpoint: "https://project.example/rest/v1",
schema: "private_sync",
useCustomRequestHandler: true,
vaultCredential: "credential with spaces",
vaultId: "vault-id-00000001",
}),
expectedRepositoryId: repositoryId,
journalFormat: "adaptive-v1" as const,
packReadPolicy: "whole-pack" as const,
};
const form = postgRESTJournalFormFromSettings(settings);
expect(form).toEqual({
apiKey: "publishable-key",
endpoint: "https://project.example/rest/v1",
expectedRepositoryId: repositoryId,
schema: "private_sync",
useCustomRequestHandler: true,
vaultCredential: "credential with spaces",
vaultId: "vault-id-00000001",
});
expect(postgRESTSyncSettingsFromForm(form)).toEqual(settings);
});
it("uses the exposed-schema default for a new profile", () => {
expect(
postgRESTJournalFormFromSettings({
postgrestActiveConnectionURI: "",
expectedRepositoryId: repositoryId,
journalFormat: "opaque-v1",
packReadPolicy: "range",
})
).toEqual({
apiKey: "",
endpoint: "",
expectedRepositoryId: "",
schema: "livesync_api",
useCustomRequestHandler: false,
vaultCredential: "",
vaultId: "",
});
});
it("rejects an invalid pinned repository identity", () => {
expect(() =>
postgRESTSyncSettingsFromForm({
apiKey: "publishable-key",
endpoint: "https://project.example/rest/v1",
expectedRepositoryId: "AA",
schema: "livesync_api",
useCustomRequestHandler: false,
vaultCredential: "vault-credential",
vaultId: "vault-id-00000001",
})
).toThrow("expectedRepositoryId must be a canonical base64url-encoded 32-byte value");
});
});
@@ -4,6 +4,8 @@ import type {
EncryptionSettings,
ObsidianLiveSyncSettings,
P2PConnectionInfo,
PostgRESTSyncSetting,
WebDAVSyncSetting,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { RemoteSetupChoice } from "@/modules/features/SetupWizard/RemoteSetupRegistry";
@@ -104,6 +106,20 @@ export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettin
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
export type SetupRemoteWebDAVResultType = typeof TYPE_CANCELLED | WebDAVSyncSetting;
export type WebDAVSetupMode = "onboarding" | "settings";
export type SetupRemoteWebDAVInitialData = {
settings: WebDAVSyncSetting;
mode: WebDAVSetupMode;
};
export type SetupRemotePostgRESTResultType = typeof TYPE_CANCELLED | PostgRESTSyncSetting;
export type PostgRESTSetupMode = "onboarding" | "settings";
export type SetupRemotePostgRESTInitialData = {
settings: PostgRESTSyncSetting;
mode: PostgRESTSetupMode;
};
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection;
export type CouchDBSetupMode = "create-or-connect" | "connect-existing" | "settings";
export type SetupRemoteCouchDBInitialData = {
@@ -0,0 +1,107 @@
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REMOTE_WEBDAV,
journalProtocolConfigurationForSettings,
parseWebDAVConnectionURI,
serialiseWebDAVConnectionURI,
type AdaptiveJournalPackReadPolicyV1,
type JournalFormatV1,
type JournalStorageAdaptiveCapabilityInspection,
type JournalStorageCapabilityInspection,
type WebDAVConnection,
type WebDAVSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
export type WebDAVJournalForm = WebDAVConnection & {
expectedRepositoryId: string;
journalFormat: JournalFormatV1;
packReadPolicy: AdaptiveJournalPackReadPolicyV1;
};
export type WebDAVCapabilitySummary =
| { kind: "verified" }
| { kind: "not-checked" }
| { kind: "unsupported"; missing: string[] }
| {
kind: "failed";
category: "authentication" | "invalid-response" | "permission" | "rate-limited" | "unavailable" | "unknown";
retry: "later" | "never" | "verify-first";
};
export type WebDAVAdaptiveCapabilitySummary = {
byteRange: WebDAVCapabilitySummary;
required: WebDAVCapabilitySummary;
};
const emptyWebDAVConnection: WebDAVConnection = {
customHeaders: "",
endpoint: "",
password: "",
prefix: "",
useCustomRequestHandler: false,
username: "",
};
function resolveProtocol(settings: WebDAVSyncSetting) {
return journalProtocolConfigurationForSettings({
...DEFAULT_SETTINGS,
remoteType: REMOTE_WEBDAV,
...settings,
});
}
export function webDAVJournalFormFromSettings(settings: WebDAVSyncSetting): WebDAVJournalForm {
const connection = settings.webDAVactiveConnectionURI.trim()
? parseWebDAVConnectionURI(settings.webDAVactiveConnectionURI.trim())
: emptyWebDAVConnection;
const protocol = resolveProtocol(settings);
return {
...connection,
...protocol,
};
}
export function webDAVSyncSettingsFromForm(form: WebDAVJournalForm): WebDAVSyncSetting {
const journalFormat = form.journalFormat;
const settings: WebDAVSyncSetting = {
webDAVactiveConnectionURI: serialiseWebDAVConnectionURI({
customHeaders: form.customHeaders.trim(),
endpoint: form.endpoint.trim(),
password: form.password,
prefix: form.prefix.trim(),
useCustomRequestHandler: form.useCustomRequestHandler,
username: form.username.trim(),
}),
expectedRepositoryId: journalFormat === "adaptive-v1" ? form.expectedRepositoryId.trim() : "",
journalFormat,
packReadPolicy: journalFormat === "adaptive-v1" ? form.packReadPolicy : "whole-pack",
};
resolveProtocol(settings);
return settings;
}
function summariseCapabilityInspection(inspection: JournalStorageCapabilityInspection): WebDAVCapabilitySummary {
switch (inspection.status) {
case "verified":
return { kind: "verified" };
case "not-checked":
return { kind: "not-checked" };
case "unsupported":
return { kind: "unsupported", missing: [...inspection.missing] };
case "failed":
return {
category: inspection.failure.category,
kind: "failed",
retry: inspection.failure.retry,
};
}
}
export function summariseAdaptiveCapabilityInspection(
inspection: JournalStorageAdaptiveCapabilityInspection
): WebDAVAdaptiveCapabilitySummary {
return {
byteRange: summariseCapabilityInspection(inspection.byteRange),
required: summariseCapabilityInspection(inspection.required),
};
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { serialiseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
import {
summariseAdaptiveCapabilityInspection,
webDAVJournalFormFromSettings,
webDAVSyncSettingsFromForm,
} from "./webDAVJournalSettings.ts";
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
describe("WebDAV Journal settings", () => {
it("round-trips connection fields separately from Adaptive protocol fields", () => {
const settings = {
webDAVactiveConnectionURI: serialiseWebDAVConnectionURI({
customHeaders: "X-Vault: notes",
endpoint: "https://dav.example/remote.php/dav/files/alice",
password: "p@ss word",
prefix: "vault/notes/",
useCustomRequestHandler: true,
username: "alice@example.com",
}),
expectedRepositoryId: repositoryId,
journalFormat: "adaptive-v1" as const,
packReadPolicy: "range" as const,
};
const form = webDAVJournalFormFromSettings(settings);
expect(form).toEqual({
customHeaders: "X-Vault: notes",
endpoint: "https://dav.example/remote.php/dav/files/alice",
expectedRepositoryId: repositoryId,
journalFormat: "adaptive-v1",
packReadPolicy: "range",
password: "p@ss word",
prefix: "vault/notes/",
useCustomRequestHandler: true,
username: "alice@example.com",
});
expect(webDAVSyncSettingsFromForm(form)).toEqual(settings);
});
it("normalises editable text and removes Adaptive-only options from Opaque settings", () => {
const settings = webDAVSyncSettingsFromForm({
customHeaders: " X-Vault: notes ",
endpoint: " http://localhost:8080/dav ",
expectedRepositoryId: ` ${repositoryId} `,
journalFormat: "opaque-v1",
packReadPolicy: "range",
password: " password with spaces ",
prefix: " vault/notes/ ",
useCustomRequestHandler: false,
username: " alice ",
});
expect(settings).toEqual({
webDAVactiveConnectionURI:
"sls+webdav://alice:%20password%20with%20spaces%20@localhost:8080/dav?insecure=true&prefix=vault%2Fnotes%2F&headers=X-Vault%3A+notes",
expectedRepositoryId: "",
journalFormat: "opaque-v1",
packReadPolicy: "whole-pack",
});
});
it("rejects an invalid pinned repository identity", () => {
expect(() =>
webDAVSyncSettingsFromForm({
customHeaders: "",
endpoint: "https://dav.example/vault",
expectedRepositoryId: "AA",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
password: "secret",
prefix: "",
useCustomRequestHandler: false,
username: "alice",
})
).toThrow("expectedRepositoryId must be a canonical base64url-encoded 32-byte value");
});
it("presents required capabilities and optional byte-range support independently", () => {
expect(
summariseAdaptiveCapabilityInspection({
required: { status: "verified" },
byteRange: { missing: ["byte-range"], status: "unsupported" },
})
).toEqual({
required: { kind: "verified" },
byteRange: { kind: "unsupported", missing: ["byte-range"] },
});
expect(
summariseAdaptiveCapabilityInspection({
required: {
failure: { category: "authentication", retry: "never" },
status: "failed",
},
byteRange: { status: "not-checked" },
})
).toEqual({
required: { category: "authentication", kind: "failed", retry: "never" },
byteRange: { kind: "not-checked" },
});
});
});
+330
View File
@@ -0,0 +1,330 @@
import type { Locator, Page } from "playwright";
import { captureObsidianDialogue, withObsidianPage } from "./ui.ts";
const remoteSetupStateKey = "__livesyncE2ERemoteSetup";
export type RemoteInspectionMode = "failed" | "verified";
export type RemoteSetupCall = {
journalFormat: string;
operation: "create" | "inspect" | "test";
remoteType: string;
};
type RemoteSetupBrowserState = {
calls: RemoteSetupCall[];
inspectionMode: RemoteInspectionMode;
nextProfileName: string;
unregister?: () => void;
};
type RuntimeRemoteConfiguration = {
id: string;
isEncrypted: boolean;
name: string;
uri: string;
};
type RuntimeSettings = {
activeConfigurationId: string;
remoteConfigurations: Record<string, RuntimeRemoteConfiguration>;
};
type RuntimePlugin = {
core: {
services: {
replicator: {
getNewReplicator: {
addHandler: (...args: unknown[]) => () => void;
} & ((settingOverride?: Record<string, unknown>) => Promise<unknown>);
};
setting: {
currentSettings(): RuntimeSettings;
};
UI: {
confirm: {
askString(
title: string,
label: string,
placeholder: string,
isPassword?: boolean
): Promise<string | false>;
};
};
};
};
};
type RuntimeSettingsController = {
close(): void;
open(): void;
openTabById(tabId: string): void;
};
type RuntimeApp = {
plugins?: { plugins: Record<string, RuntimePlugin | undefined> };
setting?: RuntimeSettingsController;
};
type RuntimeGlobal = typeof globalThis & {
app?: RuntimeApp;
[remoteSetupStateKey]?: RemoteSetupBrowserState;
};
export function remoteSelectionModal(page: Page): Locator {
return page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
});
}
export function remoteProviderModal(page: Page, title: string): Locator {
return page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: title }),
});
}
export function remoteConfigurationPanel(page: Page): Locator {
return page
.locator(".sls-setting h4.sls-setting-panel-title")
.filter({ hasText: "Connection settings" })
.locator("..");
}
function remoteProfileRow(page: Page, profileName: string): Locator {
return remoteConfigurationPanel(page).locator(".sls-remote-list .setting-item").filter({ hasText: profileName });
}
async function tooltipButton(container: Locator, label: string, fallbackText: string): Promise<Locator> {
const labelled = container.locator(`button[aria-label="${label}"], button[title="${label}"]`);
if ((await labelled.count()) > 0) return labelled.first();
return container.locator("button").filter({ hasText: fallbackText }).first();
}
export async function installRemoteSetupTestSeam(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const runtime = globalThis as RuntimeGlobal;
const plugin = runtime.app?.plugins?.plugins["obsidian-livesync"];
if (!plugin) throw new Error("Self-hosted LiveSync is not loaded");
const state: RemoteSetupBrowserState = {
calls: [],
inspectionMode: "verified",
nextProfileName: "",
};
runtime[stateKey as typeof remoteSetupStateKey] = state;
const getNewReplicator = plugin.core.services.replicator.getNewReplicator;
const replacements = {
async createReplicator(settingOverride: Record<string, unknown> = {}) {
const remoteType = String(settingOverride.remoteType ?? "");
const journalFormat = String(settingOverride.journalFormat ?? "");
state.calls.push({ journalFormat, operation: "create", remoteType });
return {
async inspectJournalStorageConnection(settings: Record<string, unknown>) {
state.calls.push({
journalFormat: String(settings.journalFormat ?? ""),
operation: "inspect",
remoteType: String(settings.remoteType ?? ""),
});
if (state.inspectionMode === "failed") {
return {
adaptiveCapabilities: {
byteRange: { status: "not-checked" },
required: { missing: ["conditional-create"], status: "unsupported" },
},
available: false,
remoteFormat: "empty",
};
}
return {
adaptiveCapabilities: {
byteRange: { status: "verified" },
required: { status: "verified" },
},
available: true,
remoteFormat: "empty",
};
},
async tryConnectRemote(settings: Record<string, unknown>) {
state.calls.push({
journalFormat: String(settings.journalFormat ?? ""),
operation: "test",
remoteType: String(settings.remoteType ?? ""),
});
return state.inspectionMode === "verified";
},
};
},
async askString(title: string, label: string, placeholder: string, isPassword: boolean = false) {
if (title !== "Remote name") return await originalAskString(title, label, placeholder, isPassword);
const name = state.nextProfileName;
state.nextProfileName = "";
if (!name) throw new Error("The remote setup E2E did not supply a profile name");
return name;
},
};
state.unregister = getNewReplicator.addHandler(replacements.createReplicator, -1000, true);
const confirm = plugin.core.services.UI.confirm;
const originalAskString = confirm.askString.bind(confirm);
confirm.askString = replacements.askString;
}, remoteSetupStateKey);
});
}
export async function setRemoteInspectionMode(port: number, mode: RemoteInspectionMode): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(
({ mode, stateKey }) => {
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
if (!state) throw new Error("The remote setup E2E seam is not installed");
state.inspectionMode = mode;
},
{ mode, stateKey: remoteSetupStateKey }
);
});
}
export async function remoteSetupCalls(port: number): Promise<RemoteSetupCall[]> {
return await withObsidianPage(port, async (page) => {
return await page.evaluate((stateKey) => {
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
if (!state) throw new Error("The remote setup E2E seam is not installed");
return state.calls;
}, remoteSetupStateKey);
});
}
export async function openRemoteConfigurationSettings(port: number, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const setting = (globalThis as RuntimeGlobal).app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.close();
});
await page.waitForTimeout(100);
await page.evaluate(() => {
const setting = (globalThis as RuntimeGlobal).app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const settings = page.locator(".sls-setting");
try {
await settings.waitFor({ state: "visible", timeout: timeoutMs });
} catch (error) {
const modalTitles = await page.locator(".modal-title").allTextContents();
const settingTabs = await page.locator(".vertical-tab-nav-item").allTextContents();
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`The LiveSync settings pane did not become visible. Open modal titles: ${JSON.stringify(modalTitles)}. Settings tabs: ${JSON.stringify(settingTabs)}. Cause: ${reason}`
);
}
await settings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click({ timeout: timeoutMs });
await remoteConfigurationPanel(page).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function closeRemoteConfigurationSettings(port: number, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const setting = (globalThis as RuntimeGlobal).app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.close();
});
await page.locator(".sls-setting").waitFor({ state: "hidden", timeout: timeoutMs });
});
}
export async function beginRemoteProfileSetup(port: number, profileName: string, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(
({ profileName, stateKey }) => {
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
if (!state) throw new Error("The remote setup E2E seam is not installed");
state.nextProfileName = profileName;
},
{ profileName, stateKey: remoteSetupStateKey }
);
const add = await tooltipButton(remoteConfigurationPanel(page), "Add new connection", "");
await add.click({ timeout: timeoutMs });
await remoteSelectionModal(page).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function captureRemoteProviderChoices(
port: number,
filename: string,
labels: readonly string[],
timeoutMs: number
): Promise<string> {
return await captureObsidianDialogue(port, filename, async (page) => {
const modal = remoteSelectionModal(page);
await modal.waitFor({ state: "visible", timeout: timeoutMs });
for (const label of labels) {
await modal.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: timeoutMs });
}
});
}
export async function selectRemoteProvider(
port: number,
choiceLabel: string,
proceedLabel: string,
providerTitle: string,
timeoutMs: number
): Promise<void> {
await withObsidianPage(port, async (page) => {
const selection = remoteSelectionModal(page);
await selection
.locator("label")
.filter({ hasText: choiceLabel })
.locator('input[type="radio"]')
.first()
.check({ timeout: timeoutMs });
await selection.getByRole("button", { name: proceedLabel, exact: true }).click({ timeout: timeoutMs });
await remoteProviderModal(page, providerTitle).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function captureAndCancelRemoteProvider(
port: number,
filename: string,
providerTitle: string,
timeoutMs: number
): Promise<string> {
const screenshot = await captureObsidianDialogue(port, filename, async (page) => {
await remoteProviderModal(page, providerTitle).waitFor({ state: "visible", timeout: timeoutMs });
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, providerTitle);
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: timeoutMs });
await modal.waitFor({ state: "hidden", timeout: timeoutMs });
});
return screenshot;
}
export async function waitForSavedRemoteProfile(port: number, profileName: string, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await remoteProfileRow(page, profileName).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function openSavedRemoteProfile(port: number, profileName: string, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const row = remoteProfileRow(page, profileName);
await row.waitFor({ state: "visible", timeout: timeoutMs });
const configure = await tooltipButton(row, "Configure", "🔧");
await configure.click({ timeout: timeoutMs });
});
}
export async function runtimeRemoteSettings(port: number): Promise<RuntimeSettings> {
return await withObsidianPage(port, async (page) => {
return await page.evaluate(() => {
const plugin = (globalThis as RuntimeGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (!plugin) throw new Error("Self-hosted LiveSync is not loaded");
return structuredClone(plugin.core.services.setting.currentSettings());
});
});
}
+1
View File
@@ -17,6 +17,7 @@ const testSteps: Step[] = [
{ name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] },
{ name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] },
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
{ name: "remote setup providers", args: ["run", "test:e2e:obsidian:remote-setup-providers"] },
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
@@ -0,0 +1,524 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import {
defaultRemoteProviderRegistry,
type BuiltInRemoteConfiguration,
type RemoteConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { parsePostgRESTConnectionURI, parseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
import { enableAndReloadPlugin } from "@vrtmrz/obsidian-test-session";
import type { Locator, Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import {
beginRemoteProfileSetup,
captureAndCancelRemoteProvider,
captureRemoteProviderChoices,
closeRemoteConfigurationSettings,
installRemoteSetupTestSeam,
openRemoteConfigurationSettings,
openSavedRemoteProfile,
remoteProviderModal,
remoteSetupCalls,
runtimeRemoteSettings,
selectRemoteProvider,
setRemoteInspectionMode,
waitForSavedRemoteProfile,
} from "../runner/remoteSetupUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
captureObsidianDialogue,
captureObsidianElement,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_SETUP_TIMEOUT_MS ?? 10000);
const repositoryA = "A".repeat(43);
const repositoryB = `${"A".repeat(42)}Q`;
const profiles = {
postgrest: {
apiKey: "publishable-ui-key",
credential: "remote-setup-ui-credential",
endpoint: "https://postgrest.example.test/rest/v1",
expectedRepositoryId: repositoryB,
name: "PostgREST UI profile",
schema: "livesync_api",
vaultId: "remote-setup-vault-01",
},
s3: {
accessKey: "remote-setup-access-key",
bucket: "remote-setup-bucket",
endpoint: "https://s3.example.test",
name: "S3 UI profile",
prefix: "adaptive-ui/",
region: "us-east-1",
secretKey: "remote-setup-secret-key",
},
webdav: {
endpoint: "https://dav.example.test/remote.php/dav/files/tester",
expectedRepositoryId: repositoryA,
name: "WebDAV UI profile",
password: "remote-setup-password",
prefix: "adaptive-ui/",
username: "remote-setup-user",
},
} as const;
const providerChoices = [
"CouchDB",
"S3-compatible Object Storage",
"WebDAV Journal",
"PostgREST Journal",
"Peer-to-Peer (P2P)",
] as const;
type PersistedSettings = {
activeConfigurationId: string;
remoteConfigurations: Record<string, RemoteConfiguration>;
};
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function profileByName(settings: PersistedSettings, name: string): RemoteConfiguration {
const profile = Object.values(settings.remoteConfigurations).find((candidate) => candidate.name === name);
if (!profile) throw new Error(`Saved remote profile '${name}' was not found`);
return profile;
}
function parseProfile(settings: PersistedSettings, name: string): BuiltInRemoteConfiguration {
return defaultRemoteProviderRegistry.parse(profileByName(settings, name).uri);
}
function assertPersistedProfiles(settings: PersistedSettings): void {
const s3 = parseProfile(settings, profiles.s3.name);
assertEqual(s3.type, "s3", "The S3 dialogue returned the wrong provider type.");
if (s3.type !== "s3") return;
assertEqual(s3.settings.endpoint, profiles.s3.endpoint, "The S3 endpoint was not saved.");
assertEqual(s3.settings.bucket, profiles.s3.bucket, "The S3 bucket was not saved.");
assertEqual(s3.settings.bucketPrefix, profiles.s3.prefix, "The S3 prefix was not saved.");
assertEqual(s3.settings.journalFormat, "adaptive-v1", "The S3 Adaptive format was not saved.");
assertEqual(s3.settings.packReadPolicy, "range", "The S3 Range policy was not saved.");
const webdav = parseProfile(settings, profiles.webdav.name);
assertEqual(webdav.type, "webdav", "The WebDAV dialogue returned the wrong provider type.");
if (webdav.type !== "webdav") return;
const webdavConnection = parseWebDAVConnectionURI(webdav.settings.webDAVactiveConnectionURI);
assertEqual(webdavConnection.endpoint, profiles.webdav.endpoint, "The WebDAV endpoint was not saved.");
assertEqual(webdavConnection.prefix, profiles.webdav.prefix, "The WebDAV prefix was not saved.");
assertEqual(webdavConnection.username, profiles.webdav.username, "The WebDAV username was not saved.");
assertEqual(webdav.settings.expectedRepositoryId, repositoryA, "The WebDAV repository ID was not saved.");
assertEqual(webdav.settings.journalFormat, "adaptive-v1", "The WebDAV Adaptive format was not saved.");
assertEqual(webdav.settings.packReadPolicy, "range", "The WebDAV Range policy was not saved.");
const postgrest = parseProfile(settings, profiles.postgrest.name);
assertEqual(postgrest.type, "postgrest", "The PostgREST dialogue returned the wrong provider type.");
if (postgrest.type !== "postgrest") return;
const postgrestConnection = parsePostgRESTConnectionURI(postgrest.settings.postgrestActiveConnectionURI);
assertEqual(postgrestConnection.endpoint, profiles.postgrest.endpoint, "The PostgREST endpoint was not saved.");
assertEqual(postgrestConnection.schema, profiles.postgrest.schema, "The PostgREST schema was not saved.");
assertEqual(postgrestConnection.vaultId, profiles.postgrest.vaultId, "The PostgREST Vault ID was not saved.");
assertEqual(postgrest.settings.expectedRepositoryId, repositoryB, "The PostgREST repository ID was not saved.");
assertEqual(postgrest.settings.journalFormat, "adaptive-v1", "PostgREST did not retain its fixed format.");
assertEqual(postgrest.settings.packReadPolicy, "whole-pack", "PostgREST did not retain its fixed read policy.");
}
function assertEncryptedProfilesAtRest(settings: PersistedSettings): void {
for (const fixture of [profiles.s3, profiles.webdav, profiles.postgrest]) {
const profile = profileByName(settings, fixture.name);
assertEqual(profile.isEncrypted, true, `Saved remote profile '${fixture.name}' was not encrypted at rest.`);
for (const exposed of [
profiles.s3.endpoint,
profiles.s3.secretKey,
profiles.webdav.endpoint,
profiles.webdav.password,
profiles.postgrest.endpoint,
profiles.postgrest.credential,
]) {
if (profile.uri.includes(exposed)) {
throw new Error(`Saved remote profile '${fixture.name}' exposed a connection secret or endpoint.`);
}
}
}
}
async function fill(locator: Locator, value: string): Promise<void> {
await locator.fill(value, { timeout: uiTimeoutMs });
}
async function openAdvanced(modal: Locator): Promise<void> {
const details = modal.locator("details").filter({ hasText: "Advanced Settings" }).first();
await details.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (!(await details.getAttribute("open"))) {
await details.locator("summary").click({ timeout: uiTimeoutMs });
}
}
async function expectInputValue(modal: Locator, name: string, expected: string): Promise<void> {
const actual = await modal.locator(`[name="${name}"]`).inputValue({ timeout: uiTimeoutMs });
assertEqual(actual, expected, `Reloaded control '${name}' has the wrong value.`);
}
async function mountLegacyProvider(
port: number,
profileName: string,
choice: string,
proceed: string,
title: string,
screenshotName: string
): Promise<string> {
await beginRemoteProfileSetup(port, profileName, uiTimeoutMs);
await selectRemoteProvider(port, choice, proceed, title, uiTimeoutMs);
return await captureAndCancelRemoteProvider(port, screenshotName, title, uiTimeoutMs);
}
async function addS3Profile(port: number): Promise<string> {
await beginRemoteProfileSetup(port, profiles.s3.name, uiTimeoutMs);
await selectRemoteProvider(
port,
"S3-compatible Object Storage",
"Continue to Object Storage setup",
"S3/MinIO/R2 Configuration",
uiTimeoutMs
);
const screenshot = await captureObsidianDialogue(port, "remote-setup-s3-adaptive.png", async (page) => {
const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration");
const testButton = modal.getByRole("button", { name: "Test Settings and Continue", exact: true });
if (!(await testButton.isDisabled()))
throw new Error("Incomplete S3 settings did not disable connection testing.");
await fill(modal.locator('[name="s3-endpoint"]'), profiles.s3.endpoint);
await fill(modal.locator('[name="s3-access-key-id"]'), profiles.s3.accessKey);
await fill(modal.locator('[name="s3-secret-access-key"]'), profiles.s3.secretKey);
await fill(modal.locator('[name="s3-bucket-name"]'), profiles.s3.bucket);
await fill(modal.locator('[name="s3-region"]'), profiles.s3.region);
await fill(modal.locator('[name="s3-folder-prefix"]'), profiles.s3.prefix);
await openAdvanced(modal);
await modal.locator('[name="s3-journal-format"]').selectOption("adaptive-v1");
await modal.locator('[name="s3-pack-read-policy"]').selectOption("range");
if (await testButton.isDisabled()) throw new Error("Complete S3 settings did not enable connection testing.");
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration");
await modal.getByRole("button", { name: "Test Settings and Continue", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForSavedRemoteProfile(port, profiles.s3.name, uiTimeoutMs);
return screenshot;
}
async function addWebDAVProfile(port: number): Promise<string> {
await beginRemoteProfileSetup(port, profiles.webdav.name, uiTimeoutMs);
await selectRemoteProvider(
port,
"WebDAV Journal",
"Continue to WebDAV setup",
"WebDAV Journal Configuration",
uiTimeoutMs
);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await fill(modal.locator('[name="webdav-endpoint"]'), profiles.webdav.endpoint);
await fill(modal.locator('[name="webdav-username"]'), profiles.webdav.username);
await fill(modal.locator('[name="webdav-password"]'), profiles.webdav.password);
await fill(modal.locator('[name="webdav-prefix"]'), profiles.webdav.prefix);
await modal.locator('[name="webdav-use-internal-api"]').check({ timeout: uiTimeoutMs });
await openAdvanced(modal);
await modal.locator('[name="webdav-journal-format"]').selectOption("adaptive-v1");
await fill(modal.locator('[name="webdav-expected-repository-id"]'), repositoryA);
await modal.locator('[name="webdav-pack-read-policy"]').selectOption("range");
});
await setRemoteInspectionMode(port, "failed");
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByText("missing required Adaptive operations", { exact: false }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) {
throw new Error("A failed WebDAV safety check exposed the verified-save action.");
}
});
await setRemoteInspectionMode(port, "verified");
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await fill(modal.locator('[name="webdav-prefix"]'), "stale-inspection/");
if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) {
throw new Error("Editing WebDAV settings did not invalidate the previous safety check.");
}
await fill(modal.locator('[name="webdav-prefix"]'), profiles.webdav.prefix);
await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
const screenshot = await captureObsidianDialogue(port, "remote-setup-webdav-verified.png", async (page) => {
await remoteProviderModal(page, "WebDAV Journal Configuration")
.getByText("Required Adaptive operations are supported", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await modal.getByRole("button", { name: "Save verified settings", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForSavedRemoteProfile(port, profiles.webdav.name, uiTimeoutMs);
return screenshot;
}
async function addPostgRESTProfile(port: number): Promise<string> {
await beginRemoteProfileSetup(port, profiles.postgrest.name, uiTimeoutMs);
await selectRemoteProvider(
port,
"PostgREST Journal",
"Continue to PostgREST setup",
"PostgREST Journal Configuration",
uiTimeoutMs
);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "PostgREST Journal Configuration");
const check = modal.getByRole("button", { name: "Check PostgREST server", exact: true });
if (!(await check.isDisabled())) {
throw new Error("Incomplete PostgREST settings did not disable the server check.");
}
await fill(modal.locator('[name="postgrest-endpoint"]'), profiles.postgrest.endpoint);
await fill(modal.locator('[name="postgrest-vault-id"]'), profiles.postgrest.vaultId);
await fill(modal.locator('[name="postgrest-vault-credential"]'), profiles.postgrest.credential);
await fill(modal.locator('[name="postgrest-schema"]'), profiles.postgrest.schema);
await fill(modal.locator('[name="postgrest-api-key"]'), profiles.postgrest.apiKey);
await modal.locator('[name="postgrest-use-internal-api"]').check({ timeout: uiTimeoutMs });
await openAdvanced(modal);
await fill(modal.locator('[name="postgrest-expected-repository-id"]'), repositoryB);
if (await check.isDisabled()) throw new Error("Complete PostgREST settings did not enable the server check.");
await check.click({ timeout: uiTimeoutMs });
try {
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`PostgREST verification did not enable saving. Dialogue text:\n${await modal.innerText()}\nCause: ${reason}`
);
}
await fill(modal.locator('[name="postgrest-api-key"]'), "stale-publishable-key");
if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) {
throw new Error("Editing PostgREST settings did not invalidate the previous server check.");
}
await fill(modal.locator('[name="postgrest-api-key"]'), profiles.postgrest.apiKey);
await modal.getByRole("button", { name: "Check PostgREST server", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
const screenshot = await captureObsidianDialogue(port, "remote-setup-postgrest-verified.png", async (page) => {
await remoteProviderModal(page, "PostgREST Journal Configuration")
.getByText("required PostgREST RPC operations", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "PostgREST Journal Configuration");
await modal.getByRole("button", { name: "Save verified settings", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForSavedRemoteProfile(port, profiles.postgrest.name, uiTimeoutMs);
return screenshot;
}
async function assertReloadedS3(port: number): Promise<void> {
await openSavedRemoteProfile(port, profiles.s3.name, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration");
await expectInputValue(modal, "s3-endpoint", profiles.s3.endpoint);
await expectInputValue(modal, "s3-bucket-name", profiles.s3.bucket);
await openAdvanced(modal);
await expectInputValue(modal, "s3-journal-format", "adaptive-v1");
await expectInputValue(modal, "s3-pack-read-policy", "range");
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function assertReloadedWebDAV(port: number): Promise<void> {
await openSavedRemoteProfile(port, profiles.webdav.name, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await expectInputValue(modal, "webdav-endpoint", profiles.webdav.endpoint);
await expectInputValue(modal, "webdav-prefix", profiles.webdav.prefix);
await openAdvanced(modal);
await expectInputValue(modal, "webdav-journal-format", "adaptive-v1");
await expectInputValue(modal, "webdav-expected-repository-id", repositoryA);
await expectInputValue(modal, "webdav-pack-read-policy", "range");
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function assertReloadedPostgREST(port: number): Promise<void> {
await openSavedRemoteProfile(port, profiles.postgrest.name, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "PostgREST Journal Configuration");
await expectInputValue(modal, "postgrest-endpoint", profiles.postgrest.endpoint);
await expectInputValue(modal, "postgrest-vault-id", profiles.postgrest.vaultId);
await expectInputValue(modal, "postgrest-schema", profiles.postgrest.schema);
await openAdvanced(modal);
await expectInputValue(modal, "postgrest-expected-repository-id", repositoryB);
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function readPersistedSettings(pluginDir: string): Promise<PersistedSettings> {
return JSON.parse(await readFile(join(pluginDir, "data.json"), "utf8")) as PersistedSettings;
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const vault = await createTemporaryVault("obsidian-livesync-remote-setup-e2e-");
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
pluginData: createE2eCouchDbPluginData(
{
dbName: "remote-setup-ui-only",
password: "",
uri: "http://127.0.0.1:5984",
username: "",
},
{
notifyThresholdOfRemoteStorageSize: 0,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
useAdvancedMode: true,
}
),
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
vault,
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const port = obsidianRemoteDebuggingPort();
await installRemoteSetupTestSeam(port);
await openRemoteConfigurationSettings(port, uiTimeoutMs);
await beginRemoteProfileSetup(port, "Cancelled CouchDB profile", uiTimeoutMs);
const selectionScreenshot = await captureRemoteProviderChoices(
port,
"remote-setup-provider-selection.png",
providerChoices,
uiTimeoutMs
);
await selectRemoteProvider(port, "CouchDB", "Continue to CouchDB setup", "CouchDB Configuration", uiTimeoutMs);
const couchdbScreenshot = await captureAndCancelRemoteProvider(
port,
"remote-setup-couchdb.png",
"CouchDB Configuration",
uiTimeoutMs
);
const p2pScreenshot = await mountLegacyProvider(
port,
"Cancelled P2P profile",
"Peer-to-Peer (P2P)",
"Continue to P2P setup",
"P2P Configuration",
"remote-setup-p2p.png"
);
const s3Screenshot = await addS3Profile(port);
const webdavScreenshot = await addWebDAVProfile(port);
const postgrestScreenshot = await addPostgRESTProfile(port);
const firstRuntimeSettings = (await runtimeRemoteSettings(port)) as PersistedSettings;
assertPersistedProfiles(firstRuntimeSettings);
const calls = await remoteSetupCalls(port);
assertEqual(
calls.filter((call) => call.operation === "test").length,
1,
"The S3 profile did not use the injected connection-test boundary exactly once."
);
if (calls.filter((call) => call.operation === "inspect").length < 5) {
throw new Error("The WebDAV and PostgREST profiles did not exercise failure, stale, and verified checks.");
}
const pluginDir = session.install.pluginDir;
assertEncryptedProfilesAtRest(await readPersistedSettings(pluginDir));
await closeRemoteConfigurationSettings(port, uiTimeoutMs);
await enableAndReloadPlugin(port, "obsidian-livesync");
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await openRemoteConfigurationSettings(port, uiTimeoutMs);
await assertReloadedS3(port);
await assertReloadedWebDAV(port);
await assertReloadedPostgREST(port);
const reloadedSettings = (await runtimeRemoteSettings(port)) as PersistedSettings;
assertPersistedProfiles(reloadedSettings);
assertEqual(
Object.keys(reloadedSettings.remoteConfigurations).length,
Object.keys(firstRuntimeSettings.remoteConfigurations).length,
"Plug-in reload changed the saved remote profile count."
);
const listScreenshot = await captureObsidianElement(port, "remote-setup-reloaded-profiles.png", (page: Page) =>
page.locator(".sls-setting .sls-remote-list")
);
console.log(
`All registered providers mounted through the real Settings flow; S3, WebDAV, and PostgREST passed validation, injected checks, persistence, and plug-in reload. Screenshots: ${[
selectionScreenshot,
couchdbScreenshot,
p2pScreenshot,
s3Screenshot,
webdavScreenshot,
postgrestScreenshot,
listScreenshot,
].join(", ")}`
);
} finally {
if (session) await session.app.stop();
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+1
View File
@@ -11,6 +11,7 @@ const focusedScenarios = new Set([
"revision-repair",
"document-history-nav",
"settings-ui",
"remote-setup-providers",
"review-harness",
"p2p-pane",
"vault-reflection",
+2
View File
@@ -17,6 +17,8 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
#### Improved
- Object Storage setup can select the experimental Adaptive Journal format and choose complete Pack or verified Range retrieval. Existing Opaque Journal repositories remain the default and require an explicit remote Rebuild before changing formats.
- Saved connections and Setup now support experimental WebDAV Journal profiles. Adaptive WebDAV runs an endpoint safety check, reports required immutable-object behaviour separately from optional Range support, and permits an unverified save only from Settings.
- Saved connections and Setup now support experimental, Adaptive-only PostgREST Journal profiles. Setup verifies the packaged RPC contract and binary semantics, the adapter rejects recognised privileged Supabase keys before a request, and Opaque Journal, Pack, and Range options remain outside this provider.
### P2P and experimental browser applications