diff --git a/README.md b/README.md index 4324c69..8986394 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ OWD Sync is the companion Obsidian plugin for vault you explicitly opened to an owner-controlled OWD deployment for sync, search, encrypted snapshots, and recovery. -> **Invited family test:** OWD Sync `0.1.4` is available through BRAT while its +> **Invited family test:** OWD Sync `0.1.6` is available through BRAT while its > Obsidian Community Plugins review is pending. The invited tester first > deploys the private OWD Platform fork from its > [trusted-tester start page](https://github.com/msinclair25/owd-platform/blob/main/docs/TRUSTED-TESTER-START.md). @@ -18,7 +18,7 @@ search, encrypted snapshots, and recovery. [BRAT](https://github.com/TfTHacker/obsidian42-brat) from Obsidian's Community Plugins. 4. Use the dashboard action to add `msinclair25/owd-sync`, then confirm - Obsidian shows version `0.1.4`. Stop if it differs. + Obsidian shows version `0.1.6`. Stop if it differs. 5. Enable **OWD Sync** under **Settings → Community plugins**. BRAT installs and updates the published GitHub Release. No terminal or hidden @@ -45,7 +45,7 @@ request, run **OWD Sync: Pair this vault with OWD**, and paste it. ## Diagnostic package Download `owd-sync-.zip` and `checksums.txt` from the matching -[OWD Sync 0.1.4 GitHub Release](https://github.com/msinclair25/owd-sync/releases/tag/0.1.4). +[OWD Sync 0.1.6 GitHub Release](https://github.com/msinclair25/owd-sync/releases/tag/0.1.6). Verify the checksum, then install the complete `owd-sync` directory as one version-matched unit. Do not mix `main.js`, `manifest.json`, or `styles.css` from different releases. If BRAT is blocked, stop the acceptance run. The ZIP diff --git a/SOURCE.md b/SOURCE.md index a8ec54b..fde30a8 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -4,11 +4,11 @@ OWD Sync is developed in the private OWD Platform monorepo and promoted to this sanitized public plugin repository while the complete platform remains in private beta. -The current `0.1.4` public source and assets correspond to: +The current `0.1.6` public source and assets correspond to: -- OWD Platform plugin tag: `owd-sync-v0.1.4` -- source commit: `5406b5880613c716f5b398353da546160d483d59` -- public release tag: `0.1.4` +- OWD Platform plugin tag: `owd-sync-v0.1.6` +- source commit: `2322e7077c93549468c983d202ab28bb161142af` +- public release tag: `0.1.6` - plugin ID: `owd-sync` The public release uses a tag exactly equal to `manifest.json`'s semantic diff --git a/manifest.json b/manifest.json index 6046907..8574930 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "owd-sync", "name": "OWD Sync", - "version": "0.1.4", + "version": "0.1.6", "minAppVersion": "1.5.0", "description": "Private, self-hosted Obsidian sync through your OWD Cloudflare deployment.", "author": "OWD Platform contributors", diff --git a/package.json b/package.json index c410dd8..b66bfc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "owd-sync", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "The OWD Sync companion plugin for Obsidian.", "license": "Apache-2.0 AND 0BSD", diff --git a/src/main.ts b/src/main.ts index 10a8e1b..05ef5a3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { Notice, requestUrl } from "obsidian"; +import { Notice, arrayBufferToBase64, requestUrl } from "obsidian"; import VaultCrdtSyncPlugin from "../vendor/yaos-src/main"; import { OwdPairingError, @@ -8,9 +8,11 @@ import { type OwdConnection, } from "./pairing-contract"; import { confirmOwdPairing, promptForOwdPairingLink } from "./pairing-modal"; +import { parseObsidianMindRuntimeProfile } from "./vault-runtime-profile"; export default class OwdSyncPlugin extends VaultCrdtSyncPlugin { private upstreamLoad: Promise = Promise.resolve(); + private confirmationTail: Promise = Promise.resolve(); override async onload(): Promise { this.addCommand({ @@ -25,6 +27,13 @@ export default class OwdSyncPlugin extends VaultCrdtSyncPlugin { this.upstreamLoad = super.onload(); await this.upstreamLoad; + if ( + this.settings.host.trim() !== "" && + this.settings.token.trim() !== "" && + this.settings.vaultId.trim() !== "" + ) { + void this.confirmCurrentSync(false); + } } override startOwdPairing(): void { @@ -75,5 +84,76 @@ export default class OwdSyncPlugin extends VaultCrdtSyncPlugin { token: connection.token, vaultId: connection.vaultId, }); + await this.confirmCurrentSync(true); + } + + private confirmCurrentSync(showSuccess: boolean): Promise { + const scheduled = this.confirmationTail.then(async () => { + const stateVector = await this.getOwdSyncConfirmationState(); + const runtimeProfile = await this.readRuntimeProfile(); + const stateVectorBase64Url = arrayBufferToBase64(stateVector) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); + const response = await requestUrl({ + body: JSON.stringify({ + pluginVersion: this.manifest.version, + ...(runtimeProfile === null ? {} : { runtimeProfile }), + schemaVersion: 3, + stateVector: stateVectorBase64Url, + }), + headers: { + Accept: "application/json", + Authorization: `Bearer ${this.settings.token}`, + "Content-Type": "application/json", + }, + method: "POST", + throw: false, + url: `${this.settings.host.replace(/\/$/u, "")}/api/vaults/${encodeURIComponent(this.settings.vaultId)}/sync-confirmation`, + }); + if (response.status !== 200 && response.status !== 202) { + const problem = + typeof response.json === "object" && + response.json !== null && + typeof Reflect.get(response.json, "error") === "object" && + Reflect.get(response.json, "error") !== null + ? Reflect.get(Reflect.get(response.json, "error"), "message") + : null; + throw new OwdPairingError( + typeof problem === "string" + ? problem + : `OWD could not confirm the first sync (server status ${response.status}).`, + ); + } + if (showSuccess) { + new Notice( + "OWD Sync connected this vault and started its searchable library.", + 8000, + ); + } + }); + this.confirmationTail = scheduled.catch((error: unknown) => { + if (!showSuccess) { + new Notice( + error instanceof Error + ? `OWD Sync: ${error.message}` + : "OWD Sync could not confirm this vault.", + 8000, + ); + } + }); + return scheduled; + } + + private async readRuntimeProfile() { + try { + const manifestPath = "vault-manifest.json"; + if (!(await this.app.vault.adapter.exists(manifestPath))) return null; + return parseObsidianMindRuntimeProfile( + await this.app.vault.adapter.read(manifestPath), + ); + } catch { + return null; + } } } diff --git a/src/vault-runtime-profile.ts b/src/vault-runtime-profile.ts new file mode 100644 index 0000000..f8ae31a --- /dev/null +++ b/src/vault-runtime-profile.ts @@ -0,0 +1,149 @@ +export type ObsidianMindRuntimeProfile = { + contentRoots: string[]; + id: "obsidian-mind"; + memoryRoot: string; + neverExposeFileNames: string[]; + version: string; +}; + +type Manifest = Record; + +const FALLBACK_ROOTS = ["brain", "reference"]; +const MAX_CONTENT_ROOTS = 32; +const MAX_NEVER_EXPOSE_FILES = 64; + +function isRecord(value: unknown): value is Manifest { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function uniqueCaseInsensitive(values: string[]): string[] { + const result = new Map(); + for (const value of values) { + const key = value.toLocaleLowerCase("en-US"); + if (!result.has(key)) result.set(key, value); + } + return [...result.values()]; +} + +function hasControlCharacters(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f); + }); +} + +/** + * Preserve the granularity of Obsidian Mind's own exposure resolver. A final + * file glob such as brain/*.md safely maps to its parent folder. A dynamic + * folder glob such as perf/h*-* is dropped because widening it to perf would + * expose more than the manifest declared. + */ +function cleanRoots(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const roots: string[] = []; + for (const raw of value) { + if (typeof raw !== "string") continue; + const trimmed = raw.trim().replace(/^\.\//u, ""); + const endsInFolder = /[\\/]$/u.test(trimmed); + const normalized = trimmed.replace(/^[\\/]+|[\\/]+$/gu, ""); + if (normalized === "") continue; + const parts = normalized.split(/[\\/]/u); + if ( + parts.some( + (part) => + part === "" || + part === "." || + part === ".." || + hasControlCharacters(part), + ) + ) { + continue; + } + const globIndex = parts.findIndex((part) => part.includes("*")); + if (globIndex === -1) { + roots.push(parts.join("/")); + continue; + } + if ( + !endsInFolder && + globIndex === parts.length - 1 && + globIndex > 0 && + parts[globIndex] === "*.md" + ) { + roots.push(parts.slice(0, globIndex).join("/")); + } + } + return uniqueCaseInsensitive(roots).slice(0, MAX_CONTENT_ROOTS); +} + +function cleanNeverExposeFileNames(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return uniqueCaseInsensitive( + value.flatMap((raw) => { + if ( + typeof raw !== "string" || + raw.length === 0 || + raw.length > 255 || + raw.includes("/") || + raw.includes("\\") || + hasControlCharacters(raw) + ) { + return []; + } + return [raw]; + }), + ).slice(0, MAX_NEVER_EXPOSE_FILES); +} + +function cleanMemoryRoot(value: unknown): string { + const roots = cleanRoots([value]); + return roots[0] ?? "memories"; +} + +function withoutMemoryRoot(roots: string[], memoryRoot: string): string[] { + const memoryKey = memoryRoot.toLocaleLowerCase("en-US"); + return roots.filter((root) => { + const key = root.toLocaleLowerCase("en-US"); + return key !== memoryKey && !key.startsWith(`${memoryKey}/`); + }); +} + +export function parseObsidianMindRuntimeProfile( + manifestText: string, +): ObsidianMindRuntimeProfile | null { + let parsed: unknown; + try { + parsed = JSON.parse(manifestText) as unknown; + } catch { + return null; + } + if ( + !isRecord(parsed) || + parsed.template !== "obsidian-mind" || + typeof parsed.version !== "string" || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(parsed.version) + ) { + return null; + } + + const memoryRoot = cleanMemoryRoot(parsed.memory_root); + const declaredRoots = cleanRoots(parsed.mcp_exposed_roots); + const derivedRoots = cleanRoots(parsed.user_content_roots); + const contentRoots = withoutMemoryRoot( + declaredRoots.length > 0 + ? declaredRoots + : derivedRoots.length > 0 + ? derivedRoots + : FALLBACK_ROOTS, + memoryRoot, + ); + if (contentRoots.length === 0) return null; + + return { + contentRoots, + id: "obsidian-mind", + memoryRoot, + neverExposeFileNames: cleanNeverExposeFileNames(parsed.mcp_never_expose), + version: parsed.version, + }; +} diff --git a/test/vault-runtime-profile.test.ts b/test/vault-runtime-profile.test.ts new file mode 100644 index 0000000..dd8a0c0 --- /dev/null +++ b/test/vault-runtime-profile.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { parseObsidianMindRuntimeProfile } from "../src/vault-runtime-profile"; + +describe("Obsidian Mind runtime profile", () => { + it("derives the upstream exposure boundary without widening folder globs", () => { + expect( + parseObsidianMindRuntimeProfile( + JSON.stringify({ + mcp_exposed_roots: [], + mcp_never_expose: ["SOUL.md", "North Star.md"], + memory_root: "memories", + template: "obsidian-mind", + user_content_roots: [ + "work/active/", + "perf/h*-*/", + "perf/competencies/*.md", + "brain/*.md", + "memories/", + ], + version: "8.1.0", + }), + ), + ).toEqual({ + contentRoots: ["work/active", "perf/competencies", "brain"], + id: "obsidian-mind", + memoryRoot: "memories", + neverExposeFileNames: ["SOUL.md", "North Star.md"], + version: "8.1.0", + }); + }); + + it("honors an explicit narrower exposure list", () => { + expect( + parseObsidianMindRuntimeProfile( + JSON.stringify({ + mcp_exposed_roots: ["brain", "memories", "../escape"], + memory_root: "memories", + template: "obsidian-mind", + user_content_roots: ["work/active"], + version: "8.1.0", + }), + )?.contentRoots, + ).toEqual(["brain"]); + }); + + it("rejects an unrelated or malformed manifest", () => { + expect( + parseObsidianMindRuntimeProfile( + JSON.stringify({ template: "other", version: "8.1.0" }), + ), + ).toBeNull(); + expect(parseObsidianMindRuntimeProfile("{")).toBeNull(); + }); +}); diff --git a/vendor/yaos-src/main.ts b/vendor/yaos-src/main.ts index 0c15855..b4b9d2b 100644 --- a/vendor/yaos-src/main.ts +++ b/vendor/yaos-src/main.ts @@ -84,6 +84,7 @@ import { runSchemaMigrationToV2 } from "./migrations/schemaV2"; import { isLocalOrigin } from "./sync/origins"; import type { EngineControlPort, DiskIngestPort } from "./runtime/engineControlPort"; import type { BindingPropagationGate } from "./sync/editorBinding"; +import * as Y from "yjs"; // Build-time constant injected by esbuild. // production build (main.js): define __YAOS_QA_HARNESS_ENABLED__ = false @@ -284,9 +285,42 @@ export default class VaultCrdtSyncPlugin extends Plugin { } await this.setupLinkController.applyOwdConnection(params); + if ( + this.settings.host.replace(/\/$/u, "") !== params.host.replace(/\/$/u, "") || + this.settings.token !== params.token || + this.settings.vaultId !== params.vaultId + ) { + throw new Error("The new OWD vault connection was not applied."); + } this.settingsTab?.display(); } + /** + * OWD adapter boundary: return a state vector only after local persistence, + * provider sync, reconciliation, and the server's durable receipt all agree. + */ + protected async getOwdSyncConfirmationState(): Promise { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + const sync = this.vaultSync; + if ( + sync?.connected === true && + sync.providerSynced === true && + this.reconciliationController?.isReconciled === true && + sync.serverAppliedLocalState === true + ) { + const bytes = Y.encodeStateVector(sync.ydoc); + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; + } + await new Promise((resolve) => setTimeout(resolve, 125)); + } + throw new Error( + "OWD has not received a durable, reconciled copy of this vault yet. Keep Obsidian open and retry.", + ); + } + async onload() { const onloadStartedAt = Date.now(); @@ -357,6 +391,11 @@ export default class VaultCrdtSyncPlugin extends Plugin { updateSettings: (mutator, reason) => this.updateSettings(mutator, reason), }); await this.loadSettings(); + if (this.settings.maxFileSizeKB > 1024) { + await this.updateSettings((settings) => { + settings.maxFileSizeKB = 1024; + }, "owd-library-size-limit"); + } this.applyRuntimeSettings("load-settings"); const self = this; this.frontmatterGuardCoordinator = new FrontmatterGuardCoordinator({ diff --git a/vendor/yaos-src/settings/settingsStore.ts b/vendor/yaos-src/settings/settingsStore.ts index 6cde491..cec0c9b 100644 --- a/vendor/yaos-src/settings/settingsStore.ts +++ b/vendor/yaos-src/settings/settingsStore.ts @@ -71,7 +71,7 @@ export const DEFAULT_SETTINGS: VaultSyncSettings = { debug: false, frontmatterGuardEnabled: true, excludePatterns: "", - maxFileSizeKB: 2048, + maxFileSizeKB: 1024, externalEditPolicy: "always", enableAttachmentSync: true, attachmentSyncExplicitlyConfigured: false, diff --git a/vendor/yaos-src/settings/settingsTab.ts b/vendor/yaos-src/settings/settingsTab.ts index a5cc21c..38015bf 100644 --- a/vendor/yaos-src/settings/settingsTab.ts +++ b/vendor/yaos-src/settings/settingsTab.ts @@ -38,7 +38,8 @@ export interface VaultSyncSettingsHost { getUpdateState(): SettingsUpdateState; } -const CLOUDFLARE_DEPLOY_URL = "https://deploy.workers.cloudflare.com/?url=https://github.com/msinclair25/owd-platform"; +const CLOUDFLARE_DEPLOY_URL = + "https://deploy.workers.cloudflare.com/?url=https://github.com/msinclair25/owd-platform"; /** Returns true if the host URL is unencrypted and not localhost. */ function isInsecureRemoteHost(host: string): boolean { @@ -220,14 +221,14 @@ export class VaultSyncSettingTab extends PluginSettingTab { new Setting(containerEl) .setName("Max text file size in kilobytes") - .setDesc("Text files larger than this are skipped for live document sync.") + .setDesc("Text files larger than this are skipped. OWD libraries support at most 1024 KB per Markdown file.") .addText((text) => text - .setPlaceholder("2048") + .setPlaceholder("1024") .setValue(String(this.host.settings.maxFileSizeKB)) .onChange(async (value) => { const n = parseInt(value, 10); - if (!isNaN(n) && n > 0) { + if (!isNaN(n) && n > 0 && n <= 1024) { await this.host.updateSettings((settings) => { settings.maxFileSizeKB = n; }, "settings:max-file-size"); diff --git a/versions.json b/versions.json index a499de0..59142f9 100644 --- a/versions.json +++ b/versions.json @@ -3,5 +3,7 @@ "0.1.1": "1.5.0", "0.1.2": "1.5.0", "0.1.3": "1.5.0", - "0.1.4": "1.5.0" + "0.1.4": "1.5.0", + "0.1.5": "1.5.0", + "0.1.6": "1.5.0" }