mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 04:52:58 +00:00
Generate current Setup URIs through Commonlib
This commit is contained in:
@@ -1,52 +1,6 @@
|
||||
import { encodeSettingsToSetupURI } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/API/processSetting";
|
||||
import { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/remote-configurations";
|
||||
import {
|
||||
createNewVaultSettings,
|
||||
PREFERRED_SETTING_SELF_HOSTED,
|
||||
} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings";
|
||||
import { runSetupURIGenerator } from "../setup/generate_setup_uri.ts";
|
||||
|
||||
function requireEnvironment(name: string): string {
|
||||
const value = Deno.env.get(name)?.trim();
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function generateSetupPassphrase(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(24));
|
||||
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll(
|
||||
"/",
|
||||
"_",
|
||||
).replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const setupPassphrase = Deno.env.get("uri_passphrase")?.trim() ||
|
||||
generateSetupPassphrase();
|
||||
const settings = createNewVaultSettings();
|
||||
Object.assign(settings, PREFERRED_SETTING_SELF_HOSTED, {
|
||||
couchDB_URI: requireEnvironment("hostname"),
|
||||
couchDB_USER: requireEnvironment("username"),
|
||||
couchDB_PASSWORD: requireEnvironment("password"),
|
||||
couchDB_DBNAME: requireEnvironment("database"),
|
||||
batchSave: true,
|
||||
periodicReplication: true,
|
||||
syncOnStart: true,
|
||||
syncOnFileOpen: true,
|
||||
syncAfterMerge: true,
|
||||
encrypt: true,
|
||||
passphrase: requireEnvironment("passphrase"),
|
||||
usePathObfuscation: true,
|
||||
});
|
||||
upsertRemoteConfigurationInPlace(settings, "couchdb", { activate: true });
|
||||
|
||||
const setupURI = await encodeSettingsToSetupURI(settings, setupPassphrase, [
|
||||
"pluginSyncExtendedSetting",
|
||||
"doNotUseFixedRevisionForChunks",
|
||||
], true);
|
||||
|
||||
console.log("\nYour passphrase for the Setup URI is:", setupPassphrase);
|
||||
console.log("This passphrase is never shown again, so store it safely.");
|
||||
console.log(setupURI.trim());
|
||||
}
|
||||
|
||||
await main();
|
||||
await runSetupURIGenerator({
|
||||
...Deno.env.toObject(),
|
||||
remote_type: "couchdb",
|
||||
});
|
||||
|
||||
+32
-2
@@ -27,7 +27,11 @@ Authentication and other non-retryable HTTP failures stop immediately. Network a
|
||||
|
||||
## Setup URI generation
|
||||
|
||||
`flyio/generate_setupuri.ts` creates a current self-hosted configuration from Commonlib's new-Vault and self-hosted presets, stores the CouchDB connection as the selected remote profile, and encodes it with Commonlib's Setup URI contract.
|
||||
`setup/generate_setup_uri.ts` creates current CouchDB, Object Storage, and P2P configurations from Commonlib's new-Vault and remote-specific presets. It stores the connection as the selected remote profile and encodes the result with Commonlib's Setup URI contract. Set `remote_type` to `couchdb`, `s3`, or `p2p`; CouchDB is the default.
|
||||
|
||||
The existing `flyio/generate_setupuri.ts` path remains a CouchDB-only compatibility wrapper for the Fly.io deployment script.
|
||||
|
||||
### CouchDB
|
||||
|
||||
```sh
|
||||
export hostname=https://couch.example.com
|
||||
@@ -36,11 +40,37 @@ export password=couchdb-admin-password
|
||||
export database=obsidiannotes
|
||||
export passphrase=a-strong-vault-encryption-passphrase
|
||||
export uri_passphrase=a-separate-setup-uri-passphrase # Optional
|
||||
deno run --minimum-dependency-age=0 --allow-env ./flyio/generate_setupuri.ts
|
||||
deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
If `uri_passphrase` is omitted, the tool generates and prints a cryptographically random one. Store the Setup URI and its passphrase separately. The `passphrase` value protects synchronised Vault data and must also be stored safely.
|
||||
|
||||
### Object Storage
|
||||
|
||||
```sh
|
||||
export remote_type=s3
|
||||
export endpoint=https://objects.example.com
|
||||
export access_key=<ACCESS KEY>
|
||||
export secret_key=<SECRET KEY>
|
||||
export bucket=vault-data
|
||||
export region=auto
|
||||
export bucket_prefix=team-a # Optional
|
||||
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
Optional Object Storage variables are `use_custom_request_handler`, `force_path_style`, and `bucket_custom_headers`.
|
||||
|
||||
### P2P
|
||||
|
||||
```sh
|
||||
export remote_type=p2p
|
||||
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
If `p2p_room_id` or `p2p_passphrase` is omitted, the tool generates it with Commonlib's room-ID contract or cryptographically secure randomness. Optional variables are `p2p_relays`, `p2p_app_id`, `p2p_auto_start`, and `p2p_auto_broadcast`. Auto-start and auto-broadcast retain Commonlib's disabled defaults unless explicitly enabled. A peer name is deliberately absent because it identifies one device and must not be copied to another device through a Setup URI.
|
||||
|
||||
## Fly.io deployment
|
||||
|
||||
`flyio/deploy-server.sh` deploys CouchDB through `flyctl`, provisions the selected database through the tool above, and prints a Commonlib-generated Setup URI. Both `flyctl` and Deno 2 are required.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { decodeSettingsFromSetupURI } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/API/processSetting";
|
||||
import { DEFAULT_SETTINGS } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings";
|
||||
import { generateSetupURI } from "./generate_setup_uri.ts";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
Deno.test("generates an Object Storage Setup URI with a selected S3 profile", async () => {
|
||||
const generated = await generateSetupURI({
|
||||
remote_type: "s3",
|
||||
endpoint: "https://objects.example.test",
|
||||
access_key: "access-key",
|
||||
secret_key: "secret-key",
|
||||
bucket: "vault-data",
|
||||
region: "auto",
|
||||
bucket_prefix: "team-a",
|
||||
passphrase: "vault-secret",
|
||||
uri_passphrase: "setup-secret",
|
||||
});
|
||||
const decoded = await decodeSettingsFromSetupURI(
|
||||
generated.setupURI,
|
||||
generated.setupPassphrase,
|
||||
);
|
||||
assert(decoded, "Commonlib could not decode the Object Storage Setup URI");
|
||||
const effective = { ...DEFAULT_SETTINGS, ...decoded };
|
||||
assert(
|
||||
effective.customChunkSize === 10,
|
||||
"the journal chunk-size preset was not applied",
|
||||
);
|
||||
assert(
|
||||
effective.liveSync,
|
||||
"Object Storage was not configured for live journal synchronisation",
|
||||
);
|
||||
assert(
|
||||
effective.endpoint === "https://objects.example.test",
|
||||
"the endpoint was not preserved",
|
||||
);
|
||||
assert(
|
||||
effective.bucketPrefix === "team-a",
|
||||
"the bucket prefix was not preserved",
|
||||
);
|
||||
|
||||
const profiles = Object.values(decoded.remoteConfigurations ?? {});
|
||||
assert(
|
||||
profiles.length === 1,
|
||||
"the Setup URI did not contain exactly one Object Storage profile",
|
||||
);
|
||||
assert(
|
||||
decoded.activeConfigurationId === profiles[0].id,
|
||||
"the Object Storage profile was not selected",
|
||||
);
|
||||
assert(
|
||||
profiles[0].uri.startsWith("sls+s3://"),
|
||||
"the selected profile was not an S3 connection URI",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("generates a random-room P2P Setup URI without copying a device identity", async () => {
|
||||
const generated = await generateSetupURI({
|
||||
remote_type: "p2p",
|
||||
passphrase: "vault-secret",
|
||||
uri_passphrase: "setup-secret",
|
||||
});
|
||||
const decoded = await decodeSettingsFromSetupURI(
|
||||
generated.setupURI,
|
||||
generated.setupPassphrase,
|
||||
);
|
||||
assert(decoded, "Commonlib could not decode the P2P Setup URI");
|
||||
const effective = { ...DEFAULT_SETTINGS, ...decoded };
|
||||
assert(
|
||||
/^\d{3}-\d{3}-\d{3}-[a-z0-9]{3}$/.test(effective.P2P_roomID),
|
||||
"Commonlib did not generate the expected random room ID",
|
||||
);
|
||||
assert(
|
||||
/^[A-Za-z0-9_-]{32}$/.test(effective.P2P_passphrase),
|
||||
"the generated P2P passphrase was not a 32-character base64url secret",
|
||||
);
|
||||
assert(
|
||||
!effective.P2P_AutoStart,
|
||||
"P2P auto-start was enabled without an explicit request",
|
||||
);
|
||||
assert(
|
||||
!effective.P2P_AutoBroadcast,
|
||||
"P2P auto-broadcast was enabled without an explicit request",
|
||||
);
|
||||
assert(
|
||||
!Object.hasOwn(decoded, "P2P_DevicePeerName"),
|
||||
"the Setup URI copied a device-specific P2P peer name",
|
||||
);
|
||||
|
||||
const profiles = Object.values(decoded.remoteConfigurations ?? {});
|
||||
assert(
|
||||
profiles.length === 1,
|
||||
"the Setup URI did not contain exactly one P2P profile",
|
||||
);
|
||||
assert(
|
||||
decoded.activeConfigurationId === profiles[0].id,
|
||||
"the P2P profile was not selected as the main remote",
|
||||
);
|
||||
assert(
|
||||
decoded.P2P_ActiveRemoteConfigurationId === profiles[0].id,
|
||||
"the P2P profile was not selected for P2P features",
|
||||
);
|
||||
assert(
|
||||
profiles[0].uri.startsWith("sls+p2p://"),
|
||||
"the selected profile was not a P2P connection URI",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { encodeSettingsToSetupURI } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/API/processSetting";
|
||||
import { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/common/utils";
|
||||
import { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/remote-configurations";
|
||||
import {
|
||||
createNewVaultSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
PREFERRED_BASE,
|
||||
PREFERRED_JOURNAL_SYNC,
|
||||
PREFERRED_SETTING_SELF_HOSTED,
|
||||
} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings";
|
||||
|
||||
export type SetupRemoteType = "couchdb" | "s3" | "p2p";
|
||||
export type SetupGeneratorEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export interface GeneratedSetupURI {
|
||||
remoteType: SetupRemoteType;
|
||||
setupURI: string;
|
||||
setupPassphrase: string;
|
||||
}
|
||||
|
||||
function requireValue(
|
||||
environment: SetupGeneratorEnvironment,
|
||||
name: string,
|
||||
): string {
|
||||
const value = environment[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(
|
||||
environment: SetupGeneratorEnvironment,
|
||||
name: string,
|
||||
fallback: boolean,
|
||||
): boolean {
|
||||
const value = environment[name]?.trim().toLowerCase();
|
||||
if (!value) return fallback;
|
||||
if (value === "true" || value === "1") return true;
|
||||
if (value === "false" || value === "0") return false;
|
||||
throw new Error(`${name} must be true, false, 1, or 0`);
|
||||
}
|
||||
|
||||
export function generateSecret(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(24));
|
||||
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll(
|
||||
"/",
|
||||
"_",
|
||||
).replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function applyEncryptedVaultSettings(
|
||||
settings: ObsidianLiveSyncSettings,
|
||||
environment: SetupGeneratorEnvironment,
|
||||
): void {
|
||||
Object.assign(settings, {
|
||||
encrypt: true,
|
||||
passphrase: requireValue(environment, "passphrase"),
|
||||
usePathObfuscation: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createCouchDBSettings(
|
||||
environment: SetupGeneratorEnvironment,
|
||||
): ObsidianLiveSyncSettings {
|
||||
const settings = createNewVaultSettings();
|
||||
Object.assign(settings, PREFERRED_SETTING_SELF_HOSTED, {
|
||||
couchDB_URI: requireValue(environment, "hostname"),
|
||||
couchDB_USER: requireValue(environment, "username"),
|
||||
couchDB_PASSWORD: requireValue(environment, "password"),
|
||||
couchDB_DBNAME: requireValue(environment, "database"),
|
||||
batchSave: true,
|
||||
periodicReplication: true,
|
||||
syncOnStart: true,
|
||||
syncOnFileOpen: true,
|
||||
syncAfterMerge: true,
|
||||
});
|
||||
applyEncryptedVaultSettings(settings, environment);
|
||||
upsertRemoteConfigurationInPlace(settings, "couchdb", { activate: true });
|
||||
return settings;
|
||||
}
|
||||
|
||||
function createObjectStorageSettings(
|
||||
environment: SetupGeneratorEnvironment,
|
||||
): ObsidianLiveSyncSettings {
|
||||
const settings = createNewVaultSettings();
|
||||
Object.assign(settings, PREFERRED_JOURNAL_SYNC, {
|
||||
endpoint: requireValue(environment, "endpoint"),
|
||||
accessKey: requireValue(environment, "access_key"),
|
||||
secretKey: requireValue(environment, "secret_key"),
|
||||
bucket: requireValue(environment, "bucket"),
|
||||
region: environment.region?.trim() || "auto",
|
||||
bucketPrefix: environment.bucket_prefix?.trim() || "",
|
||||
bucketCustomHeaders: environment.bucket_custom_headers?.trim() || "",
|
||||
useCustomRequestHandler: optionalBoolean(
|
||||
environment,
|
||||
"use_custom_request_handler",
|
||||
false,
|
||||
),
|
||||
forcePathStyle: optionalBoolean(environment, "force_path_style", true),
|
||||
liveSync: true,
|
||||
});
|
||||
applyEncryptedVaultSettings(settings, environment);
|
||||
upsertRemoteConfigurationInPlace(settings, "s3", { activate: true });
|
||||
return settings;
|
||||
}
|
||||
|
||||
function createP2PSettings(
|
||||
environment: SetupGeneratorEnvironment,
|
||||
): ObsidianLiveSyncSettings {
|
||||
const settings = createNewVaultSettings();
|
||||
Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, {
|
||||
P2P_Enabled: true,
|
||||
P2P_roomID: environment.p2p_room_id?.trim() || generateP2PRoomId(),
|
||||
P2P_passphrase: environment.p2p_passphrase?.trim() || generateSecret(),
|
||||
P2P_relays: environment.p2p_relays?.trim() ||
|
||||
P2P_DEFAULT_SETTINGS.P2P_relays,
|
||||
P2P_AppID: environment.p2p_app_id?.trim() || P2P_DEFAULT_SETTINGS.P2P_AppID,
|
||||
P2P_AutoStart: optionalBoolean(
|
||||
environment,
|
||||
"p2p_auto_start",
|
||||
P2P_DEFAULT_SETTINGS.P2P_AutoStart,
|
||||
),
|
||||
P2P_AutoBroadcast: optionalBoolean(
|
||||
environment,
|
||||
"p2p_auto_broadcast",
|
||||
P2P_DEFAULT_SETTINGS.P2P_AutoBroadcast,
|
||||
),
|
||||
});
|
||||
applyEncryptedVaultSettings(settings, environment);
|
||||
upsertRemoteConfigurationInPlace(settings, "p2p", {
|
||||
activate: true,
|
||||
activateForP2P: true,
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
function parseRemoteType(
|
||||
environment: SetupGeneratorEnvironment,
|
||||
): SetupRemoteType {
|
||||
const remoteType = environment.remote_type?.trim().toLowerCase() || "couchdb";
|
||||
if (remoteType === "couchdb" || remoteType === "s3" || remoteType === "p2p") {
|
||||
return remoteType;
|
||||
}
|
||||
throw new Error("remote_type must be couchdb, s3, or p2p");
|
||||
}
|
||||
|
||||
export function createSetupSettings(
|
||||
environment: SetupGeneratorEnvironment,
|
||||
): { remoteType: SetupRemoteType; settings: ObsidianLiveSyncSettings } {
|
||||
const remoteType = parseRemoteType(environment);
|
||||
if (remoteType === "couchdb") {
|
||||
return { remoteType, settings: createCouchDBSettings(environment) };
|
||||
}
|
||||
if (remoteType === "s3") {
|
||||
return { remoteType, settings: createObjectStorageSettings(environment) };
|
||||
}
|
||||
return { remoteType, settings: createP2PSettings(environment) };
|
||||
}
|
||||
|
||||
export async function generateSetupURI(
|
||||
environment: SetupGeneratorEnvironment,
|
||||
): Promise<GeneratedSetupURI> {
|
||||
const setupPassphrase = environment.uri_passphrase?.trim() ||
|
||||
generateSecret();
|
||||
const { remoteType, settings } = createSetupSettings(environment);
|
||||
const setupURI = await encodeSettingsToSetupURI(settings, setupPassphrase, [
|
||||
"pluginSyncExtendedSetting",
|
||||
"doNotUseFixedRevisionForChunks",
|
||||
], true);
|
||||
return { remoteType, setupURI: setupURI.trim(), setupPassphrase };
|
||||
}
|
||||
|
||||
export async function runSetupURIGenerator(
|
||||
environment: SetupGeneratorEnvironment = Deno.env.toObject(),
|
||||
): Promise<void> {
|
||||
const generated = await generateSetupURI(environment);
|
||||
console.log(`\nGenerated ${generated.remoteType} Setup URI.`);
|
||||
console.log(
|
||||
"Your passphrase for the Setup URI is:",
|
||||
generated.setupPassphrase,
|
||||
);
|
||||
console.log("This passphrase is never shown again, so store it safely.");
|
||||
console.log(generated.setupURI);
|
||||
}
|
||||
|
||||
if (import.meta.main) await runSetupURIGenerator();
|
||||
Reference in New Issue
Block a user