commit 470a1c15b82a598e20119c42deca47f600159ddf Author: Luke Betteridge Date: Wed Apr 8 11:55:27 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/Plan.md b/Plan.md new file mode 100644 index 0000000..f1f48ee --- /dev/null +++ b/Plan.md @@ -0,0 +1,44 @@ +# Obsidian Sync Plan + +## Goal + +Build a self-hosted sync system for an Obsidian vault that works across Windows, Linux, and Android. The system will use an Obsidian plugin for clients, a central sync server, and end-to-end encrypted note transfer. Conflicts should try automatic merge first, then fall back to user-visible conflict handling. + +## Current Implementation Slice + +1. Create a TypeScript monorepo for the plugin, server, and shared libraries. +2. Define shared sync contracts for device registration, pull, push, tombstones, and conflict responses. +3. Add a shared sync engine with AES-GCM encryption, hashing, and safe text merge helpers. +4. Build a durable server that registers devices, stores encrypted note revisions on disk, and writes structured request and client-sync logs. +5. Build an Obsidian plugin with settings, manual sync, scheduled sync, remote pull, local push, attachment sync, conflict marker handling, and sync-run diagnostics upload. +6. Validate the scaffold with a workspace build and focused tests. + +## Delivery Phases + +### Phase 1 + +- Monorepo setup +- Protocol definitions +- Shared crypto and merge helpers +- Minimal server API +- Plugin settings and sync loop + +### Phase 2 + +- Durable storage backend for the server +- Attachment sync +- Better device onboarding and recovery flow +- Structured logs and user-visible sync diagnostics + +### Phase 3 + +- Hardening for large vaults and flaky networks +- Packaging and deployment docs +- Cross-device integration testing + +## Notes + +- The server now persists encrypted sync state to a local data directory and keeps JSON-line diagnostics logs. +- Text conflicts are handled on the client and binary conflicts produce a remote conflict copy. +- Recovery bundle onboarding, device revocation, key rotation, and batched sync are implemented in the current repo. +- The next highest-priority gap is broader multi-instance Obsidian integration testing and a production-grade database-backed server store. diff --git a/README.md b/README.md new file mode 100644 index 0000000..46f69c7 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# Obsidian Sync + +Obsidian Sync is a self-hosted, end-to-end encrypted vault sync system for Obsidian across Windows, Linux, and Android. It consists of an Obsidian plugin, a sync server, and shared protocol and crypto packages inside a TypeScript monorepo. + +## Features + +- End-to-end encrypted text note and binary attachment sync +- Automatic text merge with conflict markers when both sides change +- Binary conflict copies when attachments diverge +- Durable server state stored on disk +- Structured JSON-line diagnostics and client sync log upload +- Recovery bundle export and import for onboarding new devices +- Device listing and revocation +- Vault key rotation with recovery-bundle refresh +- Paged pull and batched push behavior so interrupted sync runs can resume safely + +## Repository Layout + +- `apps/obsidian-plugin`: Obsidian plugin entry point, settings UI, and sync orchestration +- `apps/sync-server`: Express server for encrypted revision exchange and device management +- `packages/sync-protocol`: Shared request and response schemas +- `packages/sync-engine`: Shared crypto, hashing, and merge helpers +- `docs`: Architecture and deployment notes +- `tests`: Focused contract and store-level integration tests + +## Requirements + +- Node.js 22 or newer +- npm 10 or newer + +## Install + +```bash +npm install +``` + +## Build + +```bash +npm run build +``` + +## Test + +```bash +npm test +``` + +## Run The Server + +```bash +npm run dev:server +``` + +The default server URL is `http://localhost:8787`. + +## First-Time Setup + +1. Start the sync server. +2. Load the plugin into an Obsidian vault. +3. Set the server URL, vault ID, and device name in the plugin settings. +4. Run a manual sync to register the first device and generate the initial vault key. +5. Export a recovery bundle from the settings tab and store it securely. + +## Add Another Device + +1. Install the plugin on the second device. +2. Open the plugin settings and paste the recovery bundle into the import field. +3. Run a manual sync to register that device and pull the encrypted vault contents. + +## Rotate The Vault Key + +1. Open the plugin settings and use `Rotate key`. +2. Wait for the client to re-encrypt and upload the current vault contents. +3. Export the fresh recovery bundle. +4. Import that new bundle on every other device before the next sync. + +## Revoke A Device + +Use the plugin settings device list to revoke a device that should no longer have access. A revoked device can no longer authenticate to the server. + +## Server Data + +- `data/sync-state.json`: durable revision, device, tombstone, and key-status state +- `data/logs/server.jsonl`: structured server and client-sync diagnostics + +## Current Limitations + +- The server storage backend is file-based rather than SQLite or PostgreSQL. +- Sync validation is focused on shared libraries and the server store; there is not yet a full Obsidian plugin integration harness. +- Device onboarding is driven by a copy-and-paste recovery bundle instead of a richer pairing flow. + +## Documentation + +- `docs/architecture.md` +- `docs/deployment.md` +- `Plan.md` diff --git a/Specsheet.md b/Specsheet.md new file mode 100644 index 0000000..094afde --- /dev/null +++ b/Specsheet.md @@ -0,0 +1,2 @@ +# Description +I need a system that syncs my obisian vault between different devices and is connected by a server. For this application, I will need a client side plugin for obisdian which works on both windows, linux, and android obsidian. I will also need a server application that will allow me to sync my notes to it and distribute it out to other devices connected. This system will require a version control system that allow the user to edit it on different devices and sort out the different edits. If there is an error create a log and alert the user. \ No newline at end of file diff --git a/apps/obsidian-plugin/manifest.json b/apps/obsidian-plugin/manifest.json new file mode 100644 index 0000000..9d0fef4 --- /dev/null +++ b/apps/obsidian-plugin/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "obsidian-sync", + "name": "Obsidian Sync", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Sync a vault through a self-hosted encrypted sync server.", + "author": "Local", + "isDesktopOnly": false +} diff --git a/apps/obsidian-plugin/package.json b/apps/obsidian-plugin/package.json new file mode 100644 index 0000000..4469d80 --- /dev/null +++ b/apps/obsidian-plugin/package.json @@ -0,0 +1,18 @@ +{ + "name": "@obsidian-sync/obsidian-plugin", + "private": true, + "version": "0.1.0", + "main": "dist/main.js", + "types": "dist/main.d.ts", + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "@obsidian-sync/sync-engine": "0.1.0", + "@obsidian-sync/sync-protocol": "0.1.0" + }, + "devDependencies": { + "obsidian": "^1.8.7" + } +} diff --git a/apps/obsidian-plugin/src/main.ts b/apps/obsidian-plugin/src/main.ts new file mode 100644 index 0000000..353d9da --- /dev/null +++ b/apps/obsidian-plugin/src/main.ts @@ -0,0 +1,96 @@ +import { Notice, Plugin } from "obsidian"; + +import { SyncSettingsTab } from "./settings/SyncSettingsTab"; +import { DEFAULT_SETTINGS, type ObsidianSyncSettings } from "./settings/settings"; +import { SyncService } from "./sync/SyncService"; + +export default class ObsidianSyncPlugin extends Plugin { + settings: ObsidianSyncSettings = DEFAULT_SETTINGS; + + private syncService?: SyncService; + private scheduledSyncHandle?: number; + + async onload(): Promise { + await this.loadSettings(); + + this.syncService = new SyncService( + this.app, + () => this.settings, + async (settings) => { + this.settings = settings; + await this.saveSettings(); + } + ); + + this.addSettingTab(new SyncSettingsTab(this.app, this, () => this.configureSyncSchedule())); + + this.addCommand({ + id: "obsidian-sync-run-manual", + name: "Run encrypted sync now", + callback: async () => { + await this.syncService?.runManualSync(); + } + }); + + this.addCommand({ + id: "obsidian-sync-rotate-vault-key", + name: "Rotate vault key", + callback: async () => { + const result = await this.getSyncServiceOrThrow().rotateVaultKey(); + new Notice( + `Vault key rotated. Uploaded ${result.uploadedFiles} files. Export the fresh recovery bundle to update other devices.` + ); + } + }); + + this.configureSyncSchedule(); + new Notice("Obsidian Sync loaded."); + } + + onunload(): void { + if (this.scheduledSyncHandle !== undefined) { + window.clearInterval(this.scheduledSyncHandle); + } + } + + async loadSettings(): Promise { + const loaded = (await this.loadData()) as Partial | null; + this.settings = { + ...DEFAULT_SETTINGS, + ...loaded, + deviceName: loaded?.deviceName || this.app.vault.getName(), + syncState: { + ...DEFAULT_SETTINGS.syncState, + ...(loaded?.syncState ?? {}), + files: { + ...DEFAULT_SETTINGS.syncState.files, + ...(loaded?.syncState?.files ?? {}) + } + } + }; + await this.saveSettings(); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + getSyncServiceOrThrow(): SyncService { + if (!this.syncService) { + throw new Error("Sync service is not ready yet."); + } + + return this.syncService; + } + + private configureSyncSchedule(): void { + if (this.scheduledSyncHandle !== undefined) { + window.clearInterval(this.scheduledSyncHandle); + } + + const intervalMinutes = Math.max(1, this.settings.syncIntervalMinutes); + this.scheduledSyncHandle = window.setInterval(() => { + void this.syncService?.runScheduledSync(); + }, intervalMinutes * 60_000); + } +} \ No newline at end of file diff --git a/apps/obsidian-plugin/src/settings/SyncSettingsTab.ts b/apps/obsidian-plugin/src/settings/SyncSettingsTab.ts new file mode 100644 index 0000000..534dbc6 --- /dev/null +++ b/apps/obsidian-plugin/src/settings/SyncSettingsTab.ts @@ -0,0 +1,228 @@ +import { Notice, PluginSettingTab, Setting, TextAreaComponent } from "obsidian"; + +import type ObsidianSyncPlugin from "../main"; + +export class SyncSettingsTab extends PluginSettingTab { + private exportedRecoveryBundle = ""; + private importedRecoveryBundle = ""; + + constructor( + app: ObsidianSyncPlugin["app"], + private readonly plugin: ObsidianSyncPlugin, + private readonly onSettingsUpdated: () => void + ) { + super(app, plugin); + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl("h2", { text: "Obsidian Sync" }); + const syncService = this.plugin.getSyncServiceOrThrow(); + + new Setting(containerEl) + .setName("Server URL") + .setDesc("Base URL of the sync server.") + .addText((text) => + text + .setPlaceholder("http://localhost:8787") + .setValue(this.plugin.settings.serverUrl) + .onChange(async (value) => { + this.plugin.settings.serverUrl = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Vault ID") + .setDesc("Stable identifier used to group devices for the same vault.") + .addText((text) => + text.setValue(this.plugin.settings.vaultId).onChange(async (value) => { + this.plugin.settings.vaultId = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Device name") + .setDesc("Human-readable name shown on the server.") + .addText((text) => + text.setValue(this.plugin.settings.deviceName).onChange(async (value) => { + this.plugin.settings.deviceName = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Sync interval") + .setDesc("Minutes between scheduled sync runs.") + .addText((text) => + text.setValue(String(this.plugin.settings.syncIntervalMinutes)).onChange(async (value) => { + const minutes = Number(value); + this.plugin.settings.syncIntervalMinutes = Number.isFinite(minutes) && minutes > 0 ? minutes : 5; + await this.plugin.saveSettings(); + this.onSettingsUpdated(); + }) + ); + + new Setting(containerEl) + .setName("Pull batch size") + .setDesc("Maximum remote changes to request in a single pull page.") + .addText((text) => + text.setValue(String(this.plugin.settings.pullBatchSize)).onChange(async (value) => { + const batchSize = Number(value); + this.plugin.settings.pullBatchSize = Number.isFinite(batchSize) && batchSize > 0 ? Math.floor(batchSize) : 50; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Push batch size") + .setDesc("Maximum local changes to upload in a single push request.") + .addText((text) => + text.setValue(String(this.plugin.settings.pushBatchSize)).onChange(async (value) => { + const batchSize = Number(value); + this.plugin.settings.pushBatchSize = Number.isFinite(batchSize) && batchSize > 0 ? Math.floor(batchSize) : 50; + await this.plugin.saveSettings(); + }) + ); + + containerEl.createEl("h3", { text: "Recovery" }); + + let exportBundleTextArea: TextAreaComponent | undefined; + new Setting(containerEl) + .setName("Export recovery bundle") + .setDesc("Generate a bundle you can paste into another device to recover the same encrypted vault.") + .addTextArea((textArea) => { + exportBundleTextArea = textArea; + textArea.setValue(this.exportedRecoveryBundle); + textArea.inputEl.rows = 8; + textArea.inputEl.cols = 40; + }) + .addButton((button) => + button.setButtonText("Generate").onClick(async () => { + this.exportedRecoveryBundle = await syncService.exportRecoveryBundle(); + exportBundleTextArea?.setValue(this.exportedRecoveryBundle); + new Notice("Recovery bundle generated."); + }) + ) + .addButton((button) => + button.setButtonText("Clear").onClick(() => { + this.exportedRecoveryBundle = ""; + exportBundleTextArea?.setValue(""); + }) + ); + + let importBundleTextArea: TextAreaComponent | undefined; + new Setting(containerEl) + .setName("Import recovery bundle") + .setDesc("Paste a bundle from another device to register this device against the same encrypted vault.") + .addTextArea((textArea) => { + importBundleTextArea = textArea; + textArea.setValue(this.importedRecoveryBundle); + textArea.inputEl.rows = 8; + textArea.inputEl.cols = 40; + textArea.onChange((value) => { + this.importedRecoveryBundle = value; + }); + }) + .addButton((button) => + button.setButtonText("Import").onClick(async () => { + await syncService.importRecoveryBundle(this.importedRecoveryBundle); + this.importedRecoveryBundle = ""; + this.exportedRecoveryBundle = ""; + importBundleTextArea?.setValue(""); + new Notice("Recovery bundle imported. Run sync to register this device."); + this.display(); + }) + ); + + containerEl.createEl("h3", { text: "Devices" }); + const deviceListContainer = containerEl.createDiv(); + + new Setting(containerEl) + .setName("Connected devices") + .setDesc("Refresh the active device list and revoke devices that should no longer have access.") + .addButton((button) => + button.setButtonText("Refresh").onClick(async () => { + await this.renderDeviceList(deviceListContainer); + }) + ); + + void this.renderDeviceList(deviceListContainer); + + containerEl.createEl("h3", { text: "Key rotation" }); + + new Setting(containerEl) + .setName("Rotate vault key") + .setDesc("Generate a new vault key, re-encrypt local content, and require your other devices to import a fresh recovery bundle.") + .addButton((button) => + button.setButtonText("Rotate key").onClick(async () => { + const result = await syncService.rotateVaultKey(); + this.exportedRecoveryBundle = result.recoveryBundle; + exportBundleTextArea?.setValue(result.recoveryBundle); + new Notice( + `Vault key rotated. Uploaded ${result.uploadedFiles} files. Share the fresh recovery bundle with your other devices.` + ); + await this.renderDeviceList(deviceListContainer); + }) + ); + } + + private async renderDeviceList(containerEl: HTMLElement): Promise { + containerEl.empty(); + + if (!this.plugin.settings.deviceId || !this.plugin.settings.authToken) { + containerEl.createEl("p", { + text: "Run a sync or import a recovery bundle to register this device before managing devices." + }); + return; + } + + try { + const response = await this.plugin.getSyncServiceOrThrow().listDevices(); + const summaryParts = [`Active server key: ${response.activeKeyId ?? "not set"}`]; + if (response.keyRotatedAt) { + summaryParts.push(`Last key rotation: ${response.keyRotatedAt}`); + } + + containerEl.createEl("p", { + text: summaryParts.join(" | ") + }); + + for (const device of response.devices) { + const isCurrentDevice = device.deviceId === this.plugin.settings.deviceId; + const description = [ + `ID: ${device.deviceId}`, + `Registered: ${device.issuedAt}`, + device.revokedAt ? `Revoked: ${device.revokedAt}` : "Active" + ].join(" | "); + + new Setting(containerEl) + .setName(`${device.deviceName}${isCurrentDevice ? " (this device)" : ""}`) + .setDesc(description) + .addButton((button) => { + if (device.revokedAt) { + button.setButtonText("Revoked").setDisabled(true); + return; + } + + button.setButtonText(isCurrentDevice ? "Revoke this device" : "Revoke").onClick(async () => { + const selfRevoked = await this.plugin.getSyncServiceOrThrow().revokeDevice(device.deviceId); + new Notice( + selfRevoked + ? "This device was revoked. Import a recovery bundle or run sync again to register a new device identity." + : "Device revoked." + ); + await this.renderDeviceList(containerEl); + }); + }); + } + } catch (error) { + containerEl.createEl("p", { + text: error instanceof Error ? `Failed to load devices: ${error.message}` : "Failed to load devices." + }); + } + } +} diff --git a/apps/obsidian-plugin/src/settings/settings.ts b/apps/obsidian-plugin/src/settings/settings.ts new file mode 100644 index 0000000..4131116 --- /dev/null +++ b/apps/obsidian-plugin/src/settings/settings.ts @@ -0,0 +1,47 @@ +import type { SyncFileKind } from "@obsidian-sync/sync-protocol"; + +export interface SyncedFileState { + kind: SyncFileKind; + revisionId: string; + contentHash: string; + lastSyncedContent?: string; + updatedAt: string; + pendingConflict?: boolean; + pendingConflictHash?: string; +} + +export interface SyncState { + serverRevision: number; + files: Record; +} + +export interface ObsidianSyncSettings { + serverUrl: string; + vaultId: string; + deviceId: string; + deviceName: string; + authToken: string; + keyId: string; + exportedVaultKey: string; + syncIntervalMinutes: number; + pullBatchSize: number; + pushBatchSize: number; + syncState: SyncState; +} + +export const DEFAULT_SETTINGS: ObsidianSyncSettings = { + serverUrl: "http://localhost:8787", + vaultId: "primary-vault", + deviceId: "", + deviceName: "", + authToken: "", + keyId: "", + exportedVaultKey: "", + syncIntervalMinutes: 5, + pullBatchSize: 50, + pushBatchSize: 50, + syncState: { + serverRevision: 0, + files: {} + } +}; diff --git a/apps/obsidian-plugin/src/sync/SyncRunLogger.ts b/apps/obsidian-plugin/src/sync/SyncRunLogger.ts new file mode 100644 index 0000000..4325c97 --- /dev/null +++ b/apps/obsidian-plugin/src/sync/SyncRunLogger.ts @@ -0,0 +1,91 @@ +import type { ClientLogEntry, ClientLogUploadRequest } from "@obsidian-sync/sync-protocol"; + +type LogLevel = ClientLogEntry["level"]; + +function normalizeError(error: unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + + return { + value: String(error) + }; +} + +export class SyncRunLogger { + private readonly runId = globalThis.crypto.randomUUID(); + + private readonly entries: ClientLogEntry[] = []; + + private deviceId: string; + + constructor(deviceId: string) { + this.deviceId = deviceId || "unregistered-device"; + } + + setDeviceId(deviceId: string): void { + if (deviceId) { + this.deviceId = deviceId; + } + } + + getRunId(): string { + return this.runId; + } + + hasEntries(): boolean { + return this.entries.length > 0; + } + + toUploadRequest(vaultId: string): ClientLogUploadRequest { + return { + vaultId, + deviceId: this.deviceId, + runId: this.runId, + entries: [...this.entries] + }; + } + + debug(message: string, context?: Record): void { + this.log("debug", message, context); + } + + info(message: string, context?: Record): void { + this.log("info", message, context); + } + + warn(message: string, context?: Record): void { + this.log("warn", message, context); + } + + error(message: string, error?: unknown, context?: Record): void { + this.log("error", message, { + ...context, + ...(error === undefined ? {} : { error: normalizeError(error) }) + }); + } + + private log(level: LogLevel, message: string, context?: Record): void { + const entry: ClientLogEntry = { + level, + message, + timestamp: new Date().toISOString(), + deviceId: this.deviceId, + context: { + runId: this.runId, + ...(context ?? {}) + } + }; + + this.entries.push(entry); + + const consoleMethod = + level === "debug" ? console.debug : level === "info" ? console.info : level === "warn" ? console.warn : console.error; + + consoleMethod(`[Obsidian Sync][${this.runId}] ${message}`, entry.context ?? {}); + } +} \ No newline at end of file diff --git a/apps/obsidian-plugin/src/sync/SyncService.ts b/apps/obsidian-plugin/src/sync/SyncService.ts new file mode 100644 index 0000000..dd3eb4f --- /dev/null +++ b/apps/obsidian-plugin/src/sync/SyncService.ts @@ -0,0 +1,984 @@ +import { App, Notice, TFile, normalizePath } from "obsidian"; + +import { + computeBinaryHash, + computeTextHash, + createConflictCopyPath, + decryptBytes, + decryptText, + encryptBytes, + encryptText, + generateVaultKey, + importVaultKey, + mergeTextRevisions, + type VaultKeyHandle +} from "@obsidian-sync/sync-engine"; +import { + ClientLogUploadResponseSchema, + ListDevicesResponseSchema, + RecoveryBundleSchema, + RegisterDeviceResponseSchema, + RevokeDeviceResponseSchema, + RotateVaultKeyResponseSchema, + SyncPullResponseSchema, + SyncPushResponseSchema, + type ListDevicesResponse, + type RecoveryBundle, + type SyncConflict, + type SyncFileKind, + type SyncFileRecord, + type SyncPullResponse, + type Tombstone +} from "@obsidian-sync/sync-protocol"; + +import type { ObsidianSyncSettings, SyncedFileState } from "../settings/settings"; +import { SyncRunLogger } from "./SyncRunLogger"; + +type SettingsReader = () => ObsidianSyncSettings; +type SettingsWriter = (settings: ObsidianSyncSettings) => Promise; +type SyncMode = "manual" | "scheduled"; + +const DEFAULT_BATCH_SIZE = 50; +const IGNORED_PATH_PREFIXES = [".obsidian/"]; +const TEXT_FILE_EXTENSIONS = new Set(["md", "txt", "markdown", "canvas", "json", "yaml", "yml", "csv"]); + +interface CollectLocalChangeOptions { + forceAllFiles?: boolean; +} + +interface LocalChangeSet { + files: SyncFileRecord[]; + tombstones: Tombstone[]; + statesByPath: Record; +} + +interface LocalFileSnapshot { + kind: SyncFileKind; + contentHash: string; + updatedAt: string; + sizeBytes: number; + textContent?: string; + binaryContent?: ArrayBuffer; +} + +interface PullSummary { + pulledChanges: number; + finalServerRevision: number; +} + +interface PushSummary { + acceptedServerRevision: number; + acceptedFilePaths: string[]; + acceptedTombstones: string[]; + conflicts: SyncConflict[]; +} + +export interface RotateVaultKeyResult { + recoveryBundle: string; + activeKeyId: string; + rotatedAt: string; + uploadedFiles: number; +} + +export class SyncService { + constructor( + private readonly app: App, + private readonly readSettings: SettingsReader, + private readonly writeSettings: SettingsWriter + ) {} + + async runManualSync(): Promise { + await this.runSync("manual"); + } + + async runScheduledSync(): Promise { + await this.runSync("scheduled"); + } + + async exportRecoveryBundle(): Promise { + await this.ensureVaultKey(); + const settings = this.readSettings(); + return this.serializeRecoveryBundle(settings); + } + + async importRecoveryBundle(serializedBundle: string): Promise { + const parsed = RecoveryBundleSchema.parse(JSON.parse(serializedBundle) as RecoveryBundle); + const nextSettings = this.cloneSettings(); + + nextSettings.serverUrl = parsed.serverUrl; + nextSettings.vaultId = parsed.vaultId; + nextSettings.keyId = parsed.keyId; + nextSettings.exportedVaultKey = parsed.exportedVaultKey; + nextSettings.deviceId = ""; + nextSettings.authToken = ""; + nextSettings.syncState = { + serverRevision: 0, + files: {} + }; + + await this.writeSettings(nextSettings); + } + + async listDevices(): Promise { + await this.ensureRegisteredDevice(); + const settings = this.readSettings(); + return this.postJson( + "/api/devices/list", + { + vaultId: settings.vaultId, + deviceId: settings.deviceId + }, + ListDevicesResponseSchema, + settings.authToken + ); + } + + async revokeDevice(targetDeviceId: string): Promise { + await this.ensureRegisteredDevice(); + const settings = this.readSettings(); + + await this.postJson( + "/api/devices/revoke", + { + vaultId: settings.vaultId, + deviceId: settings.deviceId, + targetDeviceId + }, + RevokeDeviceResponseSchema, + settings.authToken + ); + + if (targetDeviceId === settings.deviceId) { + const nextSettings = this.cloneSettings(); + nextSettings.deviceId = ""; + nextSettings.authToken = ""; + await this.writeSettings(nextSettings); + return true; + } + + return false; + } + + async rotateVaultKey(): Promise { + const diagnostics = new SyncRunLogger(this.readSettings().deviceId); + + try { + const currentVaultKey = await this.ensureVaultKey(); + diagnostics.info("vault-key-ready", { + keyId: currentVaultKey.keyId + }); + + await this.ensureDeviceRegistration(diagnostics); + diagnostics.setDeviceId(this.readSettings().deviceId); + + const generatedKey = await generateVaultKey(); + const localChanges = await this.collectLocalChanges( + { + keyId: generatedKey.keyId, + key: generatedKey.key + }, + diagnostics, + { + forceAllFiles: true + } + ); + + const pushSummary = await this.pushLocalChanges(localChanges, diagnostics); + const settings = this.readSettings(); + const rotated = await this.postJson( + "/api/keys/rotate", + { + vaultId: settings.vaultId, + deviceId: settings.deviceId, + nextKeyId: generatedKey.keyId, + previousKeyId: settings.keyId || undefined + }, + RotateVaultKeyResponseSchema, + settings.authToken + ); + + const nextSettings = this.cloneSettings(); + nextSettings.keyId = generatedKey.keyId; + nextSettings.exportedVaultKey = generatedKey.exportedKey; + nextSettings.syncState.serverRevision = Math.max(nextSettings.syncState.serverRevision, pushSummary.acceptedServerRevision); + await this.writeSettings(nextSettings); + + diagnostics.info("vault-key-rotated", { + activeKeyId: rotated.activeKeyId, + rotatedAt: rotated.rotatedAt, + uploadedFiles: pushSummary.acceptedFilePaths.length + }); + + return { + recoveryBundle: this.serializeRecoveryBundle(nextSettings), + activeKeyId: rotated.activeKeyId, + rotatedAt: rotated.rotatedAt, + uploadedFiles: pushSummary.acceptedFilePaths.length + }; + } catch (error) { + diagnostics.error("vault-key-rotation-failed", error); + throw error; + } finally { + await this.uploadDiagnostics(diagnostics); + } + } + + private async runSync(mode: SyncMode): Promise { + const settings = this.readSettings(); + const diagnostics = new SyncRunLogger(settings.deviceId); + if (!settings.serverUrl || !settings.vaultId) { + if (mode === "manual") { + new Notice("Set a server URL and vault ID before syncing."); + } + return; + } + + diagnostics.info("sync-started", { + mode, + serverRevision: settings.syncState.serverRevision + }); + + try { + const vaultKey = await this.ensureVaultKey(); + diagnostics.info("vault-key-ready", { + keyId: vaultKey.keyId + }); + + await this.ensureDeviceRegistration(diagnostics); + diagnostics.setDeviceId(this.readSettings().deviceId); + + const pullSummary = await this.pullAndApplyRemoteChanges(vaultKey, diagnostics); + const localChanges = await this.collectLocalChanges(vaultKey, diagnostics); + const pushSummary = await this.pushLocalChanges(localChanges, diagnostics); + + const nextSettings = this.cloneSettings(); + nextSettings.syncState.serverRevision = Math.max(pullSummary.finalServerRevision, pushSummary.acceptedServerRevision); + await this.writeSettings(nextSettings); + + if (mode === "manual") { + new Notice( + `Sync complete. Pulled ${pullSummary.pulledChanges} changes, uploaded ${pushSummary.acceptedFilePaths.length} files.` + ); + } + + if (pushSummary.conflicts.length > 0) { + diagnostics.warn("server-conflicts-reported", { + count: pushSummary.conflicts.length, + paths: pushSummary.conflicts.map((conflict) => conflict.path) + }); + new Notice(`${pushSummary.conflicts.length} server conflicts need review.`); + } + } catch (error) { + diagnostics.error("sync-failed", error, { + mode + }); + console.error("Obsidian Sync failed", error); + new Notice( + error instanceof Error + ? `Sync failed: ${error.message}. Run ${diagnostics.getRunId()}.` + : `Sync failed. Run ${diagnostics.getRunId()}.` + ); + } finally { + await this.uploadDiagnostics(diagnostics); + } + } + + private cloneSettings(): ObsidianSyncSettings { + const current = this.readSettings(); + return { + ...current, + syncState: { + ...current.syncState, + files: { ...current.syncState.files } + } + }; + } + + private getBaseUrl(): string { + return this.readSettings().serverUrl.replace(/\/$/, ""); + } + + private getPullBatchSize(): number { + return Math.max(1, this.readSettings().pullBatchSize || DEFAULT_BATCH_SIZE); + } + + private getPushBatchSize(): number { + return Math.max(1, this.readSettings().pushBatchSize || DEFAULT_BATCH_SIZE); + } + + private getSyncableFiles(): TFile[] { + return this.app.vault.getFiles().filter((file) => !IGNORED_PATH_PREFIXES.some((prefix) => file.path.startsWith(prefix))); + } + + private resolveFileKind(file: TFile, previous?: SyncedFileState): SyncFileKind { + if (previous?.kind) { + return previous.kind; + } + + return TEXT_FILE_EXTENSIONS.has(file.extension.toLowerCase()) ? "text" : "binary"; + } + + private createSyncedState( + kind: SyncFileKind, + revisionId: string, + contentHash: string, + updatedAt: string, + lastSyncedContent?: string + ): SyncedFileState { + return { + kind, + revisionId, + contentHash, + updatedAt, + ...(kind === "text" && lastSyncedContent !== undefined ? { lastSyncedContent } : {}) + }; + } + + private buildRecoveryBundle(settings: ObsidianSyncSettings): RecoveryBundle { + return RecoveryBundleSchema.parse({ + version: 1, + serverUrl: settings.serverUrl, + vaultId: settings.vaultId, + keyId: settings.keyId, + exportedVaultKey: settings.exportedVaultKey, + generatedAt: new Date().toISOString() + }); + } + + private serializeRecoveryBundle(settings: ObsidianSyncSettings): string { + return JSON.stringify(this.buildRecoveryBundle(settings), null, 2); + } + + private async readLocalFileSnapshot(file: TFile, previous?: SyncedFileState): Promise { + const kind = this.resolveFileKind(file, previous); + + if (kind === "text") { + const textContent = await this.app.vault.cachedRead(file); + return { + kind, + textContent, + contentHash: await computeTextHash(textContent), + updatedAt: new Date(file.stat.mtime).toISOString(), + sizeBytes: new TextEncoder().encode(textContent).byteLength + }; + } + + const binaryContent = await this.app.vault.readBinary(file); + return { + kind, + binaryContent, + contentHash: await computeBinaryHash(binaryContent), + updatedAt: new Date(file.stat.mtime).toISOString(), + sizeBytes: file.stat.size + }; + } + + private async ensureVaultKey(): Promise { + const settings = this.readSettings(); + if (settings.exportedVaultKey && settings.keyId) { + return importVaultKey(settings.exportedVaultKey, settings.keyId); + } + + const nextSettings = this.cloneSettings(); + const generated = await generateVaultKey(); + nextSettings.keyId = generated.keyId; + nextSettings.exportedVaultKey = generated.exportedKey; + await this.writeSettings(nextSettings); + + return { + keyId: generated.keyId, + key: generated.key + }; + } + + private async ensureRegisteredDevice(): Promise { + await this.ensureDeviceRegistration(new SyncRunLogger(this.readSettings().deviceId)); + } + + private async ensureDeviceRegistration(diagnostics: SyncRunLogger): Promise { + const settings = this.readSettings(); + if (settings.deviceId && settings.authToken) { + return; + } + + const deviceName = settings.deviceName || this.app.vault.getName(); + const response = await this.postJson( + "/api/devices/register", + { + vaultId: settings.vaultId, + deviceName + }, + RegisterDeviceResponseSchema + ); + + diagnostics.info("device-registered", { + deviceId: response.deviceId, + deviceName + }); + + const nextSettings = this.cloneSettings(); + nextSettings.deviceId = response.deviceId; + nextSettings.authToken = response.token; + nextSettings.deviceName = deviceName; + await this.writeSettings(nextSettings); + } + + private ensureCompatibleVaultKey(pullResponse: SyncPullResponse): void { + const settings = this.readSettings(); + if (pullResponse.activeKeyId && settings.keyId && pullResponse.activeKeyId !== settings.keyId) { + throw new Error("The server is using a different vault key. Import the latest recovery bundle before syncing."); + } + } + + private async pullRemoteChangesPage(sinceServerRevision: number): Promise { + const settings = this.readSettings(); + return this.postJson( + "/api/sync/pull", + { + vaultId: settings.vaultId, + deviceId: settings.deviceId, + sinceServerRevision, + limit: this.getPullBatchSize() + }, + SyncPullResponseSchema, + settings.authToken + ); + } + + private async pullAndApplyRemoteChanges(vaultKey: VaultKeyHandle, diagnostics: SyncRunLogger): Promise { + let pulledChanges = 0; + let nextSinceServerRevision = this.readSettings().syncState.serverRevision; + + while (true) { + const pullResponse = await this.pullRemoteChangesPage(nextSinceServerRevision); + this.ensureCompatibleVaultKey(pullResponse); + + if (pullResponse.changes.length === 0) { + diagnostics.info("remote-pull-finished", { + changes: pulledChanges, + serverRevision: pullResponse.serverRevision, + hasMore: pullResponse.hasMore + }); + break; + } + + await this.applyRemoteChanges(pullResponse, vaultKey, diagnostics); + pulledChanges += pullResponse.changes.length; + nextSinceServerRevision = pullResponse.nextSinceServerRevision; + + diagnostics.info("remote-pull-page-applied", { + pageChanges: pullResponse.changes.length, + nextSinceServerRevision, + hasMore: pullResponse.hasMore + }); + + if (!pullResponse.hasMore) { + break; + } + } + + return { + pulledChanges, + finalServerRevision: this.readSettings().syncState.serverRevision + }; + } + + private async pushLocalChanges(localChanges: LocalChangeSet, diagnostics: SyncRunLogger): Promise { + const settings = this.readSettings(); + if (localChanges.files.length === 0 && localChanges.tombstones.length === 0) { + return { + acceptedServerRevision: settings.syncState.serverRevision, + acceptedFilePaths: [], + acceptedTombstones: [], + conflicts: [] + }; + } + + const acceptedFilePaths: string[] = []; + const acceptedTombstones: string[] = []; + const conflicts: SyncConflict[] = []; + let acceptedServerRevision = settings.syncState.serverRevision; + let batchNumber = 0; + const fileBatches = this.chunkEntries(localChanges.files, this.getPushBatchSize()); + const tombstoneBatches = this.chunkEntries(localChanges.tombstones, this.getPushBatchSize()); + + for (const fileBatch of fileBatches) { + batchNumber += 1; + const response = await this.pushBatch(fileBatch, [], acceptedServerRevision); + await this.reconcileAcceptedLocalChanges(localChanges.statesByPath, response.acceptedFilePaths, response.acceptedTombstones); + acceptedServerRevision = Math.max(acceptedServerRevision, response.acceptedServerRevision); + acceptedFilePaths.push(...response.acceptedFilePaths); + acceptedTombstones.push(...response.acceptedTombstones); + conflicts.push(...response.conflicts); + await this.persistAcceptedServerRevision(acceptedServerRevision); + + diagnostics.info("push-batch-finished", { + batchNumber, + batchKind: "files", + attempted: fileBatch.length, + accepted: response.acceptedFilePaths.length, + conflicts: response.conflicts.length + }); + } + + for (const tombstoneBatch of tombstoneBatches) { + batchNumber += 1; + const response = await this.pushBatch([], tombstoneBatch, acceptedServerRevision); + await this.reconcileAcceptedLocalChanges(localChanges.statesByPath, response.acceptedFilePaths, response.acceptedTombstones); + acceptedServerRevision = Math.max(acceptedServerRevision, response.acceptedServerRevision); + acceptedFilePaths.push(...response.acceptedFilePaths); + acceptedTombstones.push(...response.acceptedTombstones); + conflicts.push(...response.conflicts); + await this.persistAcceptedServerRevision(acceptedServerRevision); + + diagnostics.info("push-batch-finished", { + batchNumber, + batchKind: "tombstones", + attempted: tombstoneBatch.length, + accepted: response.acceptedTombstones.length, + conflicts: response.conflicts.length + }); + } + + diagnostics.info("remote-push-finished", { + acceptedFiles: acceptedFilePaths.length, + acceptedDeletes: acceptedTombstones.length, + conflicts: conflicts.length + }); + + return { + acceptedServerRevision, + acceptedFilePaths, + acceptedTombstones, + conflicts + }; + } + + private async pushBatch( + files: SyncFileRecord[], + tombstones: Tombstone[], + knownServerRevision: number + ): Promise> { + const settings = this.readSettings(); + return this.postJson( + "/api/sync/push", + { + vaultId: settings.vaultId, + deviceId: settings.deviceId, + knownServerRevision, + files, + tombstones + }, + SyncPushResponseSchema, + settings.authToken + ); + } + + private chunkEntries(entries: TValue[], batchSize: number): TValue[][] { + if (entries.length === 0) { + return []; + } + + const batches: TValue[][] = []; + for (let index = 0; index < entries.length; index += batchSize) { + batches.push(entries.slice(index, index + batchSize)); + } + + return batches; + } + + private async collectLocalChanges( + vaultKey: VaultKeyHandle, + diagnostics: SyncRunLogger, + options: CollectLocalChangeOptions = {} + ): Promise { + const settings = this.readSettings(); + const files: SyncFileRecord[] = []; + const tombstones: Tombstone[] = []; + const statesByPath: Record = {}; + const seenPaths = new Set(); + + for (const file of this.getSyncableFiles()) { + const previous = settings.syncState.files[file.path]; + const snapshot = await this.readLocalFileSnapshot(file, previous); + seenPaths.add(file.path); + + if (previous?.pendingConflict && previous.pendingConflictHash === snapshot.contentHash) { + diagnostics.debug("skipped-pending-conflict-file", { + path: file.path, + kind: snapshot.kind + }); + continue; + } + + if (!options.forceAllFiles && previous && previous.contentHash === snapshot.contentHash) { + continue; + } + + const envelope = + snapshot.kind === "text" + ? await encryptText(snapshot.textContent ?? "", vaultKey) + : await encryptBytes(snapshot.binaryContent ?? new ArrayBuffer(0), vaultKey); + const revisionId = globalThis.crypto.randomUUID(); + + files.push({ + manifest: { + path: file.path, + kind: snapshot.kind, + contentHash: snapshot.contentHash, + revisionId, + baseRevisionId: previous?.revisionId, + updatedAt: snapshot.updatedAt, + sizeBytes: snapshot.sizeBytes, + deviceId: settings.deviceId + }, + envelope + }); + + statesByPath[file.path] = { + kind: snapshot.kind, + revisionId, + contentHash: snapshot.contentHash, + updatedAt: snapshot.updatedAt, + ...(snapshot.kind === "text" ? { lastSyncedContent: snapshot.textContent } : {}) + }; + } + + for (const [path, state] of Object.entries(settings.syncState.files)) { + if (seenPaths.has(path)) { + continue; + } + + tombstones.push({ + path, + revisionId: globalThis.crypto.randomUUID(), + baseRevisionId: state.revisionId, + deletedAt: new Date().toISOString(), + deviceId: settings.deviceId + }); + } + + diagnostics.info("local-scan-finished", { + stagedFiles: files.length, + stagedDeletes: tombstones.length, + forceAllFiles: Boolean(options.forceAllFiles) + }); + + return { + files, + tombstones, + statesByPath + }; + } + + private async persistAcceptedServerRevision(serverRevision: number): Promise { + const nextSettings = this.cloneSettings(); + nextSettings.syncState.serverRevision = Math.max(nextSettings.syncState.serverRevision, serverRevision); + await this.writeSettings(nextSettings); + } + + private async reconcileAcceptedLocalChanges( + statesByPath: Record, + acceptedFilePaths: string[], + acceptedTombstones: string[] + ): Promise { + if (acceptedFilePaths.length === 0 && acceptedTombstones.length === 0) { + return; + } + + const nextSettings = this.cloneSettings(); + + for (const path of acceptedFilePaths) { + const state = statesByPath[path]; + if (state) { + nextSettings.syncState.files[path] = state; + } + } + + for (const path of acceptedTombstones) { + delete nextSettings.syncState.files[path]; + } + + await this.writeSettings(nextSettings); + } + + private async applyRemoteChanges( + pullResponse: SyncPullResponse, + vaultKey: VaultKeyHandle, + diagnostics: SyncRunLogger + ): Promise { + if (pullResponse.changes.length === 0) { + return; + } + + const nextSettings = this.cloneSettings(); + + for (const change of pullResponse.changes) { + if (change.file) { + const remotePath = normalizePath(change.file.manifest.path); + const localAbstract = this.app.vault.getAbstractFileByPath(remotePath); + const previous = nextSettings.syncState.files[remotePath]; + + if (change.file.manifest.kind === "text") { + const remoteContent = await decryptText(change.file.envelope, vaultKey); + + if (!(localAbstract instanceof TFile)) { + await this.ensureParentFolders(remotePath); + await this.app.vault.create(remotePath, remoteContent); + nextSettings.syncState.files[remotePath] = this.createSyncedState( + "text", + change.file.manifest.revisionId, + change.file.manifest.contentHash, + change.file.manifest.updatedAt, + remoteContent + ); + diagnostics.info("remote-text-created", { + path: remotePath + }); + continue; + } + + const localContent = await this.app.vault.cachedRead(localAbstract); + const localHash = await computeTextHash(localContent); + + if (localHash === change.file.manifest.contentHash) { + nextSettings.syncState.files[remotePath] = this.createSyncedState( + "text", + change.file.manifest.revisionId, + change.file.manifest.contentHash, + change.file.manifest.updatedAt, + remoteContent + ); + continue; + } + + if (!previous || localHash === previous.contentHash || localContent === previous.lastSyncedContent) { + await this.app.vault.modify(localAbstract, remoteContent); + nextSettings.syncState.files[remotePath] = this.createSyncedState( + "text", + change.file.manifest.revisionId, + change.file.manifest.contentHash, + change.file.manifest.updatedAt, + remoteContent + ); + diagnostics.info("remote-text-applied", { + path: remotePath + }); + continue; + } + + const merged = mergeTextRevisions({ + base: previous.lastSyncedContent, + local: localContent, + remote: remoteContent + }); + + await this.app.vault.modify(localAbstract, merged.content); + + if (merged.status === "conflict") { + const conflictCopyPath = createConflictCopyPath(remotePath); + await this.ensureParentFolders(conflictCopyPath); + await this.writeTextFile(conflictCopyPath, remoteContent); + + nextSettings.syncState.files[remotePath] = { + ...this.createSyncedState( + "text", + change.file.manifest.revisionId, + change.file.manifest.contentHash, + change.file.manifest.updatedAt, + remoteContent + ), + pendingConflict: true, + pendingConflictHash: await computeTextHash(merged.content) + }; + + diagnostics.warn("remote-text-conflict", { + path: remotePath, + conflictCopyPath + }); + new Notice(`Conflict markers added to ${remotePath}. Review the file before the next sync.`); + continue; + } + + nextSettings.syncState.files[remotePath] = this.createSyncedState( + "text", + change.file.manifest.revisionId, + change.file.manifest.contentHash, + change.file.manifest.updatedAt, + remoteContent + ); + continue; + } + + const remoteBinary = await decryptBytes(change.file.envelope, vaultKey); + + if (!(localAbstract instanceof TFile)) { + await this.ensureParentFolders(remotePath); + await this.app.vault.createBinary(remotePath, remoteBinary); + nextSettings.syncState.files[remotePath] = this.createSyncedState( + "binary", + change.file.manifest.revisionId, + change.file.manifest.contentHash, + change.file.manifest.updatedAt + ); + diagnostics.info("remote-binary-created", { + path: remotePath + }); + continue; + } + + const localBinary = await this.app.vault.readBinary(localAbstract); + const localHash = await computeBinaryHash(localBinary); + + if (localHash === change.file.manifest.contentHash) { + nextSettings.syncState.files[remotePath] = this.createSyncedState( + "binary", + change.file.manifest.revisionId, + change.file.manifest.contentHash, + change.file.manifest.updatedAt + ); + continue; + } + + if (!previous || localHash === previous.contentHash) { + await this.app.vault.modifyBinary(localAbstract, remoteBinary); + nextSettings.syncState.files[remotePath] = this.createSyncedState( + "binary", + change.file.manifest.revisionId, + change.file.manifest.contentHash, + change.file.manifest.updatedAt + ); + diagnostics.info("remote-binary-applied", { + path: remotePath + }); + continue; + } + + const conflictCopyPath = createConflictCopyPath(remotePath); + await this.ensureParentFolders(conflictCopyPath); + await this.writeBinaryFile(conflictCopyPath, remoteBinary); + + nextSettings.syncState.files[remotePath] = { + ...this.createSyncedState( + "binary", + change.file.manifest.revisionId, + change.file.manifest.contentHash, + change.file.manifest.updatedAt + ), + pendingConflict: true, + pendingConflictHash: localHash + }; + + diagnostics.warn("remote-binary-conflict", { + path: remotePath, + conflictCopyPath + }); + new Notice(`Attachment conflict detected for ${remotePath}. Review the remote conflict copy before the next sync.`); + } + + if (change.tombstone) { + const localAbstract = this.app.vault.getAbstractFileByPath(change.tombstone.path); + const previous = nextSettings.syncState.files[change.tombstone.path]; + + if (!(localAbstract instanceof TFile)) { + delete nextSettings.syncState.files[change.tombstone.path]; + continue; + } + + const snapshot = await this.readLocalFileSnapshot(localAbstract, previous); + if (!previous || snapshot.contentHash === previous.contentHash) { + await this.app.vault.delete(localAbstract, true); + delete nextSettings.syncState.files[change.tombstone.path]; + diagnostics.info("remote-delete-applied", { + path: change.tombstone.path + }); + continue; + } + + diagnostics.warn("remote-delete-skipped", { + path: change.tombstone.path + }); + new Notice(`Skipped remote delete for ${change.tombstone.path} because local changes still exist.`); + } + } + + nextSettings.syncState.serverRevision = pullResponse.nextSinceServerRevision; + await this.writeSettings(nextSettings); + } + + private async ensureParentFolders(path: string): Promise { + const segments = normalizePath(path).split("/"); + segments.pop(); + + let currentPath = ""; + for (const segment of segments) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + if (!this.app.vault.getAbstractFileByPath(currentPath)) { + await this.app.vault.createFolder(currentPath); + } + } + } + + private async writeTextFile(path: string, content: string): Promise { + const existing = this.app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await this.app.vault.modify(existing, content); + return; + } + + await this.app.vault.create(path, content); + } + + private async writeBinaryFile(path: string, content: ArrayBuffer): Promise { + const existing = this.app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await this.app.vault.modifyBinary(existing, content); + return; + } + + await this.app.vault.createBinary(path, content); + } + + private async uploadDiagnostics(diagnostics: SyncRunLogger): Promise { + const settings = this.readSettings(); + diagnostics.setDeviceId(settings.deviceId); + + if (!diagnostics.hasEntries() || !settings.deviceId || !settings.authToken) { + return; + } + + try { + await this.postJson( + "/api/logs", + diagnostics.toUploadRequest(settings.vaultId), + ClientLogUploadResponseSchema, + settings.authToken + ); + } catch (error) { + console.warn("Failed to upload sync diagnostics", error); + } + } + + private async postJson( + path: string, + body: unknown, + schema: { parse: (value: unknown) => TSchema }, + authToken?: string + ): Promise { + const response = await fetch(`${this.getBaseUrl()}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(authToken ? { authorization: `Bearer ${authToken}` } : {}) + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + const json = (await response.json()) as unknown; + return schema.parse(json); + } +} diff --git a/apps/obsidian-plugin/tsconfig.json b/apps/obsidian-plugin/tsconfig.json new file mode 100644 index 0000000..faa8e39 --- /dev/null +++ b/apps/obsidian-plugin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "module": "CommonJS" + }, + "references": [ + { + "path": "../../packages/sync-protocol" + }, + { + "path": "../../packages/sync-engine" + } + ], + "include": [ + "src/**/*.ts" + ] +} diff --git a/apps/sync-server/package.json b/apps/sync-server/package.json new file mode 100644 index 0000000..661542b --- /dev/null +++ b/apps/sync-server/package.json @@ -0,0 +1,23 @@ +{ + "name": "@obsidian-sync/sync-server", + "private": true, + "version": "0.1.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "@obsidian-sync/sync-protocol": "0.1.0", + "cors": "^2.8.5", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "tsx": "^4.19.3" + } +} diff --git a/apps/sync-server/src/index.ts b/apps/sync-server/src/index.ts new file mode 100644 index 0000000..a136dd9 --- /dev/null +++ b/apps/sync-server/src/index.ts @@ -0,0 +1,38 @@ +import path from "node:path"; + +import cors from "cors"; +import express from "express"; + +import { createJsonFileLogger } from "./logging/jsonFileLogger"; +import { createRequestLoggingMiddleware } from "./middleware/requestLoggingMiddleware"; +import { createSyncRouter } from "./routes/createSyncRouter"; +import { createFileSyncStore } from "./store/fileSyncStore"; + +const port = Number(process.env.PORT ?? 8787); +const dataDirectory = process.env.SYNC_DATA_DIR + ? path.resolve(process.env.SYNC_DATA_DIR) + : path.resolve(process.cwd(), "data"); +const app = express(); +const logger = createJsonFileLogger(path.join(dataDirectory, "logs", "server.jsonl")); +const store = createFileSyncStore(path.join(dataDirectory, "sync-state.json")); + +app.use(cors()); +app.use(express.json({ limit: "10mb" })); +app.use(createRequestLoggingMiddleware(logger)); + +app.get("/health", (_request, response) => { + response.json({ + status: "ok", + dataDirectory + }); +}); + +app.use("/api", createSyncRouter(store, logger)); + +app.listen(port, () => { + logger.info("server-started", { + port, + dataDirectory + }); + console.log(`Obsidian Sync server listening on http://localhost:${port}`); +}); diff --git a/apps/sync-server/src/logging/jsonFileLogger.ts b/apps/sync-server/src/logging/jsonFileLogger.ts new file mode 100644 index 0000000..91aa420 --- /dev/null +++ b/apps/sync-server/src/logging/jsonFileLogger.ts @@ -0,0 +1,45 @@ +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export interface StructuredLogger { + log(level: LogLevel, message: string, context?: Record): void; + debug(message: string, context?: Record): void; + info(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, context?: Record): void; +} + +export function createJsonFileLogger(logFilePath: string): StructuredLogger { + mkdirSync(dirname(logFilePath), { recursive: true }); + + const write = (level: LogLevel, message: string, context?: Record) => { + const payload = { + timestamp: new Date().toISOString(), + level, + message, + context: context ?? {} + }; + + appendFileSync(logFilePath, `${JSON.stringify(payload)}\n`, "utf8"); + }; + + return { + log(level, message, context) { + write(level, message, context); + }, + debug(message, context) { + write("debug", message, context); + }, + info(message, context) { + write("info", message, context); + }, + warn(message, context) { + write("warn", message, context); + }, + error(message, context) { + write("error", message, context); + } + }; +} diff --git a/apps/sync-server/src/middleware/requestLoggingMiddleware.ts b/apps/sync-server/src/middleware/requestLoggingMiddleware.ts new file mode 100644 index 0000000..870052e --- /dev/null +++ b/apps/sync-server/src/middleware/requestLoggingMiddleware.ts @@ -0,0 +1,26 @@ +import type { Request, Response } from "express"; +import type { NextFunction } from "express"; + +import type { StructuredLogger } from "../logging/jsonFileLogger"; + +export function createRequestLoggingMiddleware(logger: StructuredLogger) { + return (request: Request, response: Response, next: NextFunction) => { + const requestId = globalThis.crypto.randomUUID(); + const startedAt = Date.now(); + + response.locals.requestId = requestId; + response.setHeader("x-request-id", requestId); + + response.on("finish", () => { + logger.info("http-request", { + requestId, + method: request.method, + path: request.originalUrl, + statusCode: response.statusCode, + durationMs: Date.now() - startedAt + }); + }); + + next(); + }; +} diff --git a/apps/sync-server/src/routes/createSyncRouter.ts b/apps/sync-server/src/routes/createSyncRouter.ts new file mode 100644 index 0000000..5329f49 --- /dev/null +++ b/apps/sync-server/src/routes/createSyncRouter.ts @@ -0,0 +1,206 @@ +import type { Request, Response } from "express"; +import express from "express"; + +import { + type ClientLogUploadRequest, + type ListDevicesRequest, + type RegisterDeviceRequest, + type RevokeDeviceRequest, + type RotateVaultKeyRequest, + type SyncPullRequest, + type SyncPushRequest, + ClientLogUploadRequestSchema, + ClientLogUploadResponseSchema, + ListDevicesRequestSchema, + ListDevicesResponseSchema, + RegisterDeviceRequestSchema, + RevokeDeviceRequestSchema, + RevokeDeviceResponseSchema, + RotateVaultKeyRequestSchema, + RotateVaultKeyResponseSchema, + SyncPullRequestSchema, + SyncPushRequestSchema, + parseWithSchema +} from "@obsidian-sync/sync-protocol"; + +import type { StructuredLogger } from "../logging/jsonFileLogger"; +import type { SyncStore } from "../store/syncStore"; + +function readBearerToken(request: Request): string | undefined { + const authorization = request.header("authorization"); + return authorization?.replace(/^Bearer\s+/i, ""); +} + +function getRequestId(response: Response): string { + return String(response.locals.requestId ?? "unknown-request"); +} + +function unauthorized(request: Request, response: Response, logger: StructuredLogger): void { + logger.warn("request-unauthorized", { + requestId: getRequestId(response), + method: request.method, + path: request.originalUrl + }); + + response.status(401).json({ + error: "Unauthorized" + }); +} + +function badRequest(request: Request, response: Response, logger: StructuredLogger, error: unknown): void { + logger.warn("request-invalid", { + requestId: getRequestId(response), + method: request.method, + path: request.originalUrl, + error: error instanceof Error ? error.message : "Invalid request" + }); + + response.status(400).json({ + error: error instanceof Error ? error.message : "Invalid request" + }); +} + +export function createSyncRouter(store: SyncStore, logger: StructuredLogger): express.Router { + const router = express.Router(); + + router.post("/devices/register", (request: Request, response: Response) => { + try { + const body: RegisterDeviceRequest = parseWithSchema(RegisterDeviceRequestSchema, request.body); + const registered = store.registerDevice(body); + logger.info("device-registered", { + requestId: getRequestId(response), + vaultId: registered.vaultId, + deviceId: registered.deviceId, + deviceName: body.deviceName + }); + response.status(201).json(registered); + } catch (error) { + badRequest(request, response, logger, error); + } + }); + + router.post("/sync/pull", (request: Request, response: Response) => { + try { + const body: SyncPullRequest = parseWithSchema(SyncPullRequestSchema, request.body); + const token = readBearerToken(request); + if (!token || !store.authenticate(body.vaultId, body.deviceId, token)) { + unauthorized(request, response, logger); + return; + } + + response.json(store.pull(body.vaultId, body.sinceServerRevision, body.limit)); + } catch (error) { + badRequest(request, response, logger, error); + } + }); + + router.post("/sync/push", (request: Request, response: Response) => { + try { + const body: SyncPushRequest = parseWithSchema(SyncPushRequestSchema, request.body); + const token = readBearerToken(request); + if (!token || !store.authenticate(body.vaultId, body.deviceId, token)) { + unauthorized(request, response, logger); + return; + } + + response.json(store.push(body)); + } catch (error) { + badRequest(request, response, logger, error); + } + }); + + router.post("/devices/list", (request: Request, response: Response) => { + try { + const body: ListDevicesRequest = parseWithSchema(ListDevicesRequestSchema, request.body); + const token = readBearerToken(request); + if (!token || !store.authenticate(body.vaultId, body.deviceId, token)) { + unauthorized(request, response, logger); + return; + } + + response.json(ListDevicesResponseSchema.parse(store.listDevices(body.vaultId))); + } catch (error) { + badRequest(request, response, logger, error); + } + }); + + router.post("/devices/revoke", (request: Request, response: Response) => { + try { + const body: RevokeDeviceRequest = parseWithSchema(RevokeDeviceRequestSchema, request.body); + const token = readBearerToken(request); + if (!token || !store.authenticate(body.vaultId, body.deviceId, token)) { + unauthorized(request, response, logger); + return; + } + + const revoked = store.revokeDevice(body.vaultId, body.targetDeviceId); + logger.warn("device-revoked", { + requestId: getRequestId(response), + vaultId: body.vaultId, + requestedBy: body.deviceId, + targetDeviceId: body.targetDeviceId, + revokedAt: revoked.revokedAt + }); + response.json(RevokeDeviceResponseSchema.parse(revoked)); + } catch (error) { + badRequest(request, response, logger, error); + } + }); + + router.post("/keys/rotate", (request: Request, response: Response) => { + try { + const body: RotateVaultKeyRequest = parseWithSchema(RotateVaultKeyRequestSchema, request.body); + const token = readBearerToken(request); + if (!token || !store.authenticate(body.vaultId, body.deviceId, token)) { + unauthorized(request, response, logger); + return; + } + + const rotated = store.rotateVaultKey(body.vaultId, body.nextKeyId, body.previousKeyId); + logger.info("vault-key-rotated", { + requestId: getRequestId(response), + vaultId: body.vaultId, + requestedBy: body.deviceId, + activeKeyId: rotated.activeKeyId, + rotatedAt: rotated.rotatedAt + }); + response.json(RotateVaultKeyResponseSchema.parse(rotated)); + } catch (error) { + badRequest(request, response, logger, error); + } + }); + + router.post("/logs", (request: Request, response: Response) => { + try { + const body: ClientLogUploadRequest = parseWithSchema(ClientLogUploadRequestSchema, request.body); + const token = readBearerToken(request); + if (!token || !store.authenticate(body.vaultId, body.deviceId, token)) { + unauthorized(request, response, logger); + return; + } + + for (const entry of body.entries) { + logger.log(entry.level, "client-sync-log", { + requestId: getRequestId(response), + vaultId: body.vaultId, + deviceId: body.deviceId, + runId: body.runId, + clientTimestamp: entry.timestamp, + clientMessage: entry.message, + context: entry.context ?? {} + }); + } + + response.status(202).json( + ClientLogUploadResponseSchema.parse({ + accepted: body.entries.length, + requestId: getRequestId(response) + }) + ); + } catch (error) { + badRequest(request, response, logger, error); + } + }); + + return router; +} diff --git a/apps/sync-server/src/store/fileSyncStore.ts b/apps/sync-server/src/store/fileSyncStore.ts new file mode 100644 index 0000000..974b54a --- /dev/null +++ b/apps/sync-server/src/store/fileSyncStore.ts @@ -0,0 +1,341 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import type { + ListDevicesResponse, + RegisterDeviceRequest, + RegisterDeviceResponse, + RevokeDeviceResponse, + RotateVaultKeyResponse, + SyncChange, + SyncConflict, + SyncFileRecord, + SyncPullResponse, + SyncPushRequest, + SyncPushResponse, + Tombstone +} from "@obsidian-sync/sync-protocol"; + +import type { SyncStore } from "./syncStore"; + +interface VaultDevice { + deviceId: string; + deviceName: string; + token: string; + issuedAt: string; + revokedAt?: string; +} + +interface VaultState { + serverRevision: number; + devices: Map; + files: Map; + tombstones: Map; + changes: SyncChange[]; + activeKeyId?: string; + keyRotatedAt?: string; +} + +interface PersistedVaultState { + serverRevision: number; + devices: Record; + files: Record; + tombstones: Record; + changes: SyncChange[]; + activeKeyId?: string; + keyRotatedAt?: string; +} + +interface PersistedStore { + vaults: Record; +} + +function createVaultState(): VaultState { + return { + serverRevision: 0, + devices: new Map(), + files: new Map(), + tombstones: new Map(), + changes: [] + }; +} + +function mapFromRecord(record: Record | undefined): Map { + return new Map(Object.entries(record ?? {})); +} + +function recordFromMap(map: Map): Record { + return Object.fromEntries(map.entries()); +} + +function loadVaults(filePath: string): Map { + if (!existsSync(filePath)) { + return new Map(); + } + + const raw = readFileSync(filePath, "utf8"); + if (!raw.trim()) { + return new Map(); + } + + const parsed = JSON.parse(raw) as Partial; + const vaults = new Map(); + + for (const [vaultId, vault] of Object.entries(parsed.vaults ?? {})) { + vaults.set(vaultId, { + serverRevision: vault.serverRevision ?? 0, + devices: mapFromRecord(vault.devices), + files: mapFromRecord(vault.files), + tombstones: mapFromRecord(vault.tombstones), + changes: [...(vault.changes ?? [])], + activeKeyId: vault.activeKeyId, + keyRotatedAt: vault.keyRotatedAt + }); + } + + return vaults; +} + +function persistVaults(filePath: string, vaults: Map): void { + mkdirSync(dirname(filePath), { recursive: true }); + + const payload: PersistedStore = { + vaults: Object.fromEntries( + [...vaults.entries()].map(([vaultId, vault]) => [ + vaultId, + { + serverRevision: vault.serverRevision, + devices: recordFromMap(vault.devices), + files: recordFromMap(vault.files), + tombstones: recordFromMap(vault.tombstones), + changes: vault.changes, + activeKeyId: vault.activeKeyId, + keyRotatedAt: vault.keyRotatedAt + } + ]) + ) + }; + + const temporaryPath = `${filePath}.tmp`; + writeFileSync(temporaryPath, JSON.stringify(payload, null, 2), "utf8"); + renameSync(temporaryPath, filePath); +} + +function appendFileChange(vault: VaultState, file: SyncFileRecord): void { + vault.serverRevision += 1; + vault.changes.push({ + serverRevision: vault.serverRevision, + file + }); +} + +function appendTombstoneChange(vault: VaultState, tombstone: Tombstone): void { + vault.serverRevision += 1; + vault.changes.push({ + serverRevision: vault.serverRevision, + tombstone + }); +} + +function detectFileConflict(current: SyncFileRecord | undefined, incoming: SyncFileRecord): SyncConflict | undefined { + if (!current) { + return undefined; + } + + if (current.manifest.contentHash === incoming.manifest.contentHash) { + return undefined; + } + + if (!incoming.manifest.baseRevisionId || incoming.manifest.baseRevisionId !== current.manifest.revisionId) { + return { + path: incoming.manifest.path, + serverRevisionId: current.manifest.revisionId, + clientRevisionId: incoming.manifest.revisionId, + reason: "revision-mismatch" + }; + } + + return undefined; +} + +function detectTombstoneConflict(current: SyncFileRecord | undefined, incoming: Tombstone): SyncConflict | undefined { + if (!current) { + return undefined; + } + + if (!incoming.baseRevisionId || incoming.baseRevisionId !== current.manifest.revisionId) { + return { + path: incoming.path, + serverRevisionId: current.manifest.revisionId, + clientRevisionId: incoming.revisionId, + reason: "revision-mismatch" + }; + } + + return undefined; +} + +export function createFileSyncStore(filePath: string): SyncStore { + const vaults = loadVaults(filePath); + + function getVault(vaultId: string): VaultState { + let vault = vaults.get(vaultId); + if (!vault) { + vault = createVaultState(); + vaults.set(vaultId, vault); + } + + return vault; + } + + function persist(): void { + persistVaults(filePath, vaults); + } + + return { + registerDevice(request: RegisterDeviceRequest): RegisterDeviceResponse { + const vault = getVault(request.vaultId); + const deviceId = globalThis.crypto.randomUUID(); + const token = globalThis.crypto.randomUUID(); + const issuedAt = new Date().toISOString(); + + vault.devices.set(deviceId, { + deviceId, + deviceName: request.deviceName, + token, + issuedAt + }); + + persist(); + + return { + vaultId: request.vaultId, + deviceId, + token, + issuedAt + }; + }, + + authenticate(vaultId: string, deviceId: string, token: string): boolean { + const vault = vaults.get(vaultId); + const device = vault?.devices.get(deviceId); + return Boolean(device && !device.revokedAt && device.token === token); + }, + + pull(vaultId: string, sinceServerRevision: number, limit?: number): SyncPullResponse { + const vault = getVault(vaultId); + const matchingChanges = vault.changes.filter((change) => change.serverRevision > sinceServerRevision); + const page = matchingChanges.slice(0, limit ?? matchingChanges.length); + const nextSinceServerRevision = page.at(-1)?.serverRevision ?? sinceServerRevision; + + return { + serverRevision: vault.serverRevision, + changes: page, + hasMore: matchingChanges.length > page.length, + nextSinceServerRevision, + activeKeyId: vault.activeKeyId, + keyRotatedAt: vault.keyRotatedAt + }; + }, + + push(request: SyncPushRequest): SyncPushResponse { + const vault = getVault(request.vaultId); + const acceptedFilePaths: string[] = []; + const acceptedTombstones: string[] = []; + const conflicts: SyncConflict[] = []; + let acceptedKeyId: string | undefined; + + for (const file of request.files) { + const current = vault.files.get(file.manifest.path); + const conflict = detectFileConflict(current, file); + if (conflict) { + conflicts.push(conflict); + continue; + } + + vault.files.set(file.manifest.path, file); + vault.tombstones.delete(file.manifest.path); + appendFileChange(vault, file); + acceptedFilePaths.push(file.manifest.path); + acceptedKeyId = file.envelope.keyId; + } + + for (const tombstone of request.tombstones) { + const current = vault.files.get(tombstone.path); + const conflict = detectTombstoneConflict(current, tombstone); + if (conflict) { + conflicts.push(conflict); + continue; + } + + vault.files.delete(tombstone.path); + vault.tombstones.set(tombstone.path, tombstone); + appendTombstoneChange(vault, tombstone); + acceptedTombstones.push(tombstone.path); + } + + if (acceptedKeyId && acceptedKeyId !== vault.activeKeyId) { + vault.activeKeyId = acceptedKeyId; + vault.keyRotatedAt = new Date().toISOString(); + } + + if (acceptedFilePaths.length > 0 || acceptedTombstones.length > 0) { + persist(); + } + + return { + acceptedServerRevision: vault.serverRevision, + acceptedFilePaths, + acceptedTombstones, + conflicts, + activeKeyId: vault.activeKeyId, + keyRotatedAt: vault.keyRotatedAt + }; + }, + + listDevices(vaultId: string): ListDevicesResponse { + const vault = getVault(vaultId); + const devices = [...vault.devices.values()].sort((left, right) => left.issuedAt.localeCompare(right.issuedAt)); + return { + devices, + activeKeyId: vault.activeKeyId, + keyRotatedAt: vault.keyRotatedAt + }; + }, + + revokeDevice(vaultId: string, targetDeviceId: string): RevokeDeviceResponse { + const vault = getVault(vaultId); + const device = vault.devices.get(targetDeviceId); + if (!device) { + throw new Error("Device not found."); + } + + if (!device.revokedAt) { + device.revokedAt = new Date().toISOString(); + vault.devices.set(targetDeviceId, device); + persist(); + } + + return { + targetDeviceId, + revokedAt: device.revokedAt + }; + }, + + rotateVaultKey(vaultId: string, nextKeyId: string, previousKeyId?: string): RotateVaultKeyResponse { + const vault = getVault(vaultId); + if (previousKeyId && vault.activeKeyId && previousKeyId !== vault.activeKeyId) { + throw new Error("The provided previous key does not match the current server key."); + } + + vault.activeKeyId = nextKeyId; + vault.keyRotatedAt = new Date().toISOString(); + persist(); + + return { + activeKeyId: nextKeyId, + rotatedAt: vault.keyRotatedAt + }; + } + }; +} diff --git a/apps/sync-server/src/store/syncStore.ts b/apps/sync-server/src/store/syncStore.ts new file mode 100644 index 0000000..372af88 --- /dev/null +++ b/apps/sync-server/src/store/syncStore.ts @@ -0,0 +1,20 @@ +import type { + ListDevicesResponse, + RegisterDeviceRequest, + RegisterDeviceResponse, + RevokeDeviceResponse, + RotateVaultKeyResponse, + SyncPullResponse, + SyncPushRequest, + SyncPushResponse +} from "@obsidian-sync/sync-protocol"; + +export interface SyncStore { + registerDevice(request: RegisterDeviceRequest): RegisterDeviceResponse; + authenticate(vaultId: string, deviceId: string, token: string): boolean; + pull(vaultId: string, sinceServerRevision: number, limit?: number): SyncPullResponse; + push(request: SyncPushRequest): SyncPushResponse; + listDevices(vaultId: string): ListDevicesResponse; + revokeDevice(vaultId: string, targetDeviceId: string): RevokeDeviceResponse; + rotateVaultKey(vaultId: string, nextKeyId: string, previousKeyId?: string): RotateVaultKeyResponse; +} diff --git a/apps/sync-server/tsconfig.json b/apps/sync-server/tsconfig.json new file mode 100644 index 0000000..ab699a9 --- /dev/null +++ b/apps/sync-server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "module": "CommonJS" + }, + "references": [ + { + "path": "../../packages/sync-protocol" + } + ], + "include": [ + "src/**/*.ts" + ] +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..23c7040 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,31 @@ +# Architecture + +## Components + +- `apps/obsidian-plugin`: Obsidian client plugin for local vault scanning, sync orchestration, merge handling, and user notifications. +- `apps/sync-server`: Self-hosted HTTP API for device registration and encrypted revision exchange. +- `packages/sync-protocol`: Shared schemas and request and response types. +- `packages/sync-engine`: Shared merge, hashing, and crypto helpers. + +## Current Data Flow + +1. The plugin registers a device with the server and stores the returned device identity and token. +2. The plugin creates or imports a local vault key. +3. The plugin pulls encrypted remote changes and applies them locally for both text notes and binary attachments. +4. Pull operations are paged so the client can resume from the last applied server revision after an interruption. +5. The plugin scans syncable vault files, hashes changed content, encrypts it, and pushes encrypted revisions in batches. +6. The server stores ciphertext, revision metadata, tombstones, device state, and active key status in a file-backed state store. +7. Each sync run emits client-side diagnostics that can be uploaded to the server and correlated with request logs through request IDs. +8. Recovery bundles package the vault ID, server URL, and current client-side vault key so a new device can join the same encrypted vault. + +## Conflict Policy + +- If only one side changed since the last synced base, accept the changed side. +- If both sides changed, write conflict markers into the local file, create a separate remote conflict copy, and require user review before upload continues. + +## Known Gaps + +- The server storage backend is durable but still file-based rather than relational. +- Key distribution between devices still depends on manually sharing a recovery bundle. +- Full plugin integration tests across multiple Obsidian instances are not implemented yet. + diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..9791880 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,50 @@ +# Deployment + +## Requirements + +- Node.js 22 or newer +- npm 10 or newer + +## Install + +```bash +npm install +``` + +## Build + +```bash +npm run build +``` + +## Run The Sync Server + +```bash +npm run dev:server +``` + +The server listens on `http://localhost:8787` by default. + +## Environment Variables + +- `PORT`: overrides the default HTTP port. +- `SYNC_DATA_DIR`: overrides the default data directory. If omitted, the server writes data under `./data` from the workspace root. + +## Data Written By The Server + +- `data/sync-state.json`: durable encrypted revision metadata, devices, tombstones, and sync history. +- `data/logs/server.jsonl`: structured JSON-line server and client-sync diagnostics. + +## Management Capabilities + +- `POST /api/devices/list`: lists known devices for the vault plus active key status. +- `POST /api/devices/revoke`: revokes a device so it can no longer authenticate. +- `POST /api/keys/rotate`: stores the currently active vault key identifier. +- `POST /api/sync/pull`: supports paged pulls through `sinceServerRevision` and `limit`. + +## Operational Notes + +- The server never needs plaintext note bodies. Clients encrypt before upload and decrypt after download. +- Request logs include a response header named `x-request-id` for correlating client and server failures. +- The current storage backend is file-based. It is durable for a single-node deployment but not yet designed for clustered or high-availability hosting. +- The plugin updates local sync state after each accepted push batch and each applied pull page, which lets interrupted sync runs resume from the last durable checkpoint. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2e6f462 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1723 @@ +{ + "name": "obsidian-sync", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-sync", + "version": "0.1.0", + "workspaces": [ + "apps/*", + "packages/*" + ], + "devDependencies": { + "@types/node": "^22.14.1", + "tsx": "^4.19.3", + "typescript": "^5.8.3" + } + }, + "apps/obsidian-plugin": { + "name": "@obsidian-sync/obsidian-plugin", + "version": "0.1.0", + "dependencies": { + "@obsidian-sync/sync-engine": "0.1.0", + "@obsidian-sync/sync-protocol": "0.1.0" + }, + "devDependencies": { + "obsidian": "^1.8.7" + } + }, + "apps/sync-server": { + "name": "@obsidian-sync/sync-server", + "version": "0.1.0", + "dependencies": { + "@obsidian-sync/sync-protocol": "0.1.0", + "cors": "^2.8.5", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "tsx": "^4.19.3" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@obsidian-sync/obsidian-plugin": { + "resolved": "apps/obsidian-plugin", + "link": true + }, + "node_modules/@obsidian-sync/sync-engine": { + "resolved": "packages/sync-engine", + "link": true + }, + "node_modules/@obsidian-sync/sync-protocol": { + "resolved": "packages/sync-protocol", + "link": true + }, + "node_modules/@obsidian-sync/sync-server": { + "resolved": "apps/sync-server", + "link": true + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/sync-engine": { + "name": "@obsidian-sync/sync-engine", + "version": "0.1.0", + "dependencies": { + "@obsidian-sync/sync-protocol": "0.1.0" + } + }, + "packages/sync-protocol": { + "name": "@obsidian-sync/sync-protocol", + "version": "0.1.0", + "dependencies": { + "zod": "^3.24.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9da6c18 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "obsidian-sync", + "private": true, + "version": "0.1.0", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "build": "tsc -b tsconfig.json", + "test": "tsx --test tests/**/*.test.ts", + "typecheck": "tsc -b tsconfig.json --pretty false", + "dev:server": "npm run dev --workspace @obsidian-sync/sync-server" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "tsx": "^4.19.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/sync-engine/package.json b/packages/sync-engine/package.json new file mode 100644 index 0000000..79740e1 --- /dev/null +++ b/packages/sync-engine/package.json @@ -0,0 +1,14 @@ +{ + "name": "@obsidian-sync/sync-engine", + "private": true, + "version": "0.1.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "@obsidian-sync/sync-protocol": "0.1.0" + } +} diff --git a/packages/sync-engine/src/crypto.ts b/packages/sync-engine/src/crypto.ts new file mode 100644 index 0000000..00f3ed5 --- /dev/null +++ b/packages/sync-engine/src/crypto.ts @@ -0,0 +1,182 @@ +import type { EncryptionEnvelope } from "@obsidian-sync/sync-protocol"; + +export interface VaultKeyHandle { + keyId: string; + key: CryptoKey; +} + +export interface GeneratedVaultKey extends VaultKeyHandle { + exportedKey: string; +} + +const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +function getCryptoApi(): Crypto { + if (!globalThis.crypto?.subtle) { + throw new Error("Web Crypto API is unavailable in this environment."); + } + + return globalThis.crypto; +} + +function bytesToBase64(bytes: Uint8Array): string { + let output = ""; + + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0; + const second = bytes[index + 1] ?? 0; + const third = bytes[index + 2] ?? 0; + const combined = (first << 16) | (second << 8) | third; + + output += BASE64_ALPHABET[(combined >> 18) & 63]; + output += BASE64_ALPHABET[(combined >> 12) & 63]; + output += index + 1 < bytes.length ? BASE64_ALPHABET[(combined >> 6) & 63] : "="; + output += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] : "="; + } + + return output; +} + +function base64ToBytes(value: string): Uint8Array { + const normalized = value.replace(/\s+/g, ""); + const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0; + const outputLength = Math.floor((normalized.length * 3) / 4) - padding; + const bytes = new Uint8Array(outputLength); + + let byteIndex = 0; + for (let index = 0; index < normalized.length; index += 4) { + const first = BASE64_ALPHABET.indexOf(normalized[index] ?? "A"); + const second = BASE64_ALPHABET.indexOf(normalized[index + 1] ?? "A"); + const thirdChar = normalized[index + 2] ?? "="; + const fourthChar = normalized[index + 3] ?? "="; + const third = thirdChar === "=" ? 0 : BASE64_ALPHABET.indexOf(thirdChar); + const fourth = fourthChar === "=" ? 0 : BASE64_ALPHABET.indexOf(fourthChar); + const combined = (first << 18) | (second << 12) | (third << 6) | fourth; + + bytes[byteIndex] = (combined >> 16) & 255; + byteIndex += 1; + + if (thirdChar !== "=" && byteIndex < outputLength + 1) { + bytes[byteIndex] = (combined >> 8) & 255; + byteIndex += 1; + } + + if (fourthChar !== "=" && byteIndex < outputLength + 1) { + bytes[byteIndex] = combined & 255; + byteIndex += 1; + } + } + + return bytes; +} + +function toOwnedBytes(bytes: Uint8Array): Uint8Array { + const buffer = new ArrayBuffer(bytes.byteLength); + const view = new Uint8Array(buffer); + view.set(bytes); + return view; +} + +function normalizeBytes(value: ArrayBuffer | Uint8Array): Uint8Array { + return value instanceof Uint8Array ? toOwnedBytes(value) : toOwnedBytes(new Uint8Array(value)); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return toOwnedBytes(bytes).buffer; +} + +export async function generateVaultKey(keyId = globalThis.crypto.randomUUID()): Promise { + const cryptoApi = getCryptoApi(); + const key = await cryptoApi.subtle.generateKey( + { + name: "AES-GCM", + length: 256 + }, + true, + ["encrypt", "decrypt"] + ); + + const rawKey = await cryptoApi.subtle.exportKey("raw", key); + + return { + keyId, + key, + exportedKey: bytesToBase64(new Uint8Array(rawKey)) + }; +} + +export async function importVaultKey(exportedKey: string, keyId: string): Promise { + const cryptoApi = getCryptoApi(); + const rawKey = base64ToBytes(exportedKey); + const key = await cryptoApi.subtle.importKey( + "raw", + toArrayBuffer(rawKey), + { + name: "AES-GCM", + length: 256 + }, + true, + ["encrypt", "decrypt"] + ); + + return { + keyId, + key + }; +} + +export async function encryptBytes(plaintext: ArrayBuffer | Uint8Array, vaultKey: VaultKeyHandle): Promise { + const cryptoApi = getCryptoApi(); + const iv = cryptoApi.getRandomValues(new Uint8Array(12)); + const ciphertext = await cryptoApi.subtle.encrypt( + { + name: "AES-GCM", + iv + }, + vaultKey.key, + normalizeBytes(plaintext) + ); + + return { + algorithm: "AES-GCM-256", + keyId: vaultKey.keyId, + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)) + }; +} + +export async function decryptBytes(envelope: EncryptionEnvelope, vaultKey: VaultKeyHandle): Promise { + const cryptoApi = getCryptoApi(); + const plaintext = await cryptoApi.subtle.decrypt( + { + name: "AES-GCM", + iv: toOwnedBytes(base64ToBytes(envelope.iv)) + }, + vaultKey.key, + toOwnedBytes(base64ToBytes(envelope.ciphertext)) + ); + + return plaintext.slice(0); +} + +export async function encryptText(plaintext: string, vaultKey: VaultKeyHandle): Promise { + return encryptBytes(new TextEncoder().encode(plaintext), vaultKey); +} + +export async function decryptText(envelope: EncryptionEnvelope, vaultKey: VaultKeyHandle): Promise { + return new TextDecoder().decode(await decryptBytes(envelope, vaultKey)); +} + +async function computeHash(bytes: Uint8Array): Promise { + const cryptoApi = getCryptoApi(); + const digest = await cryptoApi.subtle.digest("SHA-256", toOwnedBytes(bytes)); + return bytesToBase64(new Uint8Array(digest)); +} + +export async function computeTextHash(value: string): Promise { + return computeHash(new TextEncoder().encode(value)); +} + +export async function computeBinaryHash(value: ArrayBuffer | Uint8Array): Promise { + return computeHash(normalizeBytes(value)); +} diff --git a/packages/sync-engine/src/index.ts b/packages/sync-engine/src/index.ts new file mode 100644 index 0000000..2b9b3fd --- /dev/null +++ b/packages/sync-engine/src/index.ts @@ -0,0 +1,2 @@ +export * from "./crypto"; +export * from "./merge"; diff --git a/packages/sync-engine/src/merge.ts b/packages/sync-engine/src/merge.ts new file mode 100644 index 0000000..053a9bf --- /dev/null +++ b/packages/sync-engine/src/merge.ts @@ -0,0 +1,59 @@ +export interface MergeTextInput { + base?: string; + local: string; + remote: string; +} + +export interface MergeTextResult { + status: "merged" | "conflict"; + content: string; + reason?: string; +} + +export function mergeTextRevisions(input: MergeTextInput): MergeTextResult { + const base = input.base ?? ""; + + if (input.local === input.remote) { + return { + status: "merged", + content: input.local + }; + } + + if (base === input.local) { + return { + status: "merged", + content: input.remote + }; + } + + if (base === input.remote) { + return { + status: "merged", + content: input.local + }; + } + + return { + status: "conflict", + reason: "Both local and remote content changed since the last shared base.", + content: [ + "<<<<<<< LOCAL", + input.local, + "=======", + input.remote, + ">>>>>>> REMOTE" + ].join("\n") + }; +} + +export function createConflictCopyPath(path: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const extensionIndex = path.lastIndexOf("."); + + if (extensionIndex <= 0) { + return `${path}.remote-conflict.${timestamp}`; + } + + return `${path.slice(0, extensionIndex)}.remote-conflict.${timestamp}${path.slice(extensionIndex)}`; +} diff --git a/packages/sync-engine/tsconfig.json b/packages/sync-engine/tsconfig.json new file mode 100644 index 0000000..84a75d9 --- /dev/null +++ b/packages/sync-engine/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "module": "CommonJS" + }, + "references": [ + { + "path": "../sync-protocol" + } + ], + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/sync-protocol/package.json b/packages/sync-protocol/package.json new file mode 100644 index 0000000..8fdc1bf --- /dev/null +++ b/packages/sync-protocol/package.json @@ -0,0 +1,14 @@ +{ + "name": "@obsidian-sync/sync-protocol", + "private": true, + "version": "0.1.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "zod": "^3.24.2" + } +} diff --git a/packages/sync-protocol/src/index.ts b/packages/sync-protocol/src/index.ts new file mode 100644 index 0000000..d6553b1 --- /dev/null +++ b/packages/sync-protocol/src/index.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; + +export const SyncFileKindSchema = z.enum(["text", "binary"]); +export type SyncFileKind = z.infer; + +export const EncryptionEnvelopeSchema = z.object({ + algorithm: z.literal("AES-GCM-256"), + keyId: z.string().min(1), + iv: z.string().min(1), + ciphertext: z.string().min(1) +}); +export type EncryptionEnvelope = z.infer; + +export const FileManifestSchema = z.object({ + path: z.string().min(1), + kind: SyncFileKindSchema, + contentHash: z.string().min(1), + revisionId: z.string().min(1), + baseRevisionId: z.string().min(1).optional(), + updatedAt: z.string().datetime(), + sizeBytes: z.number().int().nonnegative(), + deviceId: z.string().min(1) +}); +export type FileManifest = z.infer; + +export const SyncFileRecordSchema = z.object({ + manifest: FileManifestSchema, + envelope: EncryptionEnvelopeSchema +}); +export type SyncFileRecord = z.infer; + +export const TombstoneSchema = z.object({ + path: z.string().min(1), + revisionId: z.string().min(1), + baseRevisionId: z.string().min(1).optional(), + deletedAt: z.string().datetime(), + deviceId: z.string().min(1) +}); +export type Tombstone = z.infer; + +export const SyncConflictSchema = z.object({ + path: z.string().min(1), + serverRevisionId: z.string().min(1), + clientRevisionId: z.string().min(1), + reason: z.enum(["revision-mismatch"]) +}); +export type SyncConflict = z.infer; + +export const SyncChangeSchema = z + .object({ + serverRevision: z.number().int().nonnegative(), + file: SyncFileRecordSchema.optional(), + tombstone: TombstoneSchema.optional() + }) + .refine((value) => Number(Boolean(value.file)) + Number(Boolean(value.tombstone)) === 1, { + message: "A change must include either a file or a tombstone." + }); +export type SyncChange = z.infer; + +export const RegisterDeviceRequestSchema = z.object({ + vaultId: z.string().min(1), + deviceName: z.string().min(1) +}); +export type RegisterDeviceRequest = z.infer; + +export const RegisterDeviceResponseSchema = z.object({ + vaultId: z.string().min(1), + deviceId: z.string().min(1), + token: z.string().min(1), + issuedAt: z.string().datetime() +}); +export type RegisterDeviceResponse = z.infer; + +export const RecoveryBundleSchema = z.object({ + version: z.literal(1), + serverUrl: z.string().min(1), + vaultId: z.string().min(1), + keyId: z.string().min(1), + exportedVaultKey: z.string().min(1), + generatedAt: z.string().datetime() +}); +export type RecoveryBundle = z.infer; + +export const DeviceRecordSchema = z.object({ + deviceId: z.string().min(1), + deviceName: z.string().min(1), + issuedAt: z.string().datetime(), + revokedAt: z.string().datetime().optional() +}); +export type DeviceRecord = z.infer; + +export const ListDevicesRequestSchema = z.object({ + vaultId: z.string().min(1), + deviceId: z.string().min(1) +}); +export type ListDevicesRequest = z.infer; + +export const ListDevicesResponseSchema = z.object({ + devices: z.array(DeviceRecordSchema), + activeKeyId: z.string().min(1).optional(), + keyRotatedAt: z.string().datetime().optional() +}); +export type ListDevicesResponse = z.infer; + +export const RevokeDeviceRequestSchema = z.object({ + vaultId: z.string().min(1), + deviceId: z.string().min(1), + targetDeviceId: z.string().min(1) +}); +export type RevokeDeviceRequest = z.infer; + +export const RevokeDeviceResponseSchema = z.object({ + targetDeviceId: z.string().min(1), + revokedAt: z.string().datetime() +}); +export type RevokeDeviceResponse = z.infer; + +export const RotateVaultKeyRequestSchema = z.object({ + vaultId: z.string().min(1), + deviceId: z.string().min(1), + nextKeyId: z.string().min(1), + previousKeyId: z.string().min(1).optional() +}); +export type RotateVaultKeyRequest = z.infer; + +export const RotateVaultKeyResponseSchema = z.object({ + activeKeyId: z.string().min(1), + rotatedAt: z.string().datetime() +}); +export type RotateVaultKeyResponse = z.infer; + +export const SyncPullRequestSchema = z.object({ + vaultId: z.string().min(1), + deviceId: z.string().min(1), + sinceServerRevision: z.number().int().nonnegative(), + limit: z.number().int().positive().max(500).optional() +}); +export type SyncPullRequest = z.infer; + +export const SyncPullResponseSchema = z.object({ + serverRevision: z.number().int().nonnegative(), + changes: z.array(SyncChangeSchema), + hasMore: z.boolean(), + nextSinceServerRevision: z.number().int().nonnegative(), + activeKeyId: z.string().min(1).optional(), + keyRotatedAt: z.string().datetime().optional() +}); +export type SyncPullResponse = z.infer; + +export const SyncPushRequestSchema = z.object({ + vaultId: z.string().min(1), + deviceId: z.string().min(1), + knownServerRevision: z.number().int().nonnegative(), + files: z.array(SyncFileRecordSchema), + tombstones: z.array(TombstoneSchema) +}); +export type SyncPushRequest = z.infer; + +export const SyncPushResponseSchema = z.object({ + acceptedServerRevision: z.number().int().nonnegative(), + acceptedFilePaths: z.array(z.string()), + acceptedTombstones: z.array(z.string()), + conflicts: z.array(SyncConflictSchema), + activeKeyId: z.string().min(1).optional(), + keyRotatedAt: z.string().datetime().optional() +}); +export type SyncPushResponse = z.infer; + +export const ClientLogEntrySchema = z.object({ + level: z.enum(["debug", "info", "warn", "error"]), + message: z.string().min(1), + timestamp: z.string().datetime(), + deviceId: z.string().min(1), + context: z.record(z.string(), z.unknown()).optional() +}); +export type ClientLogEntry = z.infer; + +export const ClientLogUploadRequestSchema = z.object({ + vaultId: z.string().min(1), + deviceId: z.string().min(1), + runId: z.string().min(1), + entries: z.array(ClientLogEntrySchema) +}); +export type ClientLogUploadRequest = z.infer; + +export const ClientLogUploadResponseSchema = z.object({ + accepted: z.number().int().nonnegative(), + requestId: z.string().min(1) +}); +export type ClientLogUploadResponse = z.infer; + +export function parseWithSchema(schema: z.ZodSchema, value: unknown): T { + return schema.parse(value); +} diff --git a/packages/sync-protocol/tsconfig.json b/packages/sync-protocol/tsconfig.json new file mode 100644 index 0000000..4de6c37 --- /dev/null +++ b/packages/sync-protocol/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "module": "CommonJS" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/tests/contract/sync-engine.test.ts b/tests/contract/sync-engine.test.ts new file mode 100644 index 0000000..1031fa2 --- /dev/null +++ b/tests/contract/sync-engine.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + computeBinaryHash, + decryptBytes, + encryptBytes, + generateVaultKey, + mergeTextRevisions +} from "../../packages/sync-engine/src"; + +test("encryptBytes round-trips binary payloads", async () => { + const vaultKey = await generateVaultKey("test-key"); + const original = new Uint8Array([1, 2, 3, 4, 5]).buffer; + + const encrypted = await encryptBytes(original, vaultKey); + const decrypted = await decryptBytes(encrypted, vaultKey); + + assert.deepEqual([...new Uint8Array(decrypted)], [1, 2, 3, 4, 5]); +}); + +test("computeBinaryHash is stable for identical binary payloads", async () => { + const first = await computeBinaryHash(new Uint8Array([9, 8, 7, 6]).buffer); + const second = await computeBinaryHash(new Uint8Array([9, 8, 7, 6]).buffer); + + assert.equal(first, second); +}); + +test("mergeTextRevisions reports conflicts when both sides diverge", () => { + const result = mergeTextRevisions({ + base: "alpha", + local: "alpha local", + remote: "alpha remote" + }); + + assert.equal(result.status, "conflict"); + assert.match(result.content, /<<<<<<< LOCAL/); + assert.match(result.content, />>>>>>> REMOTE/); +}); diff --git a/tests/e2e/multi-device-store.test.ts b/tests/e2e/multi-device-store.test.ts new file mode 100644 index 0000000..f366101 --- /dev/null +++ b/tests/e2e/multi-device-store.test.ts @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createFileSyncStore } from "../../apps/sync-server/src/store/fileSyncStore"; + +test("file-backed store survives restart and serves another device", () => { + const tempDirectory = mkdtempSync(path.join(tmpdir(), "obsidian-sync-")); + const storePath = path.join(tempDirectory, "sync-state.json"); + + try { + const serverA = createFileSyncStore(storePath); + const deviceA = serverA.registerDevice({ + vaultId: "vault-1", + deviceName: "Desktop" + }); + const deviceB = serverA.registerDevice({ + vaultId: "vault-1", + deviceName: "Phone" + }); + + const pushResponse = serverA.push({ + vaultId: "vault-1", + deviceId: deviceA.deviceId, + knownServerRevision: 0, + files: [ + { + manifest: { + path: "notes/hello.md", + kind: "text", + contentHash: "hash-1", + revisionId: "rev-1", + updatedAt: "2026-04-08T00:00:00.000Z", + sizeBytes: 12, + deviceId: deviceA.deviceId + }, + envelope: { + algorithm: "AES-GCM-256", + keyId: "key-1", + iv: "iv-1", + ciphertext: "cipher-1" + } + } + ], + tombstones: [] + }); + + assert.deepEqual(pushResponse.acceptedFilePaths, ["notes/hello.md"]); + + const serverB = createFileSyncStore(storePath); + assert.equal(serverB.authenticate("vault-1", deviceB.deviceId, deviceB.token), true); + + const pullResponse = serverB.pull("vault-1", 0); + assert.equal(pullResponse.changes.length, 1); + assert.equal(pullResponse.changes[0].file?.manifest.path, "notes/hello.md"); + assert.equal(pullResponse.serverRevision, 1); + assert.equal(pullResponse.hasMore, false); + assert.equal(pullResponse.nextSinceServerRevision, 1); + } finally { + rmSync(tempDirectory, { recursive: true, force: true }); + } +}); + +test("file-backed store paginates pull results", () => { + const tempDirectory = mkdtempSync(path.join(tmpdir(), "obsidian-sync-page-")); + const storePath = path.join(tempDirectory, "sync-state.json"); + + try { + const store = createFileSyncStore(storePath); + const device = store.registerDevice({ + vaultId: "vault-2", + deviceName: "Desktop" + }); + + store.push({ + vaultId: "vault-2", + deviceId: device.deviceId, + knownServerRevision: 0, + files: [ + { + manifest: { + path: "a.md", + kind: "text", + contentHash: "hash-a", + revisionId: "rev-a", + updatedAt: "2026-04-08T00:00:00.000Z", + sizeBytes: 1, + deviceId: device.deviceId + }, + envelope: { + algorithm: "AES-GCM-256", + keyId: "key-a", + iv: "iv-a", + ciphertext: "cipher-a" + } + } + ], + tombstones: [] + }); + + store.push({ + vaultId: "vault-2", + deviceId: device.deviceId, + knownServerRevision: 1, + files: [ + { + manifest: { + path: "b.md", + kind: "text", + contentHash: "hash-b", + revisionId: "rev-b", + updatedAt: "2026-04-08T00:01:00.000Z", + sizeBytes: 1, + deviceId: device.deviceId + }, + envelope: { + algorithm: "AES-GCM-256", + keyId: "key-a", + iv: "iv-b", + ciphertext: "cipher-b" + } + } + ], + tombstones: [] + }); + + const firstPage = store.pull("vault-2", 0, 1); + assert.equal(firstPage.changes.length, 1); + assert.equal(firstPage.hasMore, true); + assert.equal(firstPage.nextSinceServerRevision, 1); + + const secondPage = store.pull("vault-2", firstPage.nextSinceServerRevision, 1); + assert.equal(secondPage.changes.length, 1); + assert.equal(secondPage.hasMore, false); + assert.equal(secondPage.nextSinceServerRevision, 2); + assert.equal(secondPage.changes[0].file?.manifest.path, "b.md"); + } finally { + rmSync(tempDirectory, { recursive: true, force: true }); + } +}); + +test("device revocation and key rotation state are durable", () => { + const tempDirectory = mkdtempSync(path.join(tmpdir(), "obsidian-sync-device-")); + const storePath = path.join(tempDirectory, "sync-state.json"); + + try { + const store = createFileSyncStore(storePath); + const desktop = store.registerDevice({ + vaultId: "vault-3", + deviceName: "Desktop" + }); + const phone = store.registerDevice({ + vaultId: "vault-3", + deviceName: "Phone" + }); + + const rotation = store.rotateVaultKey("vault-3", "key-rotated"); + assert.equal(rotation.activeKeyId, "key-rotated"); + + const revoked = store.revokeDevice("vault-3", phone.deviceId); + assert.equal(revoked.targetDeviceId, phone.deviceId); + assert.equal(store.authenticate("vault-3", phone.deviceId, phone.token), false); + + const restartedStore = createFileSyncStore(storePath); + const devices = restartedStore.listDevices("vault-3"); + assert.equal(devices.activeKeyId, "key-rotated"); + assert.equal(devices.devices.find((device) => device.deviceId === phone.deviceId)?.revokedAt !== undefined, true); + assert.equal(restartedStore.authenticate("vault-3", desktop.deviceId, desktop.token), true); + } finally { + rmSync(tempDirectory, { recursive: true, force: true }); + } +}); \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..e4af076 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "ES2022", + "DOM" + ], + "strict": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@obsidian-sync/sync-protocol": [ + "packages/sync-protocol/src" + ], + "@obsidian-sync/sync-engine": [ + "packages/sync-engine/src" + ] + }, + "moduleResolution": "Node", + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noEmitOnError": true + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..71cdd5c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "files": [], + "references": [ + { + "path": "./packages/sync-protocol" + }, + { + "path": "./packages/sync-engine" + }, + { + "path": "./apps/sync-server" + }, + { + "path": "./apps/obsidian-plugin" + } + ] +}