Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand All @@ -45,7 +45,7 @@ request, run **OWD Sync: Pair this vault with OWD**, and paste it.
## Diagnostic package

Download `owd-sync-<version>.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
Expand Down
8 changes: 4 additions & 4 deletions SOURCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
82 changes: 81 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Notice, requestUrl } from "obsidian";
import { Notice, arrayBufferToBase64, requestUrl } from "obsidian";
import VaultCrdtSyncPlugin from "../vendor/yaos-src/main";
import {
OwdPairingError,
Expand All @@ -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<void> = Promise.resolve();
private confirmationTail: Promise<void> = Promise.resolve();

override async onload(): Promise<void> {
this.addCommand({
Expand All @@ -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 {
Expand Down Expand Up @@ -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<void> {
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;
}
}
}
149 changes: 149 additions & 0 deletions src/vault-runtime-profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
export type ObsidianMindRuntimeProfile = {
contentRoots: string[];
id: "obsidian-mind";
memoryRoot: string;
neverExposeFileNames: string[];
version: string;
};

type Manifest = Record<string, unknown>;

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<string, string>();
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,
};
}
54 changes: 54 additions & 0 deletions test/vault-runtime-profile.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading