Initial commit

This commit is contained in:
2026-04-08 11:55:27 +01:00
commit 470a1c15b8
36 changed files with 4932 additions and 0 deletions

View File

@@ -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"
}
}

View File

@@ -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<ArrayBuffer> {
const buffer = new ArrayBuffer(bytes.byteLength);
const view = new Uint8Array(buffer);
view.set(bytes);
return view;
}
function normalizeBytes(value: ArrayBuffer | Uint8Array): Uint8Array<ArrayBuffer> {
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<GeneratedVaultKey> {
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<VaultKeyHandle> {
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<EncryptionEnvelope> {
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<ArrayBuffer> {
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<EncryptionEnvelope> {
return encryptBytes(new TextEncoder().encode(plaintext), vaultKey);
}
export async function decryptText(envelope: EncryptionEnvelope, vaultKey: VaultKeyHandle): Promise<string> {
return new TextDecoder().decode(await decryptBytes(envelope, vaultKey));
}
async function computeHash(bytes: Uint8Array): Promise<string> {
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<string> {
return computeHash(new TextEncoder().encode(value));
}
export async function computeBinaryHash(value: ArrayBuffer | Uint8Array): Promise<string> {
return computeHash(normalizeBytes(value));
}

View File

@@ -0,0 +1,2 @@
export * from "./crypto";
export * from "./merge";

View File

@@ -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)}`;
}

View File

@@ -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"
]
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,194 @@
import { z } from "zod";
export const SyncFileKindSchema = z.enum(["text", "binary"]);
export type SyncFileKind = z.infer<typeof SyncFileKindSchema>;
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<typeof EncryptionEnvelopeSchema>;
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<typeof FileManifestSchema>;
export const SyncFileRecordSchema = z.object({
manifest: FileManifestSchema,
envelope: EncryptionEnvelopeSchema
});
export type SyncFileRecord = z.infer<typeof SyncFileRecordSchema>;
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<typeof TombstoneSchema>;
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<typeof SyncConflictSchema>;
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<typeof SyncChangeSchema>;
export const RegisterDeviceRequestSchema = z.object({
vaultId: z.string().min(1),
deviceName: z.string().min(1)
});
export type RegisterDeviceRequest = z.infer<typeof RegisterDeviceRequestSchema>;
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<typeof RegisterDeviceResponseSchema>;
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<typeof RecoveryBundleSchema>;
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<typeof DeviceRecordSchema>;
export const ListDevicesRequestSchema = z.object({
vaultId: z.string().min(1),
deviceId: z.string().min(1)
});
export type ListDevicesRequest = z.infer<typeof ListDevicesRequestSchema>;
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<typeof ListDevicesResponseSchema>;
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<typeof RevokeDeviceRequestSchema>;
export const RevokeDeviceResponseSchema = z.object({
targetDeviceId: z.string().min(1),
revokedAt: z.string().datetime()
});
export type RevokeDeviceResponse = z.infer<typeof RevokeDeviceResponseSchema>;
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<typeof RotateVaultKeyRequestSchema>;
export const RotateVaultKeyResponseSchema = z.object({
activeKeyId: z.string().min(1),
rotatedAt: z.string().datetime()
});
export type RotateVaultKeyResponse = z.infer<typeof RotateVaultKeyResponseSchema>;
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<typeof SyncPullRequestSchema>;
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<typeof SyncPullResponseSchema>;
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<typeof SyncPushRequestSchema>;
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<typeof SyncPushResponseSchema>;
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<typeof ClientLogEntrySchema>;
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<typeof ClientLogUploadRequestSchema>;
export const ClientLogUploadResponseSchema = z.object({
accepted: z.number().int().nonnegative(),
requestId: z.string().min(1)
});
export type ClientLogUploadResponse = z.infer<typeof ClientLogUploadResponseSchema>;
export function parseWithSchema<T>(schema: z.ZodSchema<T>, value: unknown): T {
return schema.parse(value);
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"module": "CommonJS"
},
"include": [
"src/**/*.ts"
]
}