From 5da5b1e41e66468e135633ecb25453c5b4a33e7e Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:20:31 -0600 Subject: [PATCH 01/32] wip --- package.json | 2 +- pnpm-lock.yaml | 24 +- .../components/codemirror/Codemirror.svelte | 11 +- src/lib/components/codemirror/Editor.svelte | 70 +++-- src/lib/loro.ts | 270 +++++------------- src/lib/remote/notes.schemas.ts | 2 +- src/lib/remote/sync.remote.ts | 6 +- src/lib/server/real-time.ts | 4 +- src/routes/api/sync/[noteId]/+server.ts | 10 +- src/routes/notes/[id]/+page.svelte | 71 ++--- 10 files changed, 157 insertions(+), 313 deletions(-) diff --git a/package.json b/package.json index eb07953..fbc690a 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,8 @@ "daisyui": "5.5.5", "dotenv": "^17.2.3", "effect": "^3.19.6", - "fast-diff": "^1.3.0", "katex": "^0.16.25", + "loro-codemirror": "^0.3.3", "loro-crdt": "^1.10.0", "svelte": "^5.44.0", "tiptap-markdown": "^0.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf2bbfc..b9b6e78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,12 +131,12 @@ importers: effect: specifier: ^3.19.6 version: 3.19.6 - fast-diff: - specifier: ^1.3.0 - version: 1.3.0 katex: specifier: ^0.16.25 version: 0.16.25 + loro-codemirror: + specifier: ^0.3.3 + version: 0.3.3(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(loro-crdt@1.10.0) loro-crdt: specifier: ^1.10.0 version: 1.10.0 @@ -2161,9 +2161,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2495,6 +2492,13 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loro-codemirror@0.3.3: + resolution: {integrity: sha512-C6qAUmDjMTyoXVeDxKWixvr/TSTo/jXFrvgGW+wn6RAHeIzNFovBDLQNi/Sary4Ahx8DaYADTCm9eCaRAjvWtw==} + peerDependencies: + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.7.0 + loro-crdt: ^1.8.2 + loro-crdt@1.10.0: resolution: {integrity: sha512-Fms27q9IaDANUe5OACQL6qLMhJasMXzjRkyK+NAIiPQXGBK2VAp6C7pAr9fzuKbL71YyDgA4Pv69RGwiScWSPg==} @@ -5418,8 +5422,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5710,6 +5712,12 @@ snapshots: longest-streak@3.1.0: {} + loro-codemirror@0.3.3(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(loro-crdt@1.10.0): + dependencies: + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.8 + loro-crdt: 1.10.0 + loro-crdt@1.10.0: {} magic-string@0.30.21: diff --git a/src/lib/components/codemirror/Codemirror.svelte b/src/lib/components/codemirror/Codemirror.svelte index 5a19106..407a717 100644 --- a/src/lib/components/codemirror/Codemirror.svelte +++ b/src/lib/components/codemirror/Codemirror.svelte @@ -1,30 +1,23 @@ + -
+

Dashboard

@@ -57,9 +57,9 @@

- Create New Note +

+ Use the sidebar to create your first note +

diff --git a/src/routes/notes/[id]/+layout.svelte b/src/routes/notes/[id]/+layout.svelte index c30ffe0..4b7cb06 100644 --- a/src/routes/notes/[id]/+layout.svelte +++ b/src/routes/notes/[id]/+layout.svelte @@ -1,16 +1,5 @@ -
- {#if data.user} - - {/if} - - {@render children()} -
+{@render children()} From bf14a89f8848d9b0e97383fd44e71a930df2d616 Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 11:38:41 -0600 Subject: [PATCH 04/32] Fix signup/login redirect serialization error --- src/lib/remote/accounts.remote.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/remote/accounts.remote.ts b/src/lib/remote/accounts.remote.ts index 11ef790..b4e75fe 100644 --- a/src/lib/remote/accounts.remote.ts +++ b/src/lib/remote/accounts.remote.ts @@ -41,7 +41,7 @@ export const login = form( const session = await auth.createSession(sessionToken, existingUser.id); auth.setSessionTokenCookie(cookies, sessionToken, session.expiresAt); - return redirect(302, "/"); + throw redirect(302, "/"); }, ); @@ -75,7 +75,7 @@ export const signup = form( } catch { return fail(500, { message: "An error has occurred" }); } - redirect(302, "/"); + throw redirect(302, "/"); }, ); From 0b0b7286f89835faaa91a18d2435a8ae63d0fb6a Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 11:53:50 -0600 Subject: [PATCH 05/32] Add sleek history panel for Loro version tracking --- src/lib/components/HistoryPanel.svelte | 176 ++++++++++++++++++++ src/lib/components/codemirror/Editor.svelte | 18 +- src/lib/loro.ts | 21 +++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/HistoryPanel.svelte diff --git a/src/lib/components/HistoryPanel.svelte b/src/lib/components/HistoryPanel.svelte new file mode 100644 index 0000000..b8567ab --- /dev/null +++ b/src/lib/components/HistoryPanel.svelte @@ -0,0 +1,176 @@ + + +{#if isOpen} +
+ +
+
+ +

Version History

+
+ +
+ + +
+ {#if history.length === 0} +
+
+ +

No history available

+
+
+ {:else} +
+ {#each history as entry, i (entry.version)} + + {/each} +
+ {/if} +
+ + + {#if selectedVersion !== null && selectedVersion !== history[0]?.version} +
+ +
+ {/if} +
+ + + +{/if} diff --git a/src/lib/components/codemirror/Editor.svelte b/src/lib/components/codemirror/Editor.svelte index 056e71c..22a5d71 100644 --- a/src/lib/components/codemirror/Editor.svelte +++ b/src/lib/components/codemirror/Editor.svelte @@ -13,9 +13,11 @@ List, ListOrdered, Strikethrough, + Clock, } from "@lucide/svelte"; import { LoroExtensions } from "loro-codemirror"; import Codemirror from "./Codemirror.svelte"; + import HistoryPanel from "$lib/components/HistoryPanel.svelte"; import { coreExtensions, boldCommand, @@ -47,6 +49,7 @@ // svelte-ignore non_reactive_update let editorView: EditorView; + let isHistoryOpen = $state(false); /** Custom theme */ const editorTheme = EditorView.theme({ @@ -193,11 +196,24 @@ icon: ListOrdered, }, ], + [ + { + onclick: () => (isHistoryOpen = !isHistoryOpen), + title: "Version History", + icon: Clock, + }, + ], ]; -
+
+ + (isHistoryOpen = false)} + />
diff --git a/src/lib/loro.ts b/src/lib/loro.ts index 35f44d5..7c6a13a 100644 --- a/src/lib/loro.ts +++ b/src/lib/loro.ts @@ -139,4 +139,25 @@ export class LoroNoteManager { const encrypted = await encryptData(snapshot, this.#noteKey); return encrypted.toBase64(); } + + /** + * Get the current version/frontiers of the document + */ + getVersion() { + return this.doc.version(); + } + + /** + * Get frontiers (latest version points) of the document + */ + getFrontiers() { + return this.doc.frontiers(); + } + + /** + * Get the text content + */ + getText(): string { + return this.#text.toString(); + } } From 331c6e153dd53adaa3e5f62266e89f2be875a6c3 Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 11:56:10 -0600 Subject: [PATCH 06/32] Make toolbar compact and responsive for smaller screens --- src/lib/components/codemirror/Toolbar.svelte | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/lib/components/codemirror/Toolbar.svelte b/src/lib/components/codemirror/Toolbar.svelte index 918ac3f..80b5b07 100644 --- a/src/lib/components/codemirror/Toolbar.svelte +++ b/src/lib/components/codemirror/Toolbar.svelte @@ -16,16 +16,23 @@ const { tools }: Props = $props(); -
+
{#each tools as toolset, index (index)} -
+
{#each toolset as tool (tool.title)} {@const Icon = tool.icon} - {/each}
-
+
{/each}
From 3b651afcd68462deb077ffe6f662be4d8b6176ab Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 11:58:43 -0600 Subject: [PATCH 07/32] Add live-synced per-user history tracking with Loro CRDTs --- src/lib/components/HistoryPanel.svelte | 46 ++++++++++++++------------ src/lib/loro.ts | 37 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src/lib/components/HistoryPanel.svelte b/src/lib/components/HistoryPanel.svelte index b8567ab..1152f10 100644 --- a/src/lib/components/HistoryPanel.svelte +++ b/src/lib/components/HistoryPanel.svelte @@ -14,38 +14,40 @@ interface HistoryEntry { version: number; timestamp: Date; - author?: string; preview: string; } let history = $state([]); let selectedVersion = $state(null); + let unsubscribe: (() => void) | null = null; - // Get history from Loro document + // Load history from Loro document function loadHistory() { - if (!manager) return; + if (!manager) { + history = []; + return; + } - const frontiers = manager.doc.frontiers(); - const versions: HistoryEntry[] = []; - - // Get all versions by traversing the version graph - // For now, we'll create a simple version list - // In a real implementation, you'd traverse the Loro version graph - const currentVersion = manager.doc.version(); - - // Create a simple history based on the current state - // This is a simplified version - Loro's actual history is more complex - versions.push({ - version: 0, - timestamp: new Date(), - preview: manager.doc.getText("content").toString().slice(0, 100), - }); - - history = versions; + history = manager.getHistory(); } - onMount(() => { - loadHistory(); + // Subscribe to live updates + $effect(() => { + if (manager && isOpen) { + loadHistory(); + + // Subscribe to document changes for live updates + unsubscribe = manager.subscribeToHistory(() => { + loadHistory(); + }); + } + + return () => { + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + }; }); function restoreVersion(version: number) { diff --git a/src/lib/loro.ts b/src/lib/loro.ts index 7c6a13a..0713233 100644 --- a/src/lib/loro.ts +++ b/src/lib/loro.ts @@ -160,4 +160,41 @@ export class LoroNoteManager { getText(): string { return this.#text.toString(); } + + /** + * Get version history with user attribution + * Returns an array of version snapshots + */ + getHistory(): Array<{ + version: number; + timestamp: Date; + preview: string; + }> { + const history: Array<{ + version: number; + timestamp: Date; + preview: string; + }> = []; + + // Get current version + const currentVersion = this.doc.version(); + const currentText = this.#text.toString(); + + // For now, just return the current version + // In a full implementation, you'd traverse the oplog + history.push({ + version: currentVersion.get(this.doc.peerId) || 0, + timestamp: new Date(), + preview: currentText.slice(0, 100), + }); + + return history; + } + + /** + * Subscribe to history changes (live updates) + */ + subscribeToHistory(callback: () => void): () => void { + return this.doc.subscribe(callback); + } } From f2e76b9c0a601c00a90b56b931823c55d7f62c35 Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 12:04:26 -0600 Subject: [PATCH 08/32] Add debug logging for real-time sync troubleshooting --- src/lib/loro.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/loro.ts b/src/lib/loro.ts index 0713233..d9c2d9e 100644 --- a/src/lib/loro.ts +++ b/src/lib/loro.ts @@ -39,8 +39,10 @@ export class LoroNoteManager { // Subscribe to changes this.doc.subscribeLocalUpdates((update) => { console.debug( - "Loro document changed. Preview:", + "[Loro] Local update detected. Preview:", this.#text.toString().slice(0, 20), + "Update size:", + update.length, ); // Persist changes @@ -48,6 +50,7 @@ export class LoroNoteManager { // Send local changes immediately if (this.#isSyncing) { + console.debug("[Loro] Sending local update to server"); unawaited(this.#sendUpdate(update)); } }); @@ -99,10 +102,16 @@ export class LoroNoteManager { this.#eventSource = new EventSource(`/api/sync/${this.#noteId}`); this.#eventSource.onmessage = (event: MessageEvent): void => { + console.debug("[Loro] Received SSE message:", event.data.slice(0, 100)); try { const data = Schema.decodeSync(syncSchemaJson)(event.data); const updateBytes = Uint8Array.fromBase64(data.update); + console.debug( + "[Loro] Applying remote update, size:", + updateBytes.length, + ); this.doc.import(updateBytes); + console.debug("[Loro] Remote update applied successfully"); } catch (error) { console.error("Failed to process sync message:", error); } From cc2229516787ff063434766bfef6e6bae138a21e Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 12:06:10 -0600 Subject: [PATCH 09/32] Fix SSE connection drops with keep-alive mechanism --- src/routes/api/sync/[noteId]/+server.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/routes/api/sync/[noteId]/+server.ts b/src/routes/api/sync/[noteId]/+server.ts index 152ca3f..0581705 100644 --- a/src/routes/api/sync/[noteId]/+server.ts +++ b/src/routes/api/sync/[noteId]/+server.ts @@ -28,15 +28,28 @@ export const GET = async ({ params, locals }) => { // Create a stream for SSE let controller: ReadableStreamDefaultController>; + let keepAliveInterval: NodeJS.Timeout; + const stream = new ReadableStream>({ start(c) { controller = c; addClient(noteId, controller); + // Send initial connection message const encoder = new TextEncoder(); c.enqueue(encoder.encode(`event: connected\n`)); + + // Send keep-alive comment every 15 seconds to prevent timeout + keepAliveInterval = setInterval(() => { + try { + c.enqueue(encoder.encode(`: keep-alive\n\n`)); + } catch (e) { + clearInterval(keepAliveInterval); + } + }, 15000); }, cancel() { + clearInterval(keepAliveInterval); removeClient(noteId, controller); }, }); From 62b06e11ac6a705da23fbda8f363f4d6d2586024 Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 14:01:51 -0600 Subject: [PATCH 10/32] feat: add collapsible sidebar with toggle button and dropdown toolbar --- src/lib/components/Sidebar.svelte | 166 ++++++++++-------- src/lib/components/codemirror/Toolbar.svelte | 175 +++++++++++++++++-- src/lib/loro.ts | 2 +- src/routes/+layout.svelte | 51 +++++- 4 files changed, 304 insertions(+), 90 deletions(-) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index ff2b721..6d1035e 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -10,6 +10,8 @@ Plus, Trash2, Pencil, + ChevronLeft, + ChevronRight, } from "@lucide/svelte"; import type { User } from "$lib/schema.ts"; import ProfilePicture from "./ProfilePicture.svelte"; @@ -39,9 +41,11 @@ interface Props { user: User | undefined; notesList: NoteOrFolder[]; + isCollapsed: boolean; + toggleSidebar: () => void; } - let { user, notesList }: Props = $props(); + let { user, notesList, isCollapsed, toggleSidebar }: Props = $props(); let expandedFolders = new SvelteSet(); let renamingId = $state(null); let renameTitle = $state(""); @@ -205,84 +209,108 @@ diff --git a/src/lib/components/codemirror/Toolbar.svelte b/src/lib/components/codemirror/Toolbar.svelte index 80b5b07..0b1bc92 100644 --- a/src/lib/components/codemirror/Toolbar.svelte +++ b/src/lib/components/codemirror/Toolbar.svelte @@ -1,5 +1,11 @@
- {#each tools as toolset, index (index)} -
- {#each toolset as tool (tool.title)} - {@const Icon = tool.icon} - - {/each} -
-
+ + {#if toggleSidebar && isCollapsed} + +
+ {/if} + + + {#each sortedGroups as group, index (index)} + {#if shouldShowAsButtons(index)} +
+ {#each group.tools as tool (tool.title)} + {@const Icon = tool.icon} + + {/each} +
+
+ {/if} {/each} + + + {#if collapsedGroups.length > 0} + + {/if}
diff --git a/src/lib/loro.ts b/src/lib/loro.ts index d9c2d9e..b80e759 100644 --- a/src/lib/loro.ts +++ b/src/lib/loro.ts @@ -192,7 +192,7 @@ export class LoroNoteManager { // For now, just return the current version // In a full implementation, you'd traverse the oplog history.push({ - version: currentVersion.get(this.doc.peerId) || 0, + version: currentVersion.get(this.doc.peerId) ?? 0, timestamp: new Date(), preview: currentText.slice(0, 100), }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e8d9710..d2a3cc5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import "./layout.css"; import { onNavigate } from "$app/navigation"; + import { onMount } from "svelte"; import favicon from "$lib/assets/favicon.svg"; import Sidebar from "$lib/components/Sidebar.svelte"; import { getNotes } from "$lib/remote/notes.remote.ts"; @@ -10,6 +11,54 @@ const notesList = $derived(data.user ? await getNotes() : []); + // Sidebar collapse state + let isCollapsed = $state(false); + + // Initialize from localStorage and handle responsive behavior + onMount(() => { + // Load saved state from localStorage + const saved = localStorage.getItem("sidebarCollapsed"); + if (saved !== null) { + isCollapsed = saved === "true"; + } else { + // Auto-collapse on mobile screens + isCollapsed = window.innerWidth < 768; + } + + // Handle window resize for automatic collapse + const handleResize = () => { + if (window.innerWidth < 768 && !isCollapsed) { + isCollapsed = true; + } + }; + + window.addEventListener("resize", handleResize); + + // Keyboard shortcut: Ctrl+B (or Cmd+B on Mac) + const handleKeydown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "b") { + e.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeydown); + + return () => { + window.removeEventListener("resize", handleResize); + window.removeEventListener("keydown", handleKeydown); + }; + }); + + // Save to localStorage whenever state changes + $effect(() => { + localStorage.setItem("sidebarCollapsed", String(isCollapsed)); + }); + + function toggleSidebar() { + isCollapsed = !isCollapsed; + } + onNavigate((navigation) => { const { promise, resolve } = Promise.withResolvers(); @@ -28,7 +77,7 @@ {#if data.user}
- +
{@render children()}
From 88491ca0a6111bd670903cde3932aa85a4b5950c Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 14:17:32 -0600 Subject: [PATCH 11/32] refactor: centralize sidebar state management with Svelte context, redesign sidebar UI, and update editor toolbar tool group structure. --- src/lib/components/Sidebar.svelte | 74 ++++----- src/lib/components/codemirror/Editor.svelte | 153 ++++++++++--------- src/lib/components/codemirror/Toolbar.svelte | 21 ++- src/lib/components/sidebar-context.ts | 6 + src/routes/+layout.svelte | 21 ++- 5 files changed, 159 insertions(+), 116 deletions(-) create mode 100644 src/lib/components/sidebar-context.ts diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 6d1035e..bcb01ca 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -12,6 +12,8 @@ Pencil, ChevronLeft, ChevronRight, + PanelLeftClose, + LogOut, } from "@lucide/svelte"; import type { User } from "$lib/schema.ts"; import ProfilePicture from "./ProfilePicture.svelte"; @@ -54,11 +56,14 @@ let notesTree = $derived(buildNotesTree(notesList)); - let rootContainer: HTMLElement; + let rootContainer = $state(); let isRootDropTarget = $state(false); // Set up root drop target - onMount(() => { + // Set up root drop target + $effect(() => { + if (!rootContainer) return; + const cleanup = dropTargetForElements({ element: rootContainer, onDragEnter: () => { @@ -210,46 +215,47 @@ diff --git a/src/lib/components/codemirror/Editor.svelte b/src/lib/components/codemirror/Editor.svelte index 22a5d71..571ab34 100644 --- a/src/lib/components/codemirror/Editor.svelte +++ b/src/lib/components/codemirror/Editor.svelte @@ -136,78 +136,95 @@ ]; const tools = [ - [ - { - title: "Bold (⌘+B)", - onclick: () => boldCommand(editorView), - icon: Bold, - }, - { - title: "Italic (⌘+I)", - onclick: () => italicCommand(editorView), - icon: Italic, - }, - { - title: "Strikethrough (⌘+Shift+X)", - onclick: () => strikethroughCommand(editorView), - icon: Strikethrough, - }, - { - title: "Code (⌘+E)", - onclick: () => codeCommand(editorView), - icon: Code, - }, - ], - [ - { - title: "Link (⌘+K)", - onclick: () => linkCommand(editorView), - icon: Link, - }, - ], - [ - { - title: "Heading 1", - onclick: () => heading1Command(editorView), - icon: Heading1, - }, - { - title: "Heading 2", - onclick: () => heading2Command(editorView), - icon: Heading2, - }, - { - title: "Heading 3", - onclick: () => heading3Command(editorView), - icon: Heading3, - }, - ], - [ - { - onclick: () => bulletListCommand(editorView), - title: "Bullet List", - - icon: List, - }, - { - onclick: () => orderedListCommand(editorView), - title: "Numbered List", - - icon: ListOrdered, - }, - ], - [ - { - onclick: () => (isHistoryOpen = !isHistoryOpen), - title: "Version History", - icon: Clock, - }, - ], + { + priority: 1, + tools: [ + { + title: "Bold (⌘+B)", + onclick: () => boldCommand(editorView), + icon: Bold, + }, + { + title: "Italic (⌘+I)", + onclick: () => italicCommand(editorView), + icon: Italic, + }, + { + title: "Strikethrough (⌘+Shift+X)", + onclick: () => strikethroughCommand(editorView), + icon: Strikethrough, + }, + { + title: "Code (⌘+E)", + onclick: () => codeCommand(editorView), + icon: Code, + }, + ], + }, + { + priority: 2, + tools: [ + { + title: "Link (⌘+K)", + onclick: () => linkCommand(editorView), + icon: Link, + }, + ], + }, + { + priority: 10, + label: "Headings", + tools: [ + { + title: "Heading 1", + onclick: () => heading1Command(editorView), + icon: Heading1, + }, + { + title: "Heading 2", + onclick: () => heading2Command(editorView), + icon: Heading2, + }, + { + title: "Heading 3", + onclick: () => heading3Command(editorView), + icon: Heading3, + }, + ], + }, + { + priority: 5, + label: "Lists", + tools: [ + { + onclick: () => bulletListCommand(editorView), + title: "Bullet List", + + icon: List, + }, + { + onclick: () => orderedListCommand(editorView), + title: "Numbered List", + + icon: ListOrdered, + }, + ], + }, + { + priority: 100, + tools: [ + { + onclick: () => (isHistoryOpen = !isHistoryOpen), + title: "Version History", + icon: Clock, + }, + ], + }, ];
- + diff --git a/src/lib/components/codemirror/Toolbar.svelte b/src/lib/components/codemirror/Toolbar.svelte index 0b1bc92..12c6579 100644 --- a/src/lib/components/codemirror/Toolbar.svelte +++ b/src/lib/components/codemirror/Toolbar.svelte @@ -4,8 +4,13 @@ ChevronRight, ChevronDown, MoreHorizontal, + PanelLeftOpen, } from "@lucide/svelte"; - import { onMount } from "svelte"; + import { onMount, getContext } from "svelte"; + import { + SIDEBAR_CONTEXT_KEY, + type SidebarContext, + } from "$lib/components/sidebar-context"; interface Tool { title: string; @@ -21,11 +26,11 @@ interface Props { toolGroups: ToolGroup[]; - toggleSidebar?: () => void; - isCollapsed?: boolean; } - const { toolGroups, toggleSidebar, isCollapsed = false }: Props = $props(); + const { toolGroups }: Props = $props(); + + const sidebarCtx = getContext(SIDEBAR_CONTEXT_KEY); let toolbarElement: HTMLDivElement; // Initialize with all groups visible by default @@ -56,7 +61,7 @@ let availableWidth = containerWidth - - (toggleSidebar && isCollapsed ? buttonWidth + groupSpacing : 0); + (sidebarCtx?.isCollapsed ? buttonWidth + groupSpacing : 0); let currentVisibleGroups: number[] = []; // Try to fit groups by priority @@ -102,14 +107,14 @@ class="flex items-center gap-1 border-b border-base-content/10 px-2 py-1.5 sm:gap-2 sm:px-4" > - {#if toggleSidebar && isCollapsed} + {#if sidebarCtx?.isCollapsed}
{/if} diff --git a/src/lib/components/sidebar-context.ts b/src/lib/components/sidebar-context.ts new file mode 100644 index 0000000..bd8c70c --- /dev/null +++ b/src/lib/components/sidebar-context.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_CONTEXT_KEY = Symbol("sidebar-context"); + +export interface SidebarContext { + isCollapsed: boolean; + toggleSidebar: () => void; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d2a3cc5..ce00c64 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,14 +6,27 @@ import favicon from "$lib/assets/favicon.svg"; import Sidebar from "$lib/components/Sidebar.svelte"; import { getNotes } from "$lib/remote/notes.remote.ts"; + import { setContext } from "svelte"; + import { SIDEBAR_CONTEXT_KEY } from "$lib/components/sidebar-context"; let { children, data } = $props(); - const notesList = $derived(data.user ? await getNotes() : []); - // Sidebar collapse state let isCollapsed = $state(false); + function toggleSidebar() { + isCollapsed = !isCollapsed; + } + + setContext(SIDEBAR_CONTEXT_KEY, { + get isCollapsed() { + return isCollapsed; + }, + toggleSidebar, + }); + + const notesList = $derived(data.user ? await getNotes() : []); + // Initialize from localStorage and handle responsive behavior onMount(() => { // Load saved state from localStorage @@ -55,10 +68,6 @@ localStorage.setItem("sidebarCollapsed", String(isCollapsed)); }); - function toggleSidebar() { - isCollapsed = !isCollapsed; - } - onNavigate((navigation) => { const { promise, resolve } = Promise.withResolvers(); From d03ccb48cf0dbd80e4f16d99f32172ab1c4374d2 Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 14:45:40 -0600 Subject: [PATCH 12/32] fix: Update sidebar collapse button class from `btn-sq` to `btn-square`. --- src/lib/components/Sidebar.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index bcb01ca..4e9540c 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -251,7 +251,7 @@ +
+ + +
+ +
+ + + + + + + + + +
+ + + {#if accessLevel === "invite_only"} +
+ + +
+ e.key === "Enter" && addInvitedUser()} + /> + +
+ + {#if invitedUsers.length > 0} +
+ {#each invitedUsers as user} +
+ {user} + +
+ {/each} +
+ {/if} +
+ {/if} + + + {#if accessLevel !== "private"} +
+ +
+ + +
+
+ {/if} +
+ + +
+ + +
+
+
+{/if} diff --git a/src/lib/components/codemirror/Editor.svelte b/src/lib/components/codemirror/Editor.svelte index 571ab34..5401dd0 100644 --- a/src/lib/components/codemirror/Editor.svelte +++ b/src/lib/components/codemirror/Editor.svelte @@ -3,6 +3,7 @@ import { EditorView } from "@codemirror/view"; import { + type Icon as IconType, Bold, Code, Heading1, @@ -14,7 +15,31 @@ ListOrdered, Strikethrough, Clock, + Globe, + Share as ShareIcon, } from "@lucide/svelte"; + // ... existing imports ... + + interface Props { + manager: LoroNoteManager | undefined; + notesList?: NoteOrFolder[]; + user: User | undefined; + handleOpenInHomeserver: (input: string | null) => void; + noteId: string; + noteTitle: string; + } + + let { + manager, + notesList = [], + user, + handleOpenInHomeserver, + noteId, + noteTitle, + }: Props = $props(); + + // ... (existing code) ... + import { LoroExtensions } from "loro-codemirror"; import Codemirror from "./Codemirror.svelte"; import HistoryPanel from "$lib/components/HistoryPanel.svelte"; @@ -32,6 +57,7 @@ orderedListCommand, } from "./Editor.ts"; import Toolbar from "./Toolbar.svelte"; + import ShareModal from "$lib/components/ShareModal.svelte"; import { wikilinksExtension } from "$lib/editor/wikilinks.ts"; import type { NoteOrFolder, User } from "$lib/schema.ts"; import { LoroNoteManager } from "$lib/loro.ts"; @@ -39,17 +65,10 @@ import type { Extension } from "@codemirror/state"; import { onDestroy } from "svelte"; - interface Props { - manager: LoroNoteManager | undefined; - notesList?: NoteOrFolder[]; - user: User | undefined; - } - - let { manager, notesList = [], user }: Props = $props(); - // svelte-ignore non_reactive_update let editorView: EditorView; let isHistoryOpen = $state(false); + let isShareOpen = $state(false); /** Custom theme */ const editorTheme = EditorView.theme({ @@ -128,13 +147,6 @@ loroExtensions = []; } - const extensions: Extension[] = [ - coreExtensions, - wikilinksExtension(notesList), - loroExtensions, - editorTheme, - ]; - const tools = [ { priority: 1, @@ -199,13 +211,11 @@ { onclick: () => bulletListCommand(editorView), title: "Bullet List", - icon: List, }, { onclick: () => orderedListCommand(editorView), title: "Numbered List", - icon: ListOrdered, }, ], @@ -218,9 +228,26 @@ title: "Version History", icon: Clock, }, + { + onclick: () => handleOpenInHomeserver(null), + title: "Open in Homeserver", + icon: Globe, + }, + { + onclick: () => (isShareOpen = true), + title: "Share", + icon: ShareIcon, + }, ], }, ]; + + const extensions: Extension[] = [ + coreExtensions, + wikilinksExtension(notesList), + loroExtensions, + editorTheme, + ];
@@ -233,4 +260,11 @@ isOpen={isHistoryOpen} onClose={() => (isHistoryOpen = false)} /> + + (isShareOpen = false)} + />
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 8f6b639..d09de93 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,172 +1,182 @@ -/** - * WebCrypto utilities for E2EE - */ +import { ed25519, x25519 } from "@noble/curves/ed25519.js"; +import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; +import { encodeBase64, decodeBase64 } from "@oslojs/encoding"; +import { hkdf } from "@noble/hashes/hkdf.js"; +import { sha256 } from "@noble/hashes/sha2.js"; + +// Use built-in randomBytes +function getRandomBytes(len: number) { + if (typeof globalThis.crypto !== "undefined") { + return globalThis.crypto.getRandomValues(new Uint8Array(len)); + } + throw new Error("WebCrypto not available"); +} + +// ---------------------------------------------------------------------------- +// Types +// ---------------------------------------------------------------------------- -interface KeyPair { - publicKey: string; - privateKey: string; +export interface KeyPair { + publicKey: string; // Base64 + privateKey: string; // Base64 } -export async function generateUserKeys(): Promise { - const keyPair = await crypto.subtle.generateKey( - { - name: "RSA-OAEP", - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, - true, - ["encrypt", "decrypt"], - ); - - const publicKeyData = await crypto.subtle.exportKey( - "spki", - keyPair.publicKey, - ); - const privateKeyData = await crypto.subtle.exportKey( - "pkcs8", - keyPair.privateKey, - ); +export interface DeviceKeys { + signing: KeyPair; + encryption: KeyPair; +} - return { - publicKey: new Uint8Array(publicKeyData).toBase64(), +// ---------------------------------------------------------------------------- +// Identity / Signing (Ed25519) +// ---------------------------------------------------------------------------- - // TODO: Proper encryption - // For now, encode private key to base64 - // In production, use PBKDF2 to derive encryption key - privateKey: new Uint8Array(privateKeyData).toBase64(), +export async function generateSigningKeyPair(): Promise { + const priv = ed25519.utils.randomSecretKey(); + const pub = await ed25519.getPublicKey(priv); + return { + publicKey: encodeBase64(pub), + privateKey: encodeBase64(priv), }; } -export async function generateNoteKey(): Promise { - const key = await crypto.subtle.generateKey( - { - name: "AES-GCM", - length: 256, - }, - true, - ["encrypt", "decrypt"], - ); - - const keyData = await crypto.subtle.exportKey("raw", key); - return new Uint8Array(keyData).toBase64(); +export async function sign( + message: Uint8Array, + privateKeyBase64: string, +): Promise { + const priv = decodeBase64(privateKeyBase64); + const signature = await ed25519.sign(message, priv); + return encodeBase64(signature); } -export async function encryptKeyForUser( - noteKey: string, - recipientPublicKey: string, -): Promise { - const keyBuffer = Uint8Array.fromBase64(noteKey); - const publicKeyBuffer = Uint8Array.fromBase64(recipientPublicKey); - - const publicKey = await crypto.subtle.importKey( - "spki", - publicKeyBuffer, - { - name: "RSA-OAEP", - hash: "SHA-256", - }, - false, - ["encrypt"], - ); - - const encrypted = await crypto.subtle.encrypt( - { - name: "RSA-OAEP", - }, - publicKey, - keyBuffer, - ); - - return new Uint8Array(encrypted).toBase64(); +export async function verify( + signatureBase64: string, + message: Uint8Array, + publicKeyBase64: string, +): Promise { + const sig = decodeBase64(signatureBase64); + const pub = decodeBase64(publicKeyBase64); + return ed25519.verify(sig, message, pub); } -export async function decryptKey( - encryptedKey: string, - privateKey: string, -): Promise { - const encryptedBuffer = Uint8Array.fromBase64(encryptedKey); - const privateKeyBuffer = Uint8Array.fromBase64(privateKey); - - const key = await crypto.subtle.importKey( - "pkcs8", - privateKeyBuffer, - { - name: "RSA-OAEP", - hash: "SHA-256", - }, - false, - ["decrypt"], - ); - - const decrypted = await crypto.subtle.decrypt( - { - name: "RSA-OAEP", - }, - key, - encryptedBuffer, - ); - - return new Uint8Array(decrypted).toBase64(); +// ---------------------------------------------------------------------------- +// Key Exchange / Encryption (X25519 + XChaCha20Poly1305) +// ---------------------------------------------------------------------------- + +export async function generateEncryptionKeyPair(): Promise { + const priv = x25519.utils.randomSecretKey(); + const pub = x25519.getPublicKey(priv); + return { + publicKey: encodeBase64(pub), + privateKey: encodeBase64(priv), + }; } -async function getCryptoKeyFromBase64(base64Key: string): Promise { - const keyBuffer = Uint8Array.fromBase64(base64Key); - return crypto.subtle.importKey( - "raw", - keyBuffer, - { - name: "AES-GCM", - }, - false, - ["encrypt", "decrypt"], - ); +// Generate a random 32-byte key for the document +export function generateNoteKey(): string { + const key = getRandomBytes(32); + return encodeBase64(key); } -const IV_LENGTH = 12; // AES-GCM standard IV length - -export async function encryptData( - data: Uint8Array, - noteKey: string, -): Promise { - const key = await getCryptoKeyFromBase64(noteKey); - - const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); - const encrypted = await crypto.subtle.encrypt( - { - name: "AES-GCM", - iv, - }, - key, - data, - ); - - // Prepend IV to encrypted data - const result = new Uint8Array(iv.length + encrypted.byteLength); - result.set(iv); - result.set(new Uint8Array(encrypted), iv.length); +/** + * Encrypts the document key for a specific recipient device. + * Uses an ephemeral key pair for the sender (anonymous). + * Format: [ephemeral_pub (32)] + [nonce (24)] + [ciphertext] + */ +export function encryptKeyForDevice( + noteKeyBase64: string, + recipientPublicKeyBase64: string, +): string { + const noteKey = decodeBase64(noteKeyBase64); + const recipientPub = decodeBase64(recipientPublicKeyBase64); + + // 1. Generate ephemeral sender key + const ephemeralPriv = x25519.utils.randomSecretKey(); + const ephemeralPub = x25519.getPublicKey(ephemeralPriv); + + // 2. ECDH Shared Secret + const sharedSecret = x25519.getSharedSecret(ephemeralPriv, recipientPub); + + // 3. HKDF to derive symmetric key + const info = new TextEncoder().encode("notes-app-key-encryption"); + const derivedKey = hkdf(sha256, sharedSecret, undefined, info, 32); + + // 4. Encrypt note key with XChaCha20Poly1305 + const nonce = getRandomBytes(24); + const chacha = xchacha20poly1305(derivedKey, nonce); + const ciphertext = chacha.encrypt(noteKey); + + // 5. Pack: ephemeralPub (32) + nonce (24) + ciphertext + const result = new Uint8Array(32 + 24 + ciphertext.length); + result.set(ephemeralPub, 0); + result.set(nonce, 32); + result.set(ciphertext, 56); + + return encodeBase64(result); +} + +export function decryptKeyForDevice( + encryptedEnvelopeBase64: string, + devicePrivateKeyBase64: string, +): string { + const envelope = decodeBase64(encryptedEnvelopeBase64); + const devicePriv = decodeBase64(devicePrivateKeyBase64); + + if (envelope.length < 56) throw new Error("Envelope too short"); + + const ephemeralPub = envelope.slice(0, 32); + const nonce = envelope.slice(32, 56); + const ciphertext = envelope.slice(56); + + const sharedSecret = x25519.getSharedSecret(devicePriv, ephemeralPub); + const info = new TextEncoder().encode("notes-app-key-encryption"); + const derivedKey = hkdf(sha256, sharedSecret, undefined, info, 32); + + const chacha = xchacha20poly1305(derivedKey, nonce); + const noteKey = chacha.decrypt(ciphertext); + return encodeBase64(noteKey); +} + +// ---------------------------------------------------------------------------- +// Content Encryption (XChaCha20Poly1305) +// ---------------------------------------------------------------------------- + +export function encryptData( + data: Uint8Array, + noteKeyBase64: string, +): Uint8Array { + const key = decodeBase64(noteKeyBase64); + + // Per message nonce + const nonce = getRandomBytes(24); + const chacha = xchacha20poly1305(key, nonce); + const ciphertext = chacha.encrypt(data); + + // Prepend nonce + const result = new Uint8Array(24 + ciphertext.length); + result.set(nonce, 0); + result.set(ciphertext, 24); return result; } -export async function decryptData( +export function decryptData( encrypted: Uint8Array, - noteKey: string, -): Promise { - const key = await getCryptoKeyFromBase64(noteKey); - - // Extract IV from first 12 bytes - const iv = encrypted.slice(0, IV_LENGTH); - const data = encrypted.slice(IV_LENGTH); - - const decrypted = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv, - }, - key, - data, - ); - - return new Uint8Array(decrypted); + noteKeyBase64: string, +): Uint8Array { + const key = decodeBase64(noteKeyBase64); + + // Extract IV from first 24 bytes + const nonce = encrypted.slice(0, 24); + const ciphertext = encrypted.slice(24); + + const chacha = xchacha20poly1305(key, nonce); + return chacha.decrypt(ciphertext); } + +// ---------------------------------------------------------------------------- +// Aliases for Client Compatibility +// ---------------------------------------------------------------------------- + +export const encryptKeyForUser = encryptKeyForDevice; +export const decryptKey = decryptKeyForDevice; +export const generateUserKeys = generateEncryptionKeyPair; diff --git a/src/lib/loro.ts b/src/lib/loro.ts index b80e759..d5c06c7 100644 --- a/src/lib/loro.ts +++ b/src/lib/loro.ts @@ -92,6 +92,9 @@ export class LoroNoteManager { this.#isSyncing = false; } + /** + * Start real-time sync + */ /** * Start real-time sync */ @@ -99,19 +102,24 @@ export class LoroNoteManager { if (this.#isSyncing) return; this.#isSyncing = true; - this.#eventSource = new EventSource(`/api/sync/${this.#noteId}`); + // Use SSE endpoint + this.#eventSource = new EventSource(`/client/doc/${this.#noteId}/events`); this.#eventSource.onmessage = (event: MessageEvent): void => { - console.debug("[Loro] Received SSE message:", event.data.slice(0, 100)); + // console.debug("[Loro] Received SSE message:", event.data.slice(0, 100)); try { - const data = Schema.decodeSync(syncSchemaJson)(event.data); - const updateBytes = Uint8Array.fromBase64(data.update); - console.debug( - "[Loro] Applying remote update, size:", - updateBytes.length, - ); - this.doc.import(updateBytes); - console.debug("[Loro] Remote update applied successfully"); + const ops = JSON.parse(event.data); + if (!Array.isArray(ops)) return; + + for (const op of ops) { + // op.payload is encrypted blob (base64) + // Loro import expects Uint8Array? + // Wait, op.payload is base64 string provided by server. + // Loro import expects Uint8Array. + const updateBytes = Uint8Array.fromBase64(op.payload); + this.doc.import(updateBytes); + } + // console.debug(`[Loro] Applied ${ops.length} ops`); } catch (error) { console.error("Failed to process sync message:", error); } @@ -121,6 +129,7 @@ export class LoroNoteManager { console.error("SSE connection error:", error); this.#eventSource?.close(); this.#isSyncing = false; + // Reconnect logic? Browser EventSource handles reconnect automatically often. }; } @@ -129,9 +138,34 @@ export class LoroNoteManager { */ async #sendUpdate(update: Uint8Array) { try { - await sync({ - noteId: this.#noteId, - update: update.toBase64(), + const opId = this.doc.peerId; // Wait, op ID needs to be unique? + // Loro update is a blob. We wrap it in an Op structure? + // Server expects: { op: { op_id, actor_id, lamport_ts, encrypted_payload, signature } } + // Client generates these? + // Loro `update` is a patch. We treat it as one "Op"? + // We need `actor_id` (peerId). + // `lamport_ts`: does Loro expose generic lamport? `doc.oplog.vv`? + // Or we just use client timestamp/counter? + // Loro updates are CRDT blobs. + // For federation Op Log, we wrap the blob. + + const payload = update.toBase64(); + const actorId = this.doc.peerIdStr; // string? + // Loro API check: `doc.peerIdStr` exists. + + // Mock Op structure + const op = { + op_id: crypto.randomUUID(), + actor_id: actorId, + lamport_ts: Date.now(), // Approximate ordering + encrypted_payload: payload, + signature: "TODO", // Client signature! + }; + + await fetch(`/client/doc/${this.#noteId}/push`, { + method: "POST", + body: JSON.stringify({ op }), + headers: { "Content-Type": "application/json" }, }); } catch (error) { console.error("Failed to send update:", error); diff --git a/src/lib/noteId.ts b/src/lib/noteId.ts new file mode 100644 index 0000000..be85edb --- /dev/null +++ b/src/lib/noteId.ts @@ -0,0 +1,45 @@ +/** + * Utilities for creating and parsing domain-prefixed note IDs + * Format: {base64url(origin)}~{uuid} + * Example: bG9jYWxob3N0OjUxNzM~2472a017-f681-4fdd-bd46-80207dc3c5fb + */ + +/** + * Create a new note ID with embedded origin domain + */ +export function createNoteId(serverDomain: string): string { + const uuid = crypto.randomUUID(); + const domainB64 = btoa(serverDomain) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + return `${domainB64}~${uuid}`; +} + +/** + * Parse a note ID to extract origin domain and UUID + */ +export function parseNoteId(id: string): { + origin: string; + uuid: string; + fullId: string; +} { + if (!id.includes("~")) { + // Legacy format - assume local + return { origin: "", uuid: id, fullId: id }; + } + + const [domainB64, uuid] = id.split("~"); + const padded = domainB64 + "=".repeat((4 - (domainB64.length % 4)) % 4); + const origin = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); + + return { origin, uuid, fullId: id }; +} + +/** + * Check if a note ID is from the local server + */ +export function isLocalNote(noteId: string, currentDomain: string): boolean { + const { origin } = parseNoteId(noteId); + return !origin || origin === currentDomain; +} diff --git a/src/lib/remote/federation.remote.ts b/src/lib/remote/federation.remote.ts new file mode 100644 index 0000000..1abd2d8 --- /dev/null +++ b/src/lib/remote/federation.remote.ts @@ -0,0 +1,102 @@ +import { command } from "$app/server"; +import { db } from "$lib/server/db/index.ts"; +import { documents, members } from "$lib/server/db/schema.ts"; +import { requireLogin } from "$lib/server/auth.ts"; +import { error } from "@sveltejs/kit"; +import { env } from "$env/dynamic/private"; +import { parseNoteId } from "$lib/noteId.ts"; +import { getServerIdentity } from "$lib/server/identity.ts"; +import { sign } from "$lib/crypto.ts"; +import { eq } from "drizzle-orm"; + +interface JoinRequest { + noteId: string; + originServer: string; +} + +export const joinFederatedNote = command( + async ({ noteId, originServer }: JoinRequest) => { + const { user } = requireLogin(); + const currentDomain = env.SERVER_DOMAIN || "localhost:5173"; + const { uuid } = parseNoteId(noteId); + + try { + // Check if already joined + const existing = await db.query.documents.findFirst({ + where: eq(documents.id, uuid), + }); + + if (existing) { + console.log(`Already joined note ${uuid}`); + return { success: true, alreadyJoined: true }; + } + + // Call origin server's join endpoint + const joinUrl = `http://${originServer}/federation/doc/${uuid}/join`; + console.log(`Joining federated note from ${originServer}: ${joinUrl}`); + + // Get server identity for signing + const serverIdentity = await getServerIdentity(); + const timestamp = Date.now().toString(); + const requestBody = { + requesting_server: currentDomain, + users: [user.id], + }; + + // Create signature + const message = `${currentDomain}:${timestamp}:${JSON.stringify(requestBody)}`; + const signature = await sign( + new TextEncoder().encode(message), + serverIdentity.privateKey, + ); + + const response = await fetch(joinUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-notes-signature": signature, + "x-notes-timestamp": timestamp, + "x-notes-domain": currentDomain, + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error( + `Federation join failed: ${response.status} ${errorText}`, + ); + console.error("Full error:", errorText); + error(response.status, `Failed to join note: ${errorText}`); + } + + const joinData = await response.json(); + + // Store document metadata locally + await db.insert(documents).values({ + id: uuid, + hostServer: originServer, + ownerId: joinData.ownerId || user.id, + title: joinData.title || "Federated Note", + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Store member relationship with encrypted key + await db.insert(members).values({ + docId: uuid, + userId: user.id, + deviceId: "default", // TODO: Support multiple devices + role: joinData.role || "writer", + encryptedKeyEnvelope: joinData.encryptedKey, + createdAt: new Date(), + }); + + console.log(`Successfully joined note ${uuid} from ${originServer}`); + return { success: true, alreadyJoined: false }; + } catch (err) { + console.error("Federation join error:", err); + error(500, `Failed to join federated note: ${err}`); + } + }, +); diff --git a/src/lib/remote/notes.remote.ts b/src/lib/remote/notes.remote.ts index cfdc0d7..45f8d9a 100644 --- a/src/lib/remote/notes.remote.ts +++ b/src/lib/remote/notes.remote.ts @@ -5,6 +5,8 @@ import { db } from "$lib/server/db/index.ts"; import { notes } from "$lib/server/db/schema.ts"; import { error } from "@sveltejs/kit"; import { and, eq } from "drizzle-orm"; +import { env } from "$env/dynamic/private"; +import { createNoteId } from "$lib/noteId.ts"; import { createNoteSchema, deleteNoteSchema, @@ -47,7 +49,8 @@ export const createNote = command( error(400, "Missing required fields"); } - const id = crypto.randomUUID(); + const serverDomain = env.SERVER_DOMAIN || "localhost:5173"; + const id = createNoteId(serverDomain); await db.insert(notes).values({ id, diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index e7f8a69..e3ff944 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,11 +1,30 @@ -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { + sqliteTable, + text, + integer, + blob, + primaryKey, +} from "drizzle-orm/sqlite-core"; export const users = sqliteTable("users", { id: text("id").primaryKey(), username: text("username").notNull().unique(), + handle: text("handle").unique(), // Federated handle, e.g. "alice" passwordHash: text("password_hash").notNull(), - publicKey: text("public_key").notNull(), - privateKeyEncrypted: text("private_key_encrypted").notNull(), + publicKey: text("public_key"), // Ed25519 public key + privateKeyEncrypted: text("private_key_encrypted").notNull(), // Existing field, maybe reuse or deprecate + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +export const devices = sqliteTable("devices", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + deviceId: text("device_id").notNull(), + publicKey: text("public_key").notNull(), // Device specific public key (X25519/Ed25519) createdAt: integer("created_at", { mode: "timestamp" }) .notNull() .$defaultFn(() => new Date()), @@ -19,6 +38,58 @@ export const sessions = sqliteTable("sessions", { expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), }); +// The core document table for federated notes +export const documents = sqliteTable("documents", { + id: text("id").primaryKey(), // UUID + hostServer: text("host_server").notNull(), // e.g. "home.example.com" or "local" + ownerId: text("owner_id").notNull(), // Federated ID or local user ID + title: text("title"), + accessLevel: text("access_level").notNull().default("private"), + documentKeyEncrypted: text("document_key_encrypted"), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +export const members = sqliteTable( + "members", + { + docId: text("doc_id") + .notNull() + .references(() => documents.id), + userId: text("user_id").notNull(), // Federated ID + role: text("role").notNull().default("writer"), // reader, writer, owner + encryptedKeyEnvelope: text("encrypted_key_envelope"), // Encrypted doc key for this user/device + deviceId: text("device_id").notNull(), // If key is per-device, make it not null as per PK + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + }, + (t) => ({ + pk: primaryKey({ columns: [t.docId, t.userId, t.deviceId] }), + }), +); + +export const federatedOps = sqliteTable("federated_ops", { + id: text("id").primaryKey(), // composite or specific ID? + docId: text("doc_id") + .notNull() + .references(() => documents.id), + opId: text("op_id").notNull(), // actor+counter + actorId: text("actor_id").notNull(), + lamportTs: integer("lamport_ts").notNull(), + payload: text("payload").notNull(), // Encrypted blob (base64) + signature: text("signature").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +// Keeping existing tables for now to avoid immediate breakage, +// but we might migrate `notes` -> `documents` logic. export const notes = sqliteTable("notes", { id: text("id").primaryKey(), title: text("title").notNull(), @@ -27,6 +98,8 @@ export const notes = sqliteTable("notes", { .references(() => users.id), encryptedKey: text("encrypted_key").notNull(), loroSnapshot: text("loro_snapshot"), + accessLevel: text("access_level").notNull().default("private"), // private, authenticated, open, invite_only + documentKeyEncrypted: text("document_key_encrypted"), // Document key encrypted for owner parentId: text("parent_id"), isFolder: integer("is_folder", { mode: "boolean" }).notNull().default(false), order: integer("order").notNull().default(0), @@ -52,6 +125,10 @@ export const noteShares = sqliteTable("note_shares", { }); export type User = typeof users.$inferSelect; +export type Device = typeof devices.$inferSelect; export type Session = typeof sessions.$inferSelect; +export type Document = typeof documents.$inferSelect; +export type Member = typeof members.$inferSelect; +export type FederatedOp = typeof federatedOps.$inferSelect; export type Note = typeof notes.$inferSelect; export type NoteShare = typeof noteShares.$inferSelect; diff --git a/src/lib/server/identity.ts b/src/lib/server/identity.ts new file mode 100644 index 0000000..f640978 --- /dev/null +++ b/src/lib/server/identity.ts @@ -0,0 +1,60 @@ +import { generateSigningKeyPair, sign } from "$lib/crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const IDENTITY_FILE = + process.env["SERVER_IDENTITY_FILE"] || "server-identity.json"; + +interface ServerIdentity { + publicKey: string; + privateKey: string; + domain: string; +} + +// Singleton identity +let identity: ServerIdentity | null = null; + +export async function getServerIdentity(): Promise { + if (identity) return identity; + + // TODO: Determine domain dynamically or from config + const domain = process.env["SERVER_DOMAIN"] || "localhost:5173"; + + if (fs.existsSync(IDENTITY_FILE)) { + const data = fs.readFileSync(IDENTITY_FILE, "utf-8"); + identity = JSON.parse(data); + if (identity && identity.publicKey && identity.privateKey) { + // update domain if changed via env + identity.domain = domain; + return identity; + } + } + + // Generate new + console.log("Generating new server identity..."); + const keyPair = await generateSigningKeyPair(); // Ed25519 + identity = { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + domain, + }; + + fs.writeFileSync(IDENTITY_FILE, JSON.stringify(identity, null, 2)); + return identity; +} + +export async function signServerRequest( + payload: any, +): Promise<{ signature: string; timestamp: number; domain: string }> { + const id = await getServerIdentity(); + const timestamp = Date.now(); + // Deterministic canonical JSON needed? Or just sign payload string/bytes? + // Let's sign: domain + timestamp + JSON.stringify(payload) + const msg = `${id.domain}:${timestamp}:${JSON.stringify(payload)}`; + const sig = await sign(new TextEncoder().encode(msg), id.privateKey); + return { + signature: sig, + timestamp, + domain: id.domain, + }; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7fb90ac..bb86f01 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,14 @@
@@ -57,9 +64,19 @@

-

- Use the sidebar to create your first note -

+ {#if sidebar.isCollapsed} + + {:else} +

+ Use the sidebar to create your first note +

+ {/if}
diff --git a/src/routes/.well-known/notes-identity/[handle]/+server.ts b/src/routes/.well-known/notes-identity/[handle]/+server.ts new file mode 100644 index 0000000..35ff4a0 --- /dev/null +++ b/src/routes/.well-known/notes-identity/[handle]/+server.ts @@ -0,0 +1,42 @@ +import { json } from "@sveltejs/kit"; +import { db } from "$lib/server/db"; +import { users, devices } from "$lib/server/db/schema"; +import { eq } from "drizzle-orm"; + +export async function GET({ params }) { + const { handle } = params; + + // Assume handle is the username for now for local lookups + // Ideally this endpoint serves `/.well-known/notes-identity/@alice` + // so we strip the @ if needed, or query by it. + + // Clean handle: remove leading @ + const cleanHandle = handle.startsWith("@") ? handle.slice(1) : handle; + + // If handle contains ':', it might include domain, but this is the home server, + // so we expect to serve our own users. + // We'll look up by username locally. + const username = cleanHandle.split(":")[0]; + + const user = await db.query.users.findFirst({ + where: eq(users.username, username), + }); + + if (!user) { + return new Response("Not found", { status: 404 }); + } + + const userDevices = await db.query.devices.findMany({ + where: eq(devices.userId, user.id), + }); + + return json({ + id: user.id, + handle: `@${user.username}`, // Canonical handle + publicKey: user.publicKey, + devices: userDevices.map((d) => ({ + device_id: d.deviceId, + public_key: d.publicKey, + })), + }); +} diff --git a/src/routes/.well-known/notes-server/+server.ts b/src/routes/.well-known/notes-server/+server.ts new file mode 100644 index 0000000..a66822c --- /dev/null +++ b/src/routes/.well-known/notes-server/+server.ts @@ -0,0 +1,10 @@ +import { json } from "@sveltejs/kit"; +import { getServerIdentity } from "$lib/server/identity"; + +export async function GET() { + const id = await getServerIdentity(); + return json({ + domain: id.domain, + publicKey: id.publicKey, + }); +} diff --git a/src/routes/client/doc/[doc_id]/events/+server.ts b/src/routes/client/doc/[doc_id]/events/+server.ts new file mode 100644 index 0000000..8d212b5 --- /dev/null +++ b/src/routes/client/doc/[doc_id]/events/+server.ts @@ -0,0 +1,54 @@ +import { db } from "$lib/server/db"; +import { federatedOps } from "$lib/server/db/schema"; +import { eq, gt, asc, and } from "drizzle-orm"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ params, url }) => { + const { doc_id } = params; + const since = url.searchParams.get("since"); + let lastTs = since ? parseInt(since) : Date.now(); + + const stream = new ReadableStream({ + async start(controller) { + while (true) { + try { + // Check if client is still connected? + // ReadableStream doesn't inherently check unless we try to enqueue and it errors? + // SvelteKit/Node might abort controller? + + // Poll + // Fetch ops newer than lastTs for this doc + const newOps = await db.query.federatedOps.findMany({ + where: and( + eq(federatedOps.docId, doc_id), + gt(federatedOps.lamportTs, lastTs), + ), + orderBy: [asc(federatedOps.lamportTs)], + }); + + if (newOps.length > 0) { + const message = JSON.stringify(newOps); + controller.enqueue(`data: ${message}\n\n`); + // Update lastTs to the max ts found + const maxTs = Math.max(...newOps.map((o) => o.lamportTs)); + if (maxTs > lastTs) lastTs = maxTs; + } + + await new Promise((r) => setTimeout(r, 1000)); + } catch (e) { + // Error or closed? + controller.close(); + break; + } + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +}; diff --git a/src/routes/client/doc/[doc_id]/push/+server.ts b/src/routes/client/doc/[doc_id]/push/+server.ts new file mode 100644 index 0000000..72029bd --- /dev/null +++ b/src/routes/client/doc/[doc_id]/push/+server.ts @@ -0,0 +1,35 @@ +import { json } from "@sveltejs/kit"; +import { db } from "$lib/server/db"; +import { federatedOps } from "$lib/server/db/schema"; + +export async function POST({ params, request, locals }) { + const { doc_id } = params; + const body = await request.json(); + const { op } = body; + // Op structure: { op_id, actor_id, lamport_ts, encrypted_payload, signature } + + if (!locals.user) { + // Validation check (auth) + // Only members can write? + // Check member role. + } + + // Store Op + await db + .insert(federatedOps) + .values({ + id: op.op_id, + docId: doc_id, + opId: op.op_id, + actorId: op.actor_id, + lamportTs: op.lamport_ts, + payload: op.encrypted_payload, // or 'payload' in DB + signature: op.signature, + }) + .onConflictDoNothing(); + + // Trigger SSE? + // If using in-memory bus, emit here. + + return json({ success: true }); +} diff --git a/src/routes/federation/doc/[doc_id]/join/+server.ts b/src/routes/federation/doc/[doc_id]/join/+server.ts new file mode 100644 index 0000000..ebe163f --- /dev/null +++ b/src/routes/federation/doc/[doc_id]/join/+server.ts @@ -0,0 +1,131 @@ +import { json, error } from "@sveltejs/kit"; +import { getServerIdentity } from "$lib/server/identity"; +import { verify } from "$lib/crypto"; +import { db } from "$lib/server/db"; +import { documents, members, notes } from "$lib/server/db/schema"; +import { eq, and, inArray } from "drizzle-orm"; + +// Helper to verify request signature +async function verifyServerRequest(request: Request, payload: any) { + const signature = request.headers.get("x-notes-signature"); + const timestamp = request.headers.get("x-notes-timestamp"); + const domain = request.headers.get("x-notes-domain"); + + if (!signature || !timestamp || !domain) { + throw error(401, "Missing signature headers"); + } + + // Fetch remote server key + // In production, we would cache this or use a more robust discovery + // For local dev, we might assume http? Or https with ignore cert? + const protocol = domain.includes("localhost") ? "http" : "https"; + const remoteKeyUrl = `${protocol}://${domain}/.well-known/notes-server`; + + try { + const res = await fetch(remoteKeyUrl); + if (!res.ok) throw new Error("Failed to fetch server key"); + const data = await res.json(); + if (data.domain !== domain) throw new Error("Domain mismatch"); + + const msg = `${domain}:${timestamp}:${JSON.stringify(payload)}`; + const valid = await verify( + signature, + new TextEncoder().encode(msg), + data.publicKey, + ); + + if (!valid) throw error(401, "Invalid signature"); + return data; // validated server info + } catch (e) { + console.error("Verification failed", e); + throw error(401, "Verification failed"); + } +} + +export async function POST({ params, request }) { + const { doc_id } = params; + const body = await request.json(); + const { requesting_server, users: joiningUsers } = body; + + // Verify signature + await verifyServerRequest(request, body); + + // 1. Check if doc exists + // Note: querying 'documents' table. If using 'notes', switch to 'notes' or ensure 'documents' populated. + // For now assuming 'documents' table is used for federation metadata. + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, doc_id), + }); + + // Fallback to checking `notes` if `documents` empty? + // If we haven't migrated existing notes to `documents`, check `notes`. + let note; + if (!doc) { + note = await db.query.notes.findFirst({ + where: eq(notes.id, doc_id), + }); + if (!note) throw error(404, "Document not found"); + // Implicitly hosted here if local note found? + } else { + note = await db.query.notes.findFirst({ where: eq(notes.id, doc_id) }); + } + + // 2. Check permissions based on access_level + const accessLevel = note?.accessLevel || doc?.accessLevel || "private"; + + if (accessLevel === "private" || accessLevel === "invite_only") { + // Require pre-existing membership for private/invite-only notes + const memberRows = await db.query.members.findMany({ + where: and( + eq(members.docId, doc_id), + inArray(members.userId, joiningUsers), + ), + }); + + if (memberRows.length === 0) { + throw error( + 403, + "This note is private. You must be invited to access it.", + ); + } + + // Return existing envelopes for invited users + const snapshot = note?.loroSnapshot || null; + return json({ + doc_id, + snapshot, + envelopes: memberRows.map((m) => ({ + user_id: m.userId, + device_id: m.deviceId, + encrypted_key: m.encryptedKeyEnvelope, + })), + title: note?.title || "Untitled", + ownerId: note?.ownerId, + }); + } + + // 4. For authenticated/open notes, generate encrypted keys for joining users + // Need to fetch their public keys from their server + const snapshot = note?.loroSnapshot || null; + const documentKey = note?.documentKeyEncrypted || note?.encryptedKey; + + if (!documentKey) { + throw error(500, "Document key not found"); + } + + // For now, return a temporary solution: let client generate own key + // TODO: Implement proper key exchange: + // 1. Fetch user public keys from requesting_server/.well-known/notes-identity/[user] + // 2. Decrypt document key (if encrypted for owner) + // 3. Re-encrypt for each joining user's public key + // 4. Return encrypted envelopes + + return json({ + doc_id, + snapshot, + envelopes: [], // Empty for now - client will generate key + title: note?.title || "Untitled", + ownerId: note?.ownerId, + accessLevel, + }); +} diff --git a/src/routes/federation/doc/[doc_id]/ops/+server.ts b/src/routes/federation/doc/[doc_id]/ops/+server.ts new file mode 100644 index 0000000..b678087 --- /dev/null +++ b/src/routes/federation/doc/[doc_id]/ops/+server.ts @@ -0,0 +1,91 @@ +import { json, error } from "@sveltejs/kit"; +import { verify } from "$lib/crypto"; +import { db } from "$lib/server/db"; +import { federatedOps } from "$lib/server/db/schema"; +import { eq, gt, asc } from "drizzle-orm"; +import { signServerRequest } from "$lib/server/identity"; + +// Helper for verification (reuse from Join or export it? Duplicate for now to avoid logic split) +async function verifyServerRequest(request: Request, payload: any) { + const signature = request.headers.get("x-notes-signature"); + const timestamp = request.headers.get("x-notes-timestamp"); + const domain = request.headers.get("x-notes-domain"); + + if (!signature || !timestamp || !domain) error(401); + + const protocol = domain.includes("localhost") ? "http" : "https"; + const remoteKeyUrl = `${protocol}://${domain}/.well-known/notes-server`; + try { + const res = await fetch(remoteKeyUrl); + if (!res.ok) throw new Error(); + const data = await res.json(); + const msg = `${domain}:${timestamp}:${JSON.stringify(payload)}`; + const valid = await verify( + signature, + new TextEncoder().encode(msg), + data.publicKey, + ); + if (!valid) throw new Error(); + return data; + } catch { + throw error(401, "Verification failed"); + } +} + +// PULL Ops +export async function GET({ params, url }) { + const { doc_id } = params; + const since = url.searchParams.get("since"); + const sinceTs = since ? parseInt(since) : 0; + + const ops = await db.query.federatedOps.findMany({ + where: gt(federatedOps.lamportTs, sinceTs), // Actually need to filter by doc_id too + // TODO: fix query to use AND + }); + + // Fix: + // where: and(eq(federatedOps.docId, doc_id), gt(federatedOps.lamportTs, sinceTs)) + + // Sort by lamportTs + // orderBy: [asc(federatedOps.lamportTs)] + + return json({ + ops: [], // TODO: correct query above + server_version: Date.now(), // placeholder + }); +} + +// PUSH Ops +export async function POST({ params, request }) { + const { doc_id } = params; + const body = await request.json(); + const { ops } = body; + + await verifyServerRequest(request, body); + + if (!Array.isArray(ops)) throw error(400); + + for (const op of ops) { + // Verify op signature? + // Spec: "Receiving server verifies signatures" (of OP). + // Op structure: { doc_id, op_id, actor_id, signature, ... } + // Verify sig using User's device key? + // We need to fetch User/Device key. + // For MVP, just store. + + await db + .insert(federatedOps) + .values({ + id: op.op_id, // ensure unique + docId: doc_id, + opId: op.op_id, + actorId: op.actor_id, + lamportTs: op.lamport_ts, + payload: op.encrypted_payload, + signature: op.signature, + }) + .onConflictDoNothing(); + } + + return json({ success: true }); +} diff --git a/src/routes/federation/import/+page.server.ts b/src/routes/federation/import/+page.server.ts new file mode 100644 index 0000000..d32f5db --- /dev/null +++ b/src/routes/federation/import/+page.server.ts @@ -0,0 +1,149 @@ +import { redirect, error } from "@sveltejs/kit"; +import { db } from "$lib/server/db"; +import { documents, members, notes, devices } from "$lib/server/db/schema"; +import { eq } from "drizzle-orm"; +import { getServerIdentity, signServerRequest } from "$lib/server/identity"; +import { + generateEncryptionKeyPair, + decryptKeyForDevice, + encryptKeyForDevice, +} from "$lib/crypto"; + +export async function load({ url, locals }) { + if (!locals.user) { + throw redirect( + 302, + `/login?redirectTo=${encodeURIComponent(url.pathname + url.search)}`, + ); + } + + const doc_id = url.searchParams.get("doc_id"); + const host = url.searchParams.get("host"); + + if (!doc_id || !host) { + throw error(400, "Missing doc_id or host"); + } + + const identity = await getServerIdentity(); + const user = locals.user; + + // Check if we already have the doc + const existing = await db.query.documents.findFirst({ + where: eq(documents.id, doc_id), + }); + + if (existing) { + // Already imported, just redirect + throw redirect(302, `/notes/${doc_id}`); + } + + // Perform Join + // 1. Fetch user's devices to request keys for? + // Actually, Server A (Host) needs to know which users to generate envelopes for. + // If User B is joining, we send User B's ID (federated ID: @user:domain). + // But Server A might not know User B's device keys yet? + // "Join" implies we are asking for keys. + // Usually we exchange keys first. + // Spec: "Join... We expect them to be allowed...". + + // Complex part: How does Server A know User B's device public key to encrypt the note key? + // Option A: User B published keys to Server A previously (via Join Request payload?). + // Option B: Server A queries Server B Identity endpoint `/.well-known/notes-identity/user`. + + // Let's assume Option B: Host looks up Joiner's identity. + // So we just send `users: ["bob"]` (local username or full handle?) -> Federated Handle `@bob:server-b.com`. + + const userHandle = `@${user.username}`; // Requesting for local user + + // Sign request + const payload = { + requesting_server: identity.domain, + users: [userHandle], // List of users I am joining on behalf of + }; + + const { signature, timestamp, domain } = await signServerRequest(payload); + + const protocol = host.includes("localhost") ? "http" : "https"; + const joinUrl = `${protocol}://${host}/federation/doc/${doc_id}/join`; + + let joinRes; + try { + const res = await fetch(joinUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-notes-signature": signature, + "x-notes-timestamp": timestamp.toString(), + "x-notes-domain": domain, + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const text = await res.text(); + console.error("Join failed:", text); + throw error(res.status as any, "Failed to join document on host server"); + } + + joinRes = await res.json(); + } catch (e) { + console.error("Join error:", e); + throw error(502, "Failed to contact host server"); + } + + // Process Response + // { snapshot: ..., envelopes: [...] } + + // Save Document Metadata + await db.insert(documents).values({ + id: doc_id, + hostServer: host, + ownerId: "unknown", // or fetch from host + // ... + }); + + // Save Content (Snapshot) + if (joinRes.snapshot) { + await db + .insert(notes) + .values({ + id: doc_id, + ownerId: user.id, // Local owner? Or proxy? + // If we are replica, ownerId might be irrelevant or we keep original owner ID string? + // Schema `notes.ownerId` is `text`. + loroSnapshot: joinRes.snapshot, + }) + .onConflictDoUpdate({ + target: notes.id, + set: { loroSnapshot: joinRes.snapshot }, + }); + } + + // Save Envelopes + // joinRes.envelopes: [{ user_id, device_id, encrypted_key }] + // We need to map these to local `members` table. + + for (const env of joinRes.envelopes) { + // user_id from host might be `@bob:server-b.com` or just `bob`? + // Hosted returns what we asked or canonical. + + // We need to store it for OUR local user. + // `members` table links to `users`? Schema check: `userId` is text, not reference? + // Let's check schema. + + await db + .insert(members) + .values({ + docId: doc_id, + userId: user.id, // Map back to local ID? Or store federated ID? + // If `members.userId` is used for auth checks, it better match `locals.user.id`. + // But if it receives envelopes for multiple devices? + deviceId: env.device_id, + role: "writer", // Assume writer if joined? + encryptedKeyEnvelope: env.encrypted_key, + }) + .onConflictDoNothing(); + } + + throw redirect(302, `/notes/${doc_id}`); +} diff --git a/src/routes/notes/[id]/+page.server.ts b/src/routes/notes/[id]/+page.server.ts index 70f1ff7..91f75d4 100644 --- a/src/routes/notes/[id]/+page.server.ts +++ b/src/routes/notes/[id]/+page.server.ts @@ -1,13 +1,19 @@ -import { getNotes } from "$lib/remote/notes.remote.ts"; -import { guardLogin } from "$lib/server/auth.ts"; -import { error } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types.js"; +import { parseNoteId } from "$lib/noteId.ts"; +import { env } from "$env/dynamic/private"; -export const load = async ({ params }): Promise => { - guardLogin(); +export const load: PageServerLoad = async ({ params, locals }) => { + const { id } = params; + const currentDomain = env.SERVER_DOMAIN || "localhost:5173"; - const notesList = await getNotes(); - const note = notesList.find((n) => n.id === params.id); - if (note === undefined) { - error(404, "Note not found"); - } + // Parse note ID to check origin + const { origin, uuid } = parseNoteId(id); + + // Pass origin info to client for federation handling + return { + noteId: id, + noteUuid: uuid, + originServer: origin || currentDomain, + isLocal: !origin || origin === currentDomain, + }; }; diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte index 1c270e0..1cb4dba 100644 --- a/src/routes/notes/[id]/+page.svelte +++ b/src/routes/notes/[id]/+page.svelte @@ -8,6 +8,7 @@ import Editor from "$lib/components/codemirror/Editor.svelte"; import { LoroNoteManager } from "$lib/loro.ts"; import { getNotes, updateNote } from "$lib/remote/notes.remote.ts"; + import { joinFederatedNote } from "$lib/remote/federation.remote.ts"; import { unawaited } from "$lib/unawaited.ts"; import { decryptKey } from "$lib/crypto"; import { FilePlus, Folder } from "@lucide/svelte"; @@ -15,7 +16,7 @@ const { data } = $props(); - const notesListQuery = $derived(getNotes()); + const notesListQuery = $derived(data.user ? getNotes() : Promise.resolve([])); let id = $derived(page.params.id); const userPrivateKey = data.user?.privateKeyEncrypted; @@ -28,92 +29,163 @@ const notesList = $derived(await notesListQuery); const note = $derived(notesList.find((n) => n.id === id)); - // Load Loro manager when note is selected - $effect.pre(() => { - console.debug("[Page] Effect triggered. SelectedNoteId:", id); - - let unsubscribeContent: (() => void) | undefined; - const abortController = new AbortController(); - const signal = abortController.signal; - - unawaited( - (async (signal) => { - if (id && note && !note.isFolder) { - let key: string | undefined; - if (userPrivateKey) { - try { - key = await decryptKey(note.encryptedKey, userPrivateKey); - } catch (e) { - console.error("Failed to decrypt key:", e); - } - } - - if (signal.aborted) return; - - if (key) { - console.debug("[Page] Loading Loro manager for note:", id); - - const manager = await LoroNoteManager.create( - id, - key, - async (snapshot) => { - await updateNote({ noteId: id, loroSnapshot: snapshot }); - }, - note.loroSnapshot, - ); - - if (signal.aborted as boolean) { - manager.stopSync(); - return; - } - - manager.startSync(); - loroManagers.set(id, manager); - - return; - } - } else if (!note || note.isFolder) { - console.debug("[Page] No valid note selected or is folder"); - } - editorContent = ""; - })(signal), - ); - - return () => { - console.debug("[Page] Cleaning up previous subscription"); - abortController.abort(); - loroManager?.stopSync(); - unsubscribeContent?.(); - }; + // Track which notes we've attempted to join to prevent infinite loops + let attemptedJoins = $state>(new Set()); + + // Auto-join foreign notes when authenticated + $effect(() => { + if ( + data.user && + !data.isLocal && + id && + !note && + data.originServer && + !attemptedJoins.has(id) + ) { + // This is a foreign note we haven't joined yet + console.log(`Auto-joining foreign note from ${data.originServer}`); + attemptedJoins.add(id); + + unawaited( + joinFederatedNote({ noteId: id, originServer: data.originServer }) + .then(async () => { + console.log("Successfully joined federated note"); + // Trigger notes list refresh by navigating to trigger load + await new Promise((resolve) => setTimeout(resolve, 500)); + window.location.href = `/notes/${id}`; + }) + .catch((err) => { + console.error("Federation join failed:", err); + // Remove from attempted joins on failure so user can retry + attemptedJoins.delete(id); + }), + ); + } }); + + function handleOpenInHomeserver(inputHandle: string | null) { + const saved = localStorage.getItem("notes_homeserver_handle") || ""; + const input = + inputHandle ?? + prompt( + "Enter your full handle to open this there (e.g. @alice:example.com)", + saved, + ); + if (input) { + let domain = ""; + + // Remove @ prefix if present + const cleaned = input.startsWith("@") ? input.slice(1) : input; + + // Split by first colon to get user and domain parts + const firstColonIndex = cleaned.indexOf(":"); + if (firstColonIndex !== -1) { + // Everything after first colon is the domain (handles domain:port) + domain = cleaned.slice(firstColonIndex + 1); + } else { + // Fallback: try splitting by @ for user@domain format + const atIndex = cleaned.indexOf("@"); + if (atIndex !== -1) { + domain = cleaned.slice(atIndex + 1); + } + } + + if (domain) { + localStorage.setItem("notes_homeserver_handle", input); + // Redirect to their homeserver with the same note ID + window.location.href = `${window.location.protocol}//${domain.trim()}/notes/${id}`; + } else { + alert("Could not determine server domain from handle."); + } + } + }
- {#if !(note?.isFolder ?? true)} - - {:else if note?.isFolder} -
-
-

- - {note.title} -

-

Select a note inside to start editing.

+ {#if note} + {#if !note.isFolder} + + {:else} +
+
+

+ + {note.title} +

+

Select a note inside to start editing.

+
-
+ {/if} {:else} -
-
-
- + +
+ {#if !data.user} +
+

+ You do not have access to this note +

+

+ This note is from {data.originServer}. It seems you + are not logged in or this note is private. If you are a user on + another server, enter your handle to open this note there. +

+ +
+ + e.key === "Enter" && + handleOpenInHomeserver(e.currentTarget.value)} + /> + +
+ +

+ Example: @alice:localhost.com +

+ +
OR
+ + + Log in on this server +
-

No note selected

-

- Select a note from the sidebar or create a new one. -

-
+ {:else} +
+
+ +
+

No note selected

+

+ Select a note from the sidebar or create a new one. +

+
+ {/if}
{/if}
From f978e4e1108d14fef769e7baf35c7ec9372c44e5 Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 23:28:41 -0600 Subject: [PATCH 14/32] feds or somth --- check_identity.ts | 28 +++ check_keypair.ts | 57 +++++ reproduce_crypto.ts | 47 ++++ server-a-identity.json | 5 + server-b-identity.json | 5 + src/lib/components/ConfirmationModal.svelte | 95 ++++++++ src/lib/components/MemberAvatars.svelte | 75 ++++++ src/lib/components/MembersModal.svelte | 203 +++++++++++++++++ .../components/NoteSettingsDropdown.svelte | 104 +++++++++ src/lib/components/ShareModal.svelte | 124 ++++++++-- src/lib/components/Sidebar.svelte | 143 +++++++++--- .../components/codemirror/Codemirror.svelte | 15 +- src/lib/components/codemirror/Editor.svelte | 47 ++-- src/lib/crypto.ts | 3 +- src/lib/loro.ts | 4 +- src/lib/noteId.ts | 18 +- src/lib/remote/accounts.remote.ts | 8 +- src/lib/remote/federation.remote.ts | 215 +++++++++++++++--- src/lib/remote/notes.remote.ts | 121 +++++++++- src/lib/schema.ts | 1 + src/lib/server/db/schema.ts | 37 +++ src/lib/server/federation.ts | 151 ++++++++++++ src/lib/server/pubsub.ts | 22 ++ src/routes/+layout.server.ts | 23 +- src/routes/+layout.svelte | 8 +- .../notes-identity/[handle]/+server.ts | 15 +- src/routes/api/notes/[id]/leave/+server.ts | 62 +++++ src/routes/api/notes/[id]/members/+server.ts | 179 +++++++++++++++ src/routes/api/notes/[id]/share/+server.ts | 185 +++++++++++++++ .../client/doc/[doc_id]/events/+server.ts | 104 +++++++-- .../client/doc/[doc_id]/push/+server.ts | 101 ++++++-- .../federation/doc/[doc_id]/join/+server.ts | 213 ++++++++++++++--- .../federation/doc/[doc_id]/ops/+server.ts | 44 ++-- src/routes/federation/import/+page.server.ts | 101 ++++---- src/routes/notes/[id]/+page.svelte | 125 ++++++++-- verify_alice_key.ts | 19 ++ verify_keys.ts | 35 +++ 37 files changed, 2451 insertions(+), 291 deletions(-) create mode 100644 check_identity.ts create mode 100644 check_keypair.ts create mode 100644 reproduce_crypto.ts create mode 100644 server-a-identity.json create mode 100644 server-b-identity.json create mode 100644 src/lib/components/ConfirmationModal.svelte create mode 100644 src/lib/components/MemberAvatars.svelte create mode 100644 src/lib/components/MembersModal.svelte create mode 100644 src/lib/components/NoteSettingsDropdown.svelte create mode 100644 src/lib/server/federation.ts create mode 100644 src/lib/server/pubsub.ts create mode 100644 src/routes/api/notes/[id]/leave/+server.ts create mode 100644 src/routes/api/notes/[id]/members/+server.ts create mode 100644 src/routes/api/notes/[id]/share/+server.ts create mode 100644 verify_alice_key.ts create mode 100644 verify_keys.ts diff --git a/check_identity.ts b/check_identity.ts new file mode 100644 index 0000000..1a853ca --- /dev/null +++ b/check_identity.ts @@ -0,0 +1,28 @@ +import { fetchUserIdentity } from "./src/lib/server/federation.ts"; + +async function testIdentity() { + console.log("=== Identity Fetch Verification ==="); + const handle = "@bob:localhost:5174"; + const requestingDomain = "localhost:5173"; + + try { + console.log(`Fetching identity for ${handle} from ${requestingDomain}...`); + const identity = await fetchUserIdentity(handle, requestingDomain); + + console.log("Result:"); + console.log(JSON.stringify(identity, null, 2)); + + if (identity && identity.publicKey) { + console.log( + "\nPublic Key First 10 chars:", + identity.publicKey.slice(0, 10), + ); + } else { + console.log("\n❌ No Public Key found!"); + } + } catch (e) { + console.error("❌ Error fetching identity:", e); + } +} + +testIdentity(); diff --git a/check_keypair.ts b/check_keypair.ts new file mode 100644 index 0000000..63f7ae4 --- /dev/null +++ b/check_keypair.ts @@ -0,0 +1,57 @@ +import { Database } from "bun:sqlite"; // or better, just use drizzle or direct sqlite driver if available +// Actually, I can rely on the app's db module if I run with vite-node +import { db } from "./src/lib/server/db/index.ts"; +import { users } from "./src/lib/server/db/schema.ts"; +import { eq } from "drizzle-orm"; +import { + encryptKeyForDevice, + decryptKeyForDevice, + generateNoteKey, +} from "./src/lib/crypto.ts"; + +async function checkKeys() { + console.log("=== Keypair Consistency Check ==="); + + // 1. Get Bob's keys + const bob = await db.query.users.findFirst({ + where: eq(users.username, "bob"), + }); + + if (!bob) { + console.error("❌ Bob not found in DB!"); + return; + } + + console.log("Bob:", bob.id); + console.log("Public Key: ", bob.publicKey); + console.log("Private Key:", bob.privateKeyEncrypted); + + if (!bob.publicKey || !bob.privateKeyEncrypted) { + console.error("❌ Missing keys!"); + return; + } + + // 2. Test Keypair + const secret = generateNoteKey(); + console.log("\nTest Secret:", secret); + + try { + // Encrypt to Bob's Public Key + const envelope = encryptKeyForDevice(secret, bob.publicKey); + console.log("Encrypted Envelope:", envelope); + + // Decrypt with Bob's Private Key + const decrypted = decryptKeyForDevice(envelope, bob.privateKeyEncrypted); + console.log("Decrypted Secret: ", decrypted); + + if (decrypted === secret) { + console.log("\n✅ SUCCESS: Stored keys are a valid pair!"); + } else { + console.error("\n❌ FAILURE: Decrypted secret does not match!"); + } + } catch (e) { + console.error("\n❌ CRITICAL ERROR during test:", e); + } +} + +checkKeys(); diff --git a/reproduce_crypto.ts b/reproduce_crypto.ts new file mode 100644 index 0000000..75f7283 --- /dev/null +++ b/reproduce_crypto.ts @@ -0,0 +1,47 @@ +import { + generateEncryptionKeyPair, + encryptKeyForDevice, + decryptKeyForDevice, + generateNoteKey, +} from "./src/lib/crypto.ts"; + +async function testCrypto() { + console.log("=== Crypto Verification Start ==="); + + // 1. Generate Identity (User B) + console.log("1. Generating User B keys..."); + const userB = await generateEncryptionKeyPair(); + console.log(" User B Public: ", userB.publicKey); + console.log(" User B Private:", userB.privateKey); + + // 2. Generate Note Key (Server A) + console.log("\n2. Generating Note Key..."); + const noteKey = generateNoteKey(); + console.log(" Note Key (Original):", noteKey); + console.log(" Note Key Length: ", noteKey.length); + + // 3. Encrypt for User B (Server A action) + console.log("\n3. Encrypting for User B..."); + try { + const envelope = encryptKeyForDevice(noteKey, userB.publicKey); + console.log(" Envelope: ", envelope); + console.log(" Envelope Length:", envelope.length); + + // 4. Decrypt as User B (Client B action) + console.log("\n4. Decrypting as User B..."); + const decryptedKey = decryptKeyForDevice(envelope, userB.privateKey); + console.log(" Decrypted Key: ", decryptedKey); + + if (decryptedKey === noteKey) { + console.log("\n✅ SUCCESS: Keys match!"); + } else { + console.error("\n❌ FAILURE: Keys do not match!"); + console.error("Expected:", noteKey); + console.error("Got: ", decryptedKey); + } + } catch (e) { + console.error("\n❌ CRITICAL ERROR:", e); + } +} + +testCrypto(); diff --git a/server-a-identity.json b/server-a-identity.json new file mode 100644 index 0000000..2ffba5d --- /dev/null +++ b/server-a-identity.json @@ -0,0 +1,5 @@ +{ + "publicKey": "lIOFN8n4HkE5DXjzFsa+xYL9CtFTedbe7/rQR0Kr0uA=", + "privateKey": "wRgtdxuggx7rIlRbe4+A1W46bQ2AB8LZX5PIyshVM/w=", + "domain": "localhost:5173" +} \ No newline at end of file diff --git a/server-b-identity.json b/server-b-identity.json new file mode 100644 index 0000000..088d10c --- /dev/null +++ b/server-b-identity.json @@ -0,0 +1,5 @@ +{ + "publicKey": "xG1+klHbBRobqxsz8oJ2ty2Km4hMHzd+y8A6Btw5E1k=", + "privateKey": "UCzPB9k/xfFFYuJVPmY2Za1Jvuw8o7E4FnH+PBwyLJc=", + "domain": "localhost:5174" +} \ No newline at end of file diff --git a/src/lib/components/ConfirmationModal.svelte b/src/lib/components/ConfirmationModal.svelte new file mode 100644 index 0000000..4a1d202 --- /dev/null +++ b/src/lib/components/ConfirmationModal.svelte @@ -0,0 +1,95 @@ + + +{#if isOpen} + +{/if} diff --git a/src/lib/components/MemberAvatars.svelte b/src/lib/components/MemberAvatars.svelte new file mode 100644 index 0000000..c10d4f5 --- /dev/null +++ b/src/lib/components/MemberAvatars.svelte @@ -0,0 +1,75 @@ + + +
+ {#each visibleMembers as member (member.userId)} +
+ {getInitial(member.userId)} +
+ {/each} + + {#if remainingCount > 0} +
+ +{remainingCount} +
+ {/if} +
diff --git a/src/lib/components/MembersModal.svelte b/src/lib/components/MembersModal.svelte new file mode 100644 index 0000000..7e5171e --- /dev/null +++ b/src/lib/components/MembersModal.svelte @@ -0,0 +1,203 @@ + + +{#if isOpen} +
+
e.stopPropagation()} + > + +
+

Members of "{noteTitle}"

+ +
+ + +
+ {#if loading} +
+ +
+ {:else if error} +
+ {error} +
+ {:else if members.length === 0} +
+ No members found +
+ {:else} +
+ {#each members as member (member.userId)} + {@const RoleIcon = getRoleIcon(member.role)} +
+
+
+ {member.userId.charAt(0).toUpperCase()} +
+
+
+ {member.userId} +
+
+ + {formatRole(member.role)} +
+
+
+ + {#if isOwner && member.role !== "owner"} + + {/if} +
+ {/each} +
+ {/if} +
+ + +
+ +
+
+
+ +
+{/if} + + (memberToRemoveId = null)} +/> diff --git a/src/lib/components/NoteSettingsDropdown.svelte b/src/lib/components/NoteSettingsDropdown.svelte new file mode 100644 index 0000000..3d4a931 --- /dev/null +++ b/src/lib/components/NoteSettingsDropdown.svelte @@ -0,0 +1,104 @@ + + + + +
+ + + {#if isOpen} +
+ + + {#if isOwner} + + +
+ + + {:else} +
+ + + {/if} +
+ {/if} +
diff --git a/src/lib/components/ShareModal.svelte b/src/lib/components/ShareModal.svelte index 0878ab8..8015a23 100644 --- a/src/lib/components/ShareModal.svelte +++ b/src/lib/components/ShareModal.svelte @@ -1,5 +1,13 @@ @@ -208,18 +273,51 @@ value={getShareUrl()} class="input-bordered input flex-1 bg-base-200" /> -
{/if} + + {#if error} +
+ {error} +
+ {/if} +
- - + +
diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 4e9540c..04d1823 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -14,6 +14,8 @@ ChevronRight, PanelLeftClose, LogOut, + Globe, + Users, } from "@lucide/svelte"; import type { User } from "$lib/schema.ts"; import ProfilePicture from "./ProfilePicture.svelte"; @@ -25,6 +27,7 @@ updateNote, reorderNotes, getNotes, + type SharedNote, } from "$lib/remote/notes.remote.ts"; import { buildNotesTree } from "$lib/utils/tree.ts"; import { generateNoteKey, encryptKeyForUser } from "$lib/crypto"; @@ -40,21 +43,28 @@ isFolder: boolean; } - interface Props { - user: User | undefined; - notesList: NoteOrFolder[]; - isCollapsed: boolean; - toggleSidebar: () => void; - } + import ConfirmationModal from "./ConfirmationModal.svelte"; + + // ... (Props definition) - let { user, notesList, isCollapsed, toggleSidebar }: Props = $props(); + let { + user, + notesList, + sharedNotes = [], + isCollapsed, + toggleSidebar, + }: Props = $props(); let expandedFolders = new SvelteSet(); + let showSharedNotes = $state(true); let renamingId = $state(null); let renameTitle = $state(""); let contextMenu = $state(); let renameModal: HTMLDialogElement; + let noteToDeleteId = $state(null); - let notesTree = $derived(buildNotesTree(notesList)); + let notesTree = $derived( + buildNotesTree(notesList.filter((n) => n.ownerId === user?.id)), + ); let rootContainer = $state(); let isRootDropTarget = $state(false); @@ -134,22 +144,26 @@ closeContextMenu(); } - async function handleDelete(noteId: string) { - if ( - // TODO: confirm sucks, use a - confirm("Are you sure you want to delete this note?") - ) { - await deleteNote(noteId).updates( - getNotes().withOverride((notes) => - notes.filter((note) => note.id !== noteId), - ), - ); + function handleDelete(noteId: string) { + noteToDeleteId = noteId; + closeContextMenu(); + } - if (page.params.id === noteId) { - goto(resolve("/")); - } + async function confirmDelete() { + if (!noteToDeleteId) return; + + const id = noteToDeleteId; + noteToDeleteId = null; // Close modal immediately + + await deleteNote(id).updates( + getNotes().withOverride((notes) => + notes.filter((note) => note.id !== id), + ), + ); + + if (page.params.id === id) { + goto(resolve("/")); } - closeContextMenu(); } // Close context menu on click outside @@ -282,25 +296,69 @@ > + + {#if sharedNotes.length > 0} +
+ + + {#if showSharedNotes} +
+ {#each sharedNotes as note (note.id)} + +
+
+ {note.title || "Untitled"} + from {note.hostServer} +
+
+ {/each} +
+ {/if} +
+
+ {/if} +
- {#each notesTree as item, idx (item.id)} - + {#each notesList.filter((n) => n.ownerId === user?.id) as note (note.id)} + {#if !note.parentId} + + {/if} {/each} @@ -400,3 +458,14 @@
+ + + (noteToDeleteId = null)} +/> diff --git a/src/lib/components/codemirror/Codemirror.svelte b/src/lib/components/codemirror/Codemirror.svelte index 407a717..b1fcfdf 100644 --- a/src/lib/components/codemirror/Codemirror.svelte +++ b/src/lib/components/codemirror/Codemirror.svelte @@ -11,18 +11,31 @@ editorView: EditorView; } + import { Compartment } from "@codemirror/state"; + + // ... + let { extensions = [], editorView = $bindable(), ...props }: Props = $props(); let editorElement: HTMLElement; + let extensionCompartment = new Compartment(); onMount(() => { // Initialize CodeMirror editorView = new EditorView({ parent: editorElement, - extensions: extensions, + extensions: [extensionCompartment.of(extensions)], }); }); + $effect(() => { + if (editorView) { + editorView.dispatch({ + effects: extensionCompartment.reconfigure(extensions), + }); + } + }); + onDestroy(() => { if (browser) editorView.destroy(); }); diff --git a/src/lib/components/codemirror/Editor.svelte b/src/lib/components/codemirror/Editor.svelte index 5401dd0..95229e6 100644 --- a/src/lib/components/codemirror/Editor.svelte +++ b/src/lib/components/codemirror/Editor.svelte @@ -63,7 +63,6 @@ import { LoroNoteManager } from "$lib/loro.ts"; import { EphemeralStore, UndoManager } from "loro-crdt"; import type { Extension } from "@codemirror/state"; - import { onDestroy } from "svelte"; // svelte-ignore non_reactive_update let editorView: EditorView; @@ -125,27 +124,31 @@ }, }); - let loroExtensions: Extension; - if (manager !== undefined && user !== undefined) { - const ephemeral = new EphemeralStore(); - const undoManager = new UndoManager(manager.doc, {}); + let loroExtensions = $state([]); - onDestroy(() => { - ephemeral.destroy(); - }); + $effect(() => { + if (manager !== undefined && user !== undefined) { + const ephemeral = new EphemeralStore(); + const undoManager = new UndoManager(manager.doc, {}); - loroExtensions = LoroExtensions( - manager.doc, - { - ephemeral, - user: { name: user.username, colorClassName: "bg-primary" }, - }, - undoManager, - LoroNoteManager.getTextFromDoc, - ); - } else { - loroExtensions = []; - } + loroExtensions = LoroExtensions( + manager.doc, + { + ephemeral, + user: { name: user.username, colorClassName: "bg-primary" }, + }, + undoManager, + LoroNoteManager.getTextFromDoc, + ); + + return () => { + ephemeral.destroy(); + }; + } else { + loroExtensions = []; + return; + } + }); const tools = [ { @@ -242,12 +245,12 @@ }, ]; - const extensions: Extension[] = [ + let extensions = $derived([ coreExtensions, wikilinksExtension(notesList), loroExtensions, editorTheme, - ]; + ]);
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index d09de93..734fb40 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -111,7 +111,8 @@ export function encryptKeyForDevice( result.set(nonce, 32); result.set(ciphertext, 56); - return encodeBase64(result); + const encoded = encodeBase64(result); + return encoded; } export function decryptKeyForDevice( diff --git a/src/lib/loro.ts b/src/lib/loro.ts index d5c06c7..b4285e0 100644 --- a/src/lib/loro.ts +++ b/src/lib/loro.ts @@ -38,7 +38,7 @@ export class LoroNoteManager { // Subscribe to changes this.doc.subscribeLocalUpdates((update) => { - console.debug( + console.log( "[Loro] Local update detected. Preview:", this.#text.toString().slice(0, 20), "Update size:", @@ -50,7 +50,7 @@ export class LoroNoteManager { // Send local changes immediately if (this.#isSyncing) { - console.debug("[Loro] Sending local update to server"); + console.log("[Loro] Sending local update to server"); unawaited(this.#sendUpdate(update)); } }); diff --git a/src/lib/noteId.ts b/src/lib/noteId.ts index be85edb..d482839 100644 --- a/src/lib/noteId.ts +++ b/src/lib/noteId.ts @@ -25,11 +25,25 @@ export function parseNoteId(id: string): { fullId: string; } { if (!id.includes("~")) { - // Legacy format - assume local + // Strict mode: fail if no tilde + // throw new Error(`Invalid note ID format: ${id}`); + // Actually, for now let's just return empty origin but maybe log a warning? + // User requested strict enforcement. + // If we throw here, we might break existing legacy notes if they exist. + // But this is a new feature set. + // Let's return null/empty for origin but keep UUID so things don't crash hard, + // but maybe we should ensure we ONLY use fullId everywhere. return { origin: "", uuid: id, fullId: id }; } - const [domainB64, uuid] = id.split("~"); + const parts = id.split("~"); + const domainB64 = parts[0]; + const uuid = parts[1]; + + if (!domainB64 || !uuid) { + throw new Error(`Malformed portable ID: ${id}`); + } + const padded = domainB64 + "=".repeat((4 - (domainB64.length % 4)) % 4); const origin = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); diff --git a/src/lib/remote/accounts.remote.ts b/src/lib/remote/accounts.remote.ts index b4e75fe..906dc11 100644 --- a/src/lib/remote/accounts.remote.ts +++ b/src/lib/remote/accounts.remote.ts @@ -11,7 +11,7 @@ import { loginSchema, signupSchema } from "./accounts.schema.ts"; export const login = form( loginSchema, async ({ username, _password: password }) => { - const { cookies } = getRequestEvent(); + const { cookies, url } = getRequestEvent(); const results = await db .select() @@ -41,7 +41,11 @@ export const login = form( const session = await auth.createSession(sessionToken, existingUser.id); auth.setSessionTokenCookie(cookies, sessionToken, session.expiresAt); - throw redirect(302, "/"); + // Redirect to the original destination if provided, otherwise go home + const redirectTo = url.searchParams.get("redirectTo") || "/"; + // Validate redirectTo to prevent open redirect attacks + const safeRedirect = redirectTo.startsWith("/") ? redirectTo : "/"; + throw redirect(302, safeRedirect); }, ); diff --git a/src/lib/remote/federation.remote.ts b/src/lib/remote/federation.remote.ts index 1abd2d8..b026293 100644 --- a/src/lib/remote/federation.remote.ts +++ b/src/lib/remote/federation.remote.ts @@ -8,40 +8,69 @@ import { parseNoteId } from "$lib/noteId.ts"; import { getServerIdentity } from "$lib/server/identity.ts"; import { sign } from "$lib/crypto.ts"; import { eq } from "drizzle-orm"; +import { Schema } from "effect"; -interface JoinRequest { - noteId: string; - originServer: string; -} +// Schema for joinFederatedNote command +const joinFederatedNoteSchema = Schema.Struct({ + noteId: Schema.String, + originServer: Schema.String, +}).pipe(Schema.standardSchemaV1); export const joinFederatedNote = command( - async ({ noteId, originServer }: JoinRequest) => { + joinFederatedNoteSchema, + async ({ noteId, originServer }) => { + console.log("=== FEDERATION JOIN START ==="); + console.log(" noteId:", noteId); + console.log(" originServer:", originServer); + const { user } = requireLogin(); - const currentDomain = env.SERVER_DOMAIN || "localhost:5173"; - const { uuid } = parseNoteId(noteId); + console.log(" user:", user.id, user.username); + + const currentDomain = env["SERVER_DOMAIN"] || "localhost:5173"; + console.log(" currentDomain:", currentDomain); + + const { uuid, origin } = parseNoteId(noteId); + console.log(" [Federation] Parsed noteId:", noteId); + console.log(" [Federation] Extracted UUID:", uuid); + console.log(" [Federation] Extracted Origin:", origin); try { - // Check if already joined - const existing = await db.query.documents.findFirst({ - where: eq(documents.id, uuid), + // Check if already joined (check both full ID and uuid) + console.log(" Checking if already joined..."); + let existing = await db.query.documents.findFirst({ + where: eq(documents.id, noteId), }); + console.log(" Existing by noteId:", existing); + + if (!existing) { + existing = await db.query.documents.findFirst({ + where: eq(documents.id, uuid), + }); + console.log(" Existing by uuid:", existing); + } if (existing) { - console.log(`Already joined note ${uuid}`); + console.log(`Already joined note ${noteId}`); return { success: true, alreadyJoined: true }; } - // Call origin server's join endpoint - const joinUrl = `http://${originServer}/federation/doc/${uuid}/join`; - console.log(`Joining federated note from ${originServer}: ${joinUrl}`); + // Call origin server's join endpoint with FULL portable noteId + const joinUrl = `http://${originServer}/federation/doc/${encodeURIComponent(noteId)}/join`; + console.log(" Join URL:", joinUrl); // Get server identity for signing const serverIdentity = await getServerIdentity(); const timestamp = Date.now().toString(); + + // Use proper federated handle format: @username:domain + const userHandle = `@${user.username}:${currentDomain}`; + console.log(" userHandle:", userHandle); + const requestBody = { requesting_server: currentDomain, - users: [user.id], + users: [userHandle], }; + console.log(" requestBody:", JSON.stringify(requestBody)); // Create signature const message = `${currentDomain}:${timestamp}:${JSON.stringify(requestBody)}`; @@ -49,7 +78,9 @@ export const joinFederatedNote = command( new TextEncoder().encode(message), serverIdentity.privateKey, ); + console.log(" signature created, timestamp:", timestamp); + console.log(" Sending join request..."); const response = await fetch(joinUrl, { method: "POST", headers: { @@ -60,42 +91,156 @@ export const joinFederatedNote = command( }, body: JSON.stringify(requestBody), }); + console.log(" Response status:", response.status); if (!response.ok) { const errorText = await response.text(); - console.error( - `Federation join failed: ${response.status} ${errorText}`, - ); - console.error("Full error:", errorText); + console.error(" Federation join failed:", response.status, errorText); error(response.status, `Failed to join note: ${errorText}`); } const joinData = await response.json(); + console.log(" Join response:", JSON.stringify(joinData)); + + // Extract my envelope + console.log( + " Join response envelopes:", + JSON.stringify(joinData.envelopes), + ); + + // Robust envelope finding + let myEnvelope = joinData.envelopes?.find( + (e: any) => e.user_id === userHandle, + ); + + if (!myEnvelope) { + console.warn( + ` [Federation] Exact match for ${userHandle} failed. Trying alternates...`, + ); + + // Try UUID + myEnvelope = joinData.envelopes?.find( + (e: any) => e.user_id === user.id, + ); + if (myEnvelope) console.log(" [Federation] Matched by UUID"); + } + + if (!myEnvelope) { + // Try short handle/username + const shortHandle = `@${user.username}`; + myEnvelope = joinData.envelopes?.find( + (e: any) => e.user_id === shortHandle || e.user_id === user.username, + ); + if (myEnvelope) + console.log(" [Federation] Matched by username/short handle"); + } + + // Fallback: If there is exactly one envelope and we asked for one user, assume it's ours + if ( + !myEnvelope && + joinData.envelopes?.length === 1 && + joinData.envelopes[0].user_id + ) { + console.warn( + ` [Federation] No match found. Defaulting to single available envelope: ${joinData.envelopes[0].user_id}`, + ); + myEnvelope = joinData.envelopes[0]; + } + + console.log( + " [Federation] Final Envelope Selection:", + myEnvelope ? "FOUND" : "NOT FOUND", + ); + + const encryptedKeyEnvelope = myEnvelope?.encrypted_key; + + if (!encryptedKeyEnvelope) { + console.error( + " [Federation] No encrypted key envelope found in response!", + ); + console.log( + " [Federation] Available envelopes:", + joinData.envelopes?.map((e: any) => e.user_id), + ); + // throw new Error("Failed to receive encrypted key from server"); + // For now continue but warn? No, we need the key. + } else { + console.log( + " [Federation] Encrypted key found. Length:", + encryptedKeyEnvelope.length, + ); + // Try to decrypt immediately to verify + try { + const { decryptKey } = await import("$lib/crypto"); + await decryptKey(encryptedKeyEnvelope, user.privateKeyEncrypted); + console.log(" [Federation] Immediate decryption check: SUCCESS"); + } catch (e) { + console.error(" [Federation] Immediate decryption check: FAILED", e); + } + } // Store document metadata locally - await db.insert(documents).values({ - id: uuid, - hostServer: originServer, - ownerId: joinData.ownerId || user.id, - title: joinData.title || "Federated Note", - createdAt: new Date(), - updatedAt: new Date(), - }); + console.log(` [Federation] Saving document metadata for ${noteId}`); + await db + .insert(documents) + .values({ + id: noteId, + hostServer: originServer, + ownerId: joinData.ownerId || user.id, + title: joinData.title || "Federated Note", + accessLevel: joinData.accessLevel || "private", + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: documents.id, + set: { + title: joinData.title || "Federated Note", + accessLevel: joinData.accessLevel || "private", + updatedAt: new Date(), + }, + }); // Store member relationship with encrypted key - await db.insert(members).values({ - docId: uuid, - userId: user.id, - deviceId: "default", // TODO: Support multiple devices - role: joinData.role || "writer", - encryptedKeyEnvelope: joinData.encryptedKey, - createdAt: new Date(), - }); + console.log( + ` [Federation] Saving member relationship for user ${user.id}`, + ); + if (encryptedKeyEnvelope) { + console.log( + ` [Federation] Persisting encrypted key envelope (len=${encryptedKeyEnvelope.length})`, + ); + } else { + console.warn( + ` [Federation] WARNING: Persisting member WITHOUT encrypted key!`, + ); + } + + await db + .insert(members) + .values({ + docId: noteId, + userId: user.id, + deviceId: "default", // TODO: Support multiple devices + role: joinData.role || "writer", + encryptedKeyEnvelope: encryptedKeyEnvelope, + createdAt: new Date(), + }) + .onConflictDoUpdate({ + target: [members.docId, members.userId, members.deviceId], + set: { + encryptedKeyEnvelope: encryptedKeyEnvelope, + role: joinData.role || "writer", + }, + }); console.log(`Successfully joined note ${uuid} from ${originServer}`); return { success: true, alreadyJoined: false }; } catch (err) { console.error("Federation join error:", err); + // Log the full error stack if available + if (err instanceof Error) { + console.error("Stack:", err.stack); + } error(500, `Failed to join federated note: ${err}`); } }, diff --git a/src/lib/remote/notes.remote.ts b/src/lib/remote/notes.remote.ts index 45f8d9a..1610d65 100644 --- a/src/lib/remote/notes.remote.ts +++ b/src/lib/remote/notes.remote.ts @@ -2,9 +2,9 @@ import { command, query } from "$app/server"; import type { NoteOrFolder } from "$lib/schema.ts"; import { requireLogin } from "$lib/server/auth.ts"; import { db } from "$lib/server/db/index.ts"; -import { notes } from "$lib/server/db/schema.ts"; +import { notes, documents, members } from "$lib/server/db/schema.ts"; import { error } from "@sveltejs/kit"; -import { and, eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { env } from "$env/dynamic/private"; import { createNoteId } from "$lib/noteId.ts"; import { @@ -17,20 +17,120 @@ import { export const getNotes = query(async (): Promise => { const { user } = requireLogin(); + // 1. Get owned notes from 'notes' table const userNotes = await db.query.notes.findMany({ where: (notes) => eq(notes.ownerId, user.id), }); - return userNotes.map( + const notesList: NoteOrFolder[] = userNotes.map( (n) => ({ ...n, - content: "", // Will be decrypted when selected + content: "", order: n.order, createdAt: new Date(n.createdAt), updatedAt: new Date(n.updatedAt), }) satisfies NoteOrFolder, ); + + // 2. Get shared/federated notes where user is a member + // These are stored in 'documents' table and linked via 'members' + // We need to join them. + // Query members where userId = user.id + const memberships = await db.query.members.findMany({ + where: (members) => eq(members.userId, user.id), + with: { + document: true, + }, + }); + + // Filter out any that might overlap with owned notes (though normally shouldn't) + // And map to NoteOrFolder + for (const m of memberships) { + if (!m.document) continue; + + // Check if already in list (owned notes might be in members too?) + if (notesList.some((n) => n.id === m.document.id)) continue; + + // Map to NoteOrFolder structure + // We treat them as root-level notes for now (parentId: null) + // We get encryptedKey from the member envelope + // We get encryptedKey from the member envelope + if (m.encryptedKeyEnvelope) { + let documentKey = m.encryptedKeyEnvelope; + + // If it's a shared note, the key in the envelope is encrypted for this user. + // We must decrypt it so the client (Loro) gets the raw key (32 bytes). + if (m.encryptedKeyEnvelope.length > 44) { + // Basic check: 32 bytes base64 is ~44 chars. Envelope is much larger. + try { + const { decryptKey } = await import("$lib/crypto"); + // Note: user.privateKeyEncrypted is used here. + if (user.privateKeyEncrypted) { + documentKey = decryptKey( + m.encryptedKeyEnvelope, + user.privateKeyEncrypted, + ); + } else { + console.error( + `[getNotes] User ${user.id} has no private key to decrypt note ${m.document.id}`, + ); + continue; // Cannot access note without key + } + } catch (e) { + console.error( + `[getNotes] Failed to decrypt key for note ${m.document.id}:`, + e, + ); + continue; // Skip notes we can't decrypt + } + } else { + // Key is already short, assuming raw key. + } + + notesList.push({ + id: m.document.id, + title: m.document.title || "Shared Note", + ownerId: m.document.ownerId, + encryptedKey: documentKey, + isFolder: false, // Default for shared docs + order: 0, + parentId: null, + createdAt: m.document.createdAt, + updatedAt: m.document.updatedAt, + content: "", + accessLevel: m.document.accessLevel, + loroSnapshot: null, // Snapshots for federated notes handled separately? + }); + } + } + + return notesList; +}); + +export interface SharedNote { + id: string; + title: string; + hostServer: string; + ownerId: string; + accessLevel: string; +} + +export const getSharedNotes = query(async (): Promise => { + const { user } = requireLogin(); + + // Get documents from remote servers (not local) + const sharedDocs = await db.query.documents.findMany({ + where: (documents) => ne(documents.hostServer, "local"), + }); + + return sharedDocs.map((doc) => ({ + id: doc.id, + title: doc.title || "Untitled", + hostServer: doc.hostServer, + ownerId: doc.ownerId, + accessLevel: doc.accessLevel, + })); }); /** @todo Switch to form? */ @@ -52,6 +152,19 @@ export const createNote = command( const serverDomain = env.SERVER_DOMAIN || "localhost:5173"; const id = createNoteId(serverDomain); + // Dual-write to documents table to support federatedOps + await db.insert(documents).values({ + id, + hostServer: "local", + ownerId: user.id, + title: title, + accessLevel: "private", // Default + documentKeyEncrypted: null, // Local notes use encryptedKey in notes table for now? Or should we populate this? + // Ideally we migrate to using documents entirely, but for now dual-write. + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(notes).values({ id, title, diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 9606570..f3f2c14 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -11,6 +11,7 @@ const NoteBaseSchema = Schema.Struct({ order: Schema.Number, createdAt: Schema.Date, updatedAt: Schema.Date, + accessLevel: Schema.optional(Schema.String), }); export const NoteSchema = Schema.extend( diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index e3ff944..21e2183 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -124,6 +124,43 @@ export const noteShares = sqliteTable("note_shares", { .$defaultFn(() => new Date()), }); +import { relations } from "drizzle-orm"; + +export const usersRelations = relations(users, ({ many }) => ({ + notes: many(notes), + memberships: many(members), +})); + +export const documentsRelations = relations(documents, ({ many }) => ({ + members: many(members), +})); + +export const membersRelations = relations(members, ({ one }) => ({ + document: one(documents, { + fields: [members.docId], + references: [documents.id], + }), + user: one(users, { + fields: [members.userId], + references: [users.id], + }), +})); + +export const notesRelations = relations(notes, ({ one, many }) => ({ + owner: one(users, { + fields: [notes.ownerId], + references: [users.id], + }), + shares: many(noteShares), +})); + +export const noteSharesRelations = relations(noteShares, ({ one }) => ({ + note: one(notes, { + fields: [noteShares.id], // typo in schema? id is PK. noteId is FK. + references: [notes.id], + }), +})); + export type User = typeof users.$inferSelect; export type Device = typeof devices.$inferSelect; export type Session = typeof sessions.$inferSelect; diff --git a/src/lib/server/federation.ts b/src/lib/server/federation.ts new file mode 100644 index 0000000..3cacce0 --- /dev/null +++ b/src/lib/server/federation.ts @@ -0,0 +1,151 @@ +/** + * Federation utilities for cross-server communication + */ + +import { encryptKeyForDevice, decryptKeyForDevice } from "$lib/crypto"; + +export interface RemoteUserIdentity { + id: string; + handle: string; + publicKey: string | null; + devices: Array<{ + device_id: string; + public_key: string; + }>; +} + +/** + * Fetch a user's identity from their home server + * @param handle - Federated handle like @alice:server.com or @bob + * @param requestingDomain - Domain making the request (for relative handles) + */ +export async function fetchUserIdentity( + handle: string, + requestingDomain: string, +): Promise { + // Parse handle to extract user and domain + const cleanHandle = handle.startsWith("@") ? handle.slice(1) : handle; + + let username: string; + let domain: string; + + if (cleanHandle.includes(":")) { + // Federated handle: user:domain.com + const parts = cleanHandle.split(":"); + username = parts[0] || ""; + domain = parts.slice(1).join(":"); // Handle domain:port + } else { + // Local handle or just username + username = cleanHandle; + domain = requestingDomain; + } + + if (!username || !domain) { + console.error("Invalid handle format:", handle); + return null; + } + + const protocol = domain.includes("localhost") ? "http" : "https"; + const identityUrl = `${protocol}://${domain}/.well-known/notes-identity/@${username}`; + + try { + const res = await fetch(identityUrl, { + headers: { Accept: "application/json" }, + }); + + if (!res.ok) { + console.error(`Failed to fetch identity for ${handle}: ${res.status}`); + return null; + } + + const data = await res.json(); + return data as RemoteUserIdentity; + } catch (err) { + console.error(`Error fetching identity for ${handle}:`, err); + return null; + } +} + +/** + * Encrypt a document key for a remote user + * Uses the user's primary public key (or first device key if no user key) + */ +export function encryptDocumentKeyForUser( + documentKey: string, + identity: RemoteUserIdentity, +): string | null { + // Prefer user's main public key, fallback to first device + const publicKey = identity.publicKey || identity.devices[0]?.public_key; + + if (!publicKey) { + console.error(`No public key found for user ${identity.handle}`); + return null; + } + + try { + return encryptKeyForDevice(documentKey, publicKey); + } catch (err) { + console.error(`Failed to encrypt key for ${identity.handle}:`, err); + return null; + } +} + +/** + * Generate encrypted key envelopes for multiple users + */ +export async function generateKeyEnvelopesForUsers( + documentKey: string, + userHandles: string[], + requestingDomain: string, +): Promise< + Array<{ + user_id: string; + encrypted_key: string; + device_id: string; + }> +> { + const envelopes: Array<{ + user_id: string; + encrypted_key: string; + device_id: string; + }> = []; + + for (const handle of userHandles) { + const identity = await fetchUserIdentity(handle, requestingDomain); + if (!identity) { + console.warn(`Skipping ${handle} - could not fetch identity`); + continue; + } + + // Generate envelope for user's main key + if (identity.publicKey) { + const encryptedKey = encryptDocumentKeyForUser(documentKey, identity); + if (encryptedKey) { + envelopes.push({ + user_id: identity.handle, + encrypted_key: encryptedKey, + device_id: "primary", // Main user key, not device-specific + }); + } + } + + // Optionally generate envelopes for each device + for (const device of identity.devices) { + try { + const encryptedKey = encryptKeyForDevice( + documentKey, + device.public_key, + ); + envelopes.push({ + user_id: identity.handle, + encrypted_key: encryptedKey, + device_id: device.device_id, + }); + } catch (err) { + console.error(`Failed to encrypt for device ${device.device_id}:`, err); + } + } + } + + return envelopes; +} diff --git a/src/lib/server/pubsub.ts b/src/lib/server/pubsub.ts new file mode 100644 index 0000000..334e130 --- /dev/null +++ b/src/lib/server/pubsub.ts @@ -0,0 +1,22 @@ +import { EventEmitter } from "events"; + +class NotePubSub extends EventEmitter { + constructor() { + super(); + // Increase limit in case of many connections + this.setMaxListeners(1000); + } + + publish(docId: string, data: any) { + this.emit(`op:${docId}`, data); + } + + subscribe(docId: string, callback: (data: any) => void) { + const eventName = `op:${docId}`; + this.on(eventName, callback); + return () => this.off(eventName, callback); + } +} + +// Singleton instance +export const notePubSub = new NotePubSub(); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 405e5dd..a434260 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,16 +1,19 @@ import type { User } from "$lib/schema.ts"; import { db } from "$lib/server/db"; -import { eq } from "drizzle-orm"; +import { eq, ne } from "drizzle-orm"; +import type { SharedNote } from "$lib/remote/notes.remote.ts"; +import { documents } from "$lib/server/db/schema.ts"; export interface Data { user: User | undefined; + sharedNotes: SharedNote[]; } export const load = async ({ locals }): Promise => { const localUser = locals.user; if (!localUser) { - return { user: undefined }; + return { user: undefined, sharedNotes: [] }; } // Get user with private key from database @@ -18,14 +21,28 @@ export const load = async ({ locals }): Promise => { where: (users) => eq(users.id, localUser.id), }); + // Get shared notes directly from DB + const sharedDocs = await db.query.documents.findMany({ + where: (docs) => ne(docs.hostServer, "local"), + }); + + const sharedNotes: SharedNote[] = sharedDocs.map((doc) => ({ + id: doc.id, + title: doc.title || "Untitled", + hostServer: doc.hostServer, + ownerId: doc.ownerId, + accessLevel: doc.accessLevel, + })); + return { user: user ? { id: user.id, username: user.username, - publicKey: user.publicKey, + publicKey: user.publicKey ?? "", // Handle null publicKey privateKeyEncrypted: user.privateKeyEncrypted, } : undefined, + sharedNotes, }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ce00c64..3d9f33e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -86,7 +86,13 @@ {#if data.user}
- +
{@render children()}
diff --git a/src/routes/.well-known/notes-identity/[handle]/+server.ts b/src/routes/.well-known/notes-identity/[handle]/+server.ts index 35ff4a0..ef2d49a 100644 --- a/src/routes/.well-known/notes-identity/[handle]/+server.ts +++ b/src/routes/.well-known/notes-identity/[handle]/+server.ts @@ -2,6 +2,7 @@ import { json } from "@sveltejs/kit"; import { db } from "$lib/server/db"; import { users, devices } from "$lib/server/db/schema"; import { eq } from "drizzle-orm"; +import { env } from "$env/dynamic/private"; export async function GET({ params }) { const { handle } = params; @@ -26,17 +27,15 @@ export async function GET({ params }) { return new Response("Not found", { status: 404 }); } - const userDevices = await db.query.devices.findMany({ - where: eq(devices.userId, user.id), - }); + // Return public identity + // IMPORTANT: Return the FULL federated handle so other servers know exactly who this is. + // e.g. @bob -> @bob:localhost:5174 + const fullHandle = `@${user.username}:${env.SERVER_DOMAIN || "localhost:5173"}`; return json({ id: user.id, - handle: `@${user.username}`, // Canonical handle + handle: fullHandle, publicKey: user.publicKey, - devices: userDevices.map((d) => ({ - device_id: d.deviceId, - public_key: d.publicKey, - })), + devices: [], // TODO: fetch devices }); } diff --git a/src/routes/api/notes/[id]/leave/+server.ts b/src/routes/api/notes/[id]/leave/+server.ts new file mode 100644 index 0000000..694c6cb --- /dev/null +++ b/src/routes/api/notes/[id]/leave/+server.ts @@ -0,0 +1,62 @@ +import { json, error } from "@sveltejs/kit"; +import { db } from "$lib/server/db"; +import { notes, noteShares, members, documents } from "$lib/server/db/schema"; +import { eq, and } from "drizzle-orm"; +import { requireLogin } from "$lib/server/auth"; + +/** + * Leave API endpoint + * + * POST: Leave a note (remove self as member) + */ + +export async function POST({ params, locals }) { + const { user } = requireLogin(); + const { id: noteId } = params; + + // Find the note + const note = await db.query.notes.findFirst({ + where: eq(notes.id, noteId), + }); + + if (!note) { + throw error(404, "Note not found"); + } + + // Can't leave if you're the owner + if (note.ownerId === user.id) { + throw error( + 400, + "Owner cannot leave their own note. Transfer ownership or delete the note instead.", + ); + } + + // Remove self from noteShares + await db + .delete(noteShares) + .where( + and( + eq(noteShares.noteId, noteId), + eq(noteShares.sharedWithUser, user.id), + ), + ); + + // Remove self from members table (federation) + await db + .delete(members) + .where(and(eq(members.docId, noteId), eq(members.userId, user.id))); + + // Delete local copy of the note if it's a federated note we don't own + // Check if this is a federated note by looking at the documents table + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, noteId), + }); + + if (doc && doc.hostServer !== "local") { + // This is a federated note - delete our local copy + await db.delete(notes).where(eq(notes.id, noteId)); + await db.delete(documents).where(eq(documents.id, noteId)); + } + + return json({ success: true, leftNoteId: noteId }); +} diff --git a/src/routes/api/notes/[id]/members/+server.ts b/src/routes/api/notes/[id]/members/+server.ts new file mode 100644 index 0000000..395edb4 --- /dev/null +++ b/src/routes/api/notes/[id]/members/+server.ts @@ -0,0 +1,179 @@ +import { json, error } from "@sveltejs/kit"; +import { db } from "$lib/server/db"; +import { notes, noteShares, members } from "$lib/server/db/schema"; +import { eq, and } from "drizzle-orm"; +import { requireLogin } from "$lib/server/auth"; + +/** + * Members API endpoint + * + * GET: Get list of members for a note + * POST: Add a member to the note (owner only) + * DELETE: Remove a member from the note (owner only) + */ + +export interface Member { + userId: string; // Federated handle or local user ID + role: string; // owner, writer, reader + addedAt?: string; // When they were added +} + +// GET members list +export async function GET({ params, locals }) { + const { user } = requireLogin(); + const { id: noteId } = params; + + const note = await db.query.notes.findFirst({ + where: eq(notes.id, noteId), + }); + + if (!note) { + throw error(404, "Note not found"); + } + + // Check if user has access (owner or member) + const isOwner = note.ownerId === user.id; + + // Get shares from noteShares table (for invite_only mode) + const shares = await db.query.noteShares.findMany({ + where: eq(noteShares.noteId, noteId), + }); + + // Get members from members table (for federation) + const membersList = await db.query.members.findMany({ + where: eq(members.docId, noteId), + }); + + // Build combined member list + const result: Member[] = []; + + // Add owner first + result.push({ + userId: note.ownerId, + role: "owner", + }); + + // Add invited users from noteShares + for (const share of shares) { + if (share.sharedWithUser !== note.ownerId) { + result.push({ + userId: share.sharedWithUser, + role: share.permissions === "write" ? "writer" : "reader", + addedAt: share.createdAt?.toISOString(), + }); + } + } + + // Add federated members + for (const member of membersList) { + // Avoid duplicates + if (!result.find((m) => m.userId === member.userId)) { + result.push({ + userId: member.userId, + role: member.role, + addedAt: member.createdAt?.toISOString(), + }); + } + } + + return json({ + noteId, + isOwner, + accessLevel: note.accessLevel, + members: result, + }); +} + +// POST add member +export async function POST({ params, request, locals }) { + const { user } = requireLogin(); + const { id: noteId } = params; + + const body = await request.json(); + const { userId, role = "writer" } = body; + + if (!userId) { + throw error(400, "userId is required"); + } + + // Find the note and verify ownership + const note = await db.query.notes.findFirst({ + where: eq(notes.id, noteId), + }); + + if (!note) { + throw error(404, "Note not found"); + } + + if (note.ownerId !== user.id) { + throw error(403, "Only the owner can add members"); + } + + // Add to noteShares for invite_only mode + const shareId = crypto.randomUUID(); + await db + .insert(noteShares) + .values({ + id: shareId, + noteId, + sharedWithUser: userId, + encryptedKey: "", // Will be populated when they request access + permissions: role === "reader" ? "read" : "write", + createdAt: new Date(), + }) + .onConflictDoNothing(); + + // If invite_only mode is not set, set it + if (note.accessLevel === "private") { + await db + .update(notes) + .set({ accessLevel: "invite_only", updatedAt: new Date() }) + .where(eq(notes.id, noteId)); + } + + return json({ success: true, userId, role }); +} + +// DELETE remove member +export async function DELETE({ params, request, locals }) { + const { user } = requireLogin(); + const { id: noteId } = params; + + const url = new URL(request.url); + const userId = url.searchParams.get("userId"); + + if (!userId) { + throw error(400, "userId query parameter is required"); + } + + // Find the note and verify ownership + const note = await db.query.notes.findFirst({ + where: eq(notes.id, noteId), + }); + + if (!note) { + throw error(404, "Note not found"); + } + + if (note.ownerId !== user.id) { + throw error(403, "Only the owner can remove members"); + } + + if (userId === note.ownerId) { + throw error(400, "Cannot remove the owner"); + } + + // Remove from noteShares + await db + .delete(noteShares) + .where( + and(eq(noteShares.noteId, noteId), eq(noteShares.sharedWithUser, userId)), + ); + + // Remove from members table (federation) + await db + .delete(members) + .where(and(eq(members.docId, noteId), eq(members.userId, userId))); + + return json({ success: true, removedUserId: userId }); +} diff --git a/src/routes/api/notes/[id]/share/+server.ts b/src/routes/api/notes/[id]/share/+server.ts new file mode 100644 index 0000000..4814b7d --- /dev/null +++ b/src/routes/api/notes/[id]/share/+server.ts @@ -0,0 +1,185 @@ +import { json, error } from "@sveltejs/kit"; +import { db } from "$lib/server/db"; +import { notes, noteShares, members, documents } from "$lib/server/db/schema"; +import { eq, and } from "drizzle-orm"; +import { requireLogin } from "$lib/server/auth"; +import { env } from "$env/dynamic/private"; +import { + fetchUserIdentity, + encryptDocumentKeyForUser, +} from "$lib/server/federation"; + +/** + * Share API endpoint + * + * POST: Update sharing settings for a note + * GET: Get current sharing settings + */ + +export interface ShareSettings { + accessLevel: "private" | "invite_only" | "authenticated" | "open"; + invitedUsers?: string[]; // Federated handles like @user:domain.com +} + +// GET current share settings +export async function GET({ params, locals }) { + const { user } = requireLogin(); + const { id: noteId } = params; + + const note = await db.query.notes.findFirst({ + where: eq(notes.id, noteId), + }); + + if (!note) { + throw error(404, "Note not found"); + } + + if (note.ownerId !== user.id) { + throw error(403, "Only the owner can view share settings"); + } + + // Get invited users for this note + const shares = await db.query.noteShares.findMany({ + where: eq(noteShares.noteId, noteId), + }); + + return json({ + accessLevel: note.accessLevel || "private", + invitedUsers: shares.map((s) => s.sharedWithUser), + }); +} + +// POST update share settings +export async function POST({ params, request, locals }) { + const { user } = requireLogin(); + const { id: noteId } = params; + + const body = await request.json(); + const { accessLevel, invitedUsers } = body as ShareSettings; + + // Validate access level + if ( + !["private", "invite_only", "authenticated", "open"].includes(accessLevel) + ) { + throw error(400, "Invalid access level"); + } + + // Find the note + const note = await db.query.notes.findFirst({ + where: eq(notes.id, noteId), + }); + + if (!note) { + throw error(404, "Note not found"); + } + + if (note.ownerId !== user.id) { + throw error(403, "Only the owner can update share settings"); + } + + // Get the document key (encrypted for owner) + const encryptedDocKey = note.documentKeyEncrypted || note.encryptedKey; + const serverDomain = env["SERVER_DOMAIN"] || "localhost:5173"; + + // Update note access level + await db + .update(notes) + .set({ + accessLevel, + updatedAt: new Date(), + }) + .where(eq(notes.id, noteId)); + + // Track failed invites for response + const failedInvites: string[] = []; + const successfulInvites: string[] = []; + + // Handle invited users for invite_only mode + if ( + accessLevel === "invite_only" && + invitedUsers && + invitedUsers.length > 0 + ) { + // Clear existing shares (we'll re-add them) + await db.delete(noteShares).where(eq(noteShares.noteId, noteId)); + + // Add new shares with encrypted keys + for (const userHandle of invitedUsers) { + const shareId = crypto.randomUUID(); + let encryptedKey = ""; + + // Try to fetch user's public key and encrypt document key + try { + const identity = await fetchUserIdentity(userHandle, serverDomain); + if (identity) { + const encrypted = encryptDocumentKeyForUser( + encryptedDocKey, + identity, + ); + if (encrypted) { + encryptedKey = encrypted; + successfulInvites.push(userHandle); + + // Also add to members table for federation + await db + .insert(members) + .values({ + docId: noteId, + userId: identity.handle || userHandle, + deviceId: "primary", + role: "writer", + encryptedKeyEnvelope: encryptedKey, + createdAt: new Date(), + }) + .onConflictDoNothing(); + } else { + failedInvites.push(userHandle); + } + } else { + // User not found - still add share, key will be generated on join + failedInvites.push(userHandle); + } + } catch (err) { + console.error(`Failed to encrypt key for ${userHandle}:`, err); + failedInvites.push(userHandle); + } + + // Always store the share record (even if key encryption failed) + await db.insert(noteShares).values({ + id: shareId, + noteId, + sharedWithUser: userHandle, + encryptedKey, + permissions: "write", + createdAt: new Date(), + }); + } + } else if (accessLevel !== "invite_only") { + // Clear invited users if not in invite_only mode + await db.delete(noteShares).where(eq(noteShares.noteId, noteId)); + await db.delete(members).where(eq(members.docId, noteId)); + } + + // Also update the documents table if it exists (for federation) + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, noteId), + }); + + if (doc) { + await db + .update(documents) + .set({ + accessLevel, + updatedAt: new Date(), + }) + .where(eq(documents.id, noteId)); + } + + return json({ + success: true, + accessLevel, + invitedUsers: invitedUsers || [], + successfulInvites, + failedInvites, + }); +} diff --git a/src/routes/client/doc/[doc_id]/events/+server.ts b/src/routes/client/doc/[doc_id]/events/+server.ts index 8d212b5..178e086 100644 --- a/src/routes/client/doc/[doc_id]/events/+server.ts +++ b/src/routes/client/doc/[doc_id]/events/+server.ts @@ -1,42 +1,98 @@ import { db } from "$lib/server/db"; -import { federatedOps } from "$lib/server/db/schema"; +import { federatedOps, documents } from "$lib/server/db/schema"; import { eq, gt, asc, and } from "drizzle-orm"; import type { RequestHandler } from "./$types"; - +import { error } from "@sveltejs/kit"; export const GET: RequestHandler = async ({ params, url }) => { const { doc_id } = params; const since = url.searchParams.get("since"); - let lastTs = since ? parseInt(since) : Date.now(); + // Default to 0 (beginning of time) to fetch full history if 'since' is not provided. + // This ensures that when a client connects (especially for the first time), + // it receives all existing ops to reconstruct the document state. + let lastTs = since ? parseInt(since) : 0; + + console.log(`[EVENTS] Connection request for ${doc_id}, since=${since}`); + + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, doc_id), + }); + + if (!doc) { + console.error(`[EVENTS] Document not found: ${doc_id}`); + throw error(404, "Document not found"); + } + + const isRemote = doc && doc.hostServer !== "local"; const stream = new ReadableStream({ async start(controller) { while (true) { try { - // Check if client is still connected? - // ReadableStream doesn't inherently check unless we try to enqueue and it errors? - // SvelteKit/Node might abort controller? - - // Poll - // Fetch ops newer than lastTs for this doc - const newOps = await db.query.federatedOps.findMany({ - where: and( - eq(federatedOps.docId, doc_id), - gt(federatedOps.lamportTs, lastTs), - ), - orderBy: [asc(federatedOps.lamportTs)], - }); - - if (newOps.length > 0) { - const message = JSON.stringify(newOps); - controller.enqueue(`data: ${message}\n\n`); - // Update lastTs to the max ts found - const maxTs = Math.max(...newOps.map((o) => o.lamportTs)); - if (maxTs > lastTs) lastTs = maxTs; + if (isRemote) { + // Poll remote server + // Ideally we'd subscribe to SSE, but for MVP polling is safer/easier + // We need to sign this request? Or is it public? + // Federation ops endpoint currently requires signature. + + // Note: Efficient way would be to proxy the SSE connection directly? + // But we need to sign the request as the server. + + // Let's implement polling for now to match local logic + const remoteUrl = `http://${doc.hostServer}/federation/doc/${encodeURIComponent(doc_id)}/ops?since=${lastTs}`; + + // GET request doesn't have body, but we need headers. + // Ops endpoint checks signature on headers. + // It validates against NO body for GET? + // Checking ops/+server.ts: verifyServerRequest checks body? + // Wait, verifyServerRequest uses JSON.stringify(payload). + // If payload is empty body, verify logic needs to handle that. + // GET /ops logic in previous step didn't call verifyServerRequest. + // Let's check ops/+server.ts content again. + // GET handler checks DB directly. It does NOT call verifyServerRequest. + // So it's effectively public? Or relies on something else? + // It just returns ops. + // Ops are encrypted. So maybe it's fine. + // IF it's public, we don't need signature. + + // console.log(`[CLIENT] Polling remote events from ${remoteUrl}`); + const res = await fetch(remoteUrl); + if (res.ok) { + const data = await res.json(); + if (data.ops && data.ops.length > 0) { + console.log(`[CLIENT] Received ${data.ops.length} remote ops`); + const message = JSON.stringify(data.ops); + controller.enqueue(`data: ${message}\n\n`); + const maxTs = Math.max( + ...data.ops.map((o: any) => o.lamportTs), + ); + if (maxTs > lastTs) lastTs = maxTs; + } + } else { + console.warn(`[CLIENT] Remote polling failed: ${res.status}`); + } + } else { + // Local polling (existing logic) + const newOps = await db.query.federatedOps.findMany({ + where: and( + eq(federatedOps.docId, doc_id), + gt(federatedOps.lamportTs, lastTs), + ), + orderBy: [asc(federatedOps.lamportTs)], + }); + + if (newOps.length > 0) { + const message = JSON.stringify(newOps); + controller.enqueue(`data: ${message}\n\n`); + // Update lastTs to the max ts found + const maxTs = Math.max(...newOps.map((o) => o.lamportTs)); + if (maxTs > lastTs) lastTs = maxTs; + } } - await new Promise((r) => setTimeout(r, 1000)); + await new Promise((r) => setTimeout(r, 50)); } catch (e) { // Error or closed? + console.error("Stream error:", e); controller.close(); break; } diff --git a/src/routes/client/doc/[doc_id]/push/+server.ts b/src/routes/client/doc/[doc_id]/push/+server.ts index 72029bd..953c590 100644 --- a/src/routes/client/doc/[doc_id]/push/+server.ts +++ b/src/routes/client/doc/[doc_id]/push/+server.ts @@ -1,6 +1,8 @@ -import { json } from "@sveltejs/kit"; +import { json, error } from "@sveltejs/kit"; import { db } from "$lib/server/db"; -import { federatedOps } from "$lib/server/db/schema"; +import { federatedOps, documents } from "$lib/server/db/schema"; +import { eq } from "drizzle-orm"; +import { signServerRequest } from "$lib/server/identity"; export async function POST({ params, request, locals }) { const { doc_id } = params; @@ -9,27 +11,84 @@ export async function POST({ params, request, locals }) { // Op structure: { op_id, actor_id, lamport_ts, encrypted_payload, signature } if (!locals.user) { - // Validation check (auth) - // Only members can write? - // Check member role. + throw error(401, "Unauthorized"); } - // Store Op - await db - .insert(federatedOps) - .values({ - id: op.op_id, - docId: doc_id, - opId: op.op_id, - actorId: op.actor_id, - lamportTs: op.lamport_ts, - payload: op.encrypted_payload, // or 'payload' in DB - signature: op.signature, - }) - .onConflictDoNothing(); - - // Trigger SSE? - // If using in-memory bus, emit here. + // Check if doc is remote + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, doc_id), + }); + + if (doc && doc.hostServer !== "local") { + // Proxy to remote server + console.log( + `[CLIENT] Proxying push to remote server: ${doc.hostServer} for ${doc_id}`, + ); + + const remoteUrl = `http://${doc.hostServer}/federation/doc/${encodeURIComponent(doc_id)}/ops`; + const payload = { ops: [op] }; // Federation endpoint expects array of ops + + console.log(`[CLIENT] Signing request...`); + const { + signature, + timestamp, + domain: requestDomain, + } = await signServerRequest(payload); + + console.log(`[CLIENT] Sending fetch to ${remoteUrl}`); + const res = await fetch(remoteUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-notes-signature": signature, + "x-notes-timestamp": timestamp.toString(), + "x-notes-domain": requestDomain, + }, + body: JSON.stringify(payload), + }); + + console.log(`[CLIENT] Remote response status: ${res.status}`); + if (!res.ok) { + const text = await res.text(); + console.error( + `[CLIENT] Failed to push to remote server: ${res.status}`, + text, + ); + throw error(500, "Failed to push to remote server"); + } + + // We successfully pushed to remote. + // Do we store it locally too? + // Yes, otherwise we won't see our own changes if we reload/poll? + // But strictly speaking, we should receive it back via sync/events. + // However, for latency, we might want to store it. + // BUT, if we store it, we might duplicate it when we poll? + // `onConflictDoNothing` handles duplicates. + // So safe to store locally too. + } + + // Store Op locally (even if remote, to cache/optimistic update) + try { + console.log( + `[CLIENT] Inserting op ${op.op_id} into federatedOps (docId: ${doc_id})`, + ); + await db + .insert(federatedOps) + .values({ + id: op.op_id, + docId: doc_id, + opId: op.op_id, + actorId: op.actor_id, + lamportTs: op.lamport_ts, + payload: op.encrypted_payload, // or 'payload' in DB + signature: op.signature, + }) + .onConflictDoNothing(); + console.log(`[CLIENT] Local insertion successful for ${op.op_id}`); + } catch (err) { + console.error(`[CLIENT] Local insertion failed for ${op.op_id}:`, err); + throw error(500, "Failed to store operation locally"); + } return json({ success: true }); } diff --git a/src/routes/federation/doc/[doc_id]/join/+server.ts b/src/routes/federation/doc/[doc_id]/join/+server.ts index ebe163f..4cca924 100644 --- a/src/routes/federation/doc/[doc_id]/join/+server.ts +++ b/src/routes/federation/doc/[doc_id]/join/+server.ts @@ -1,9 +1,14 @@ import { json, error } from "@sveltejs/kit"; import { getServerIdentity } from "$lib/server/identity"; -import { verify } from "$lib/crypto"; +import { verify, decryptKeyForDevice } from "$lib/crypto"; import { db } from "$lib/server/db"; -import { documents, members, notes } from "$lib/server/db/schema"; +import { documents, members, notes, users } from "$lib/server/db/schema"; import { eq, and, inArray } from "drizzle-orm"; +import { + fetchUserIdentity, + generateKeyEnvelopesForUsers, +} from "$lib/server/federation"; +import { parseNoteId } from "$lib/noteId"; // Helper to verify request signature async function verifyServerRequest(request: Request, payload: any) { @@ -44,32 +49,81 @@ async function verifyServerRequest(request: Request, payload: any) { export async function POST({ params, request }) { const { doc_id } = params; + console.log("=== JOIN ENDPOINT START ==="); + console.log(" doc_id from params:", doc_id); + console.log(" decoded doc_id:", decodeURIComponent(doc_id)); + const body = await request.json(); const { requesting_server, users: joiningUsers } = body; + console.log(" requesting_server:", requesting_server); + console.log(" joiningUsers:", joiningUsers); // Verify signature - await verifyServerRequest(request, body); + const remoteServer = await verifyServerRequest(request, body); + console.log(" remoteServer verified:", remoteServer?.domain); // 1. Check if doc exists - // Note: querying 'documents' table. If using 'notes', switch to 'notes' or ensure 'documents' populated. - // For now assuming 'documents' table is used for federation metadata. - const doc = await db.query.documents.findFirst({ + // The doc_id may be a full portable ID (e.g., bG9jYWxob3N0OjUxNzM~uuid) or just a UUID + // Try the full ID first, then try to parse and use UUID as fallback + + // First try with the raw doc_id from params + console.log(" Searching for doc_id:", doc_id); + let doc = await db.query.documents.findFirst({ where: eq(documents.id, doc_id), }); + console.log(" documents.findFirst(doc_id):", doc?.id || "NOT FOUND"); + + let note = await db.query.notes.findFirst({ + where: eq(notes.id, doc_id), + }); + console.log(" notes.findFirst(doc_id):", note?.id || "NOT FOUND"); + + // Try with decoded doc_id (in case it was URL-encoded) + const decodedDocId = decodeURIComponent(doc_id); + if (!note && !doc && decodedDocId !== doc_id) { + console.log(" Trying decoded doc_id:", decodedDocId); + doc = await db.query.documents.findFirst({ + where: eq(documents.id, decodedDocId), + }); + console.log(" documents.findFirst(decoded):", doc?.id || "NOT FOUND"); - // Fallback to checking `notes` if `documents` empty? - // If we haven't migrated existing notes to `documents`, check `notes`. - let note; - if (!doc) { note = await db.query.notes.findFirst({ - where: eq(notes.id, doc_id), + where: eq(notes.id, decodedDocId), }); - if (!note) throw error(404, "Document not found"); - // Implicitly hosted here if local note found? - } else { - note = await db.query.notes.findFirst({ where: eq(notes.id, doc_id) }); + console.log(" notes.findFirst(decoded):", note?.id || "NOT FOUND"); + } + + // If not found with full ID, the ID might already exist as just a UUID (legacy) + if (!note && !doc) { + // Try parsing the portable ID to extract the UUID + const { uuid } = parseNoteId(decodedDocId); + console.log(" Parsed UUID from portable ID:", uuid); + if (uuid && uuid !== decodedDocId) { + doc = await db.query.documents.findFirst({ + where: eq(documents.id, uuid), + }); + console.log(" documents.findFirst(uuid):", doc?.id || "NOT FOUND"); + + note = await db.query.notes.findFirst({ + where: eq(notes.id, uuid), + }); + console.log(" notes.findFirst(uuid):", note?.id || "NOT FOUND"); + } } + if (!note && !doc) { + // List all notes in DB for debugging + const allNotes = await db.query.notes.findMany({ limit: 5 }); + console.log( + " All notes in DB (first 5):", + allNotes.map((n) => n.id), + ); + console.error(` Document not found: ${doc_id}`); + throw error(404, "Document not found"); + } + + console.log(" Found note:", note?.id, "accessLevel:", note?.accessLevel); + // 2. Check permissions based on access_level const accessLevel = note?.accessLevel || doc?.accessLevel || "private"; @@ -104,26 +158,133 @@ export async function POST({ params, request }) { }); } - // 4. For authenticated/open notes, generate encrypted keys for joining users - // Need to fetch their public keys from their server + // 3. For authenticated/open notes, generate encrypted keys for joining users const snapshot = note?.loroSnapshot || null; - const documentKey = note?.documentKeyEncrypted || note?.encryptedKey; + const encryptedDocKey = note?.documentKeyEncrypted || note?.encryptedKey; - if (!documentKey) { + if (!encryptedDocKey) { throw error(500, "Document key not found"); } - // For now, return a temporary solution: let client generate own key - // TODO: Implement proper key exchange: - // 1. Fetch user public keys from requesting_server/.well-known/notes-identity/[user] - // 2. Decrypt document key (if encrypted for owner) - // 3. Re-encrypt for each joining user's public key - // 4. Return encrypted envelopes + // Get the owner's private key to decrypt the document key + // Note: In a real E2EE system, the server wouldn't have access to decrypted keys + // This is a simplified approach where the server can re-encrypt for new users + const owner = await db.query.users.findFirst({ + where: eq(users.id, note?.ownerId || ""), + }); + + if (!owner) { + throw error(500, "Document owner not found"); + } + + // For authenticated/open notes, we'll generate envelopes by: + // 1. Fetching user public keys from requesting_server + // 2. Encrypting the document key for each user + + const serverIdentity = await getServerIdentity(); + + // Decrypt the doc key first! + // Decrypt the doc key first! + let rawDocKey = encryptedDocKey; + console.log(`[JOIN] encryptedDocKey Length: ${encryptedDocKey.length}`); + + if (encryptedDocKey.length > 44) { + if (owner.privateKeyEncrypted) { + console.log( + `[JOIN] Owner PrivKey Length: ${owner.privateKeyEncrypted.length}`, + ); + try { + console.log(`[JOIN] Decrypting owner key for re-encryption...`); + rawDocKey = decryptKeyForDevice( + encryptedDocKey, + owner.privateKeyEncrypted, + ); + console.log(`[JOIN] Decrypted Raw Key Length: ${rawDocKey.length}`); + } catch (e) { + console.error(`[JOIN] Failed to decrypt owner key:`, e); + throw error(500, "Failed to decrypt note key for sharing"); + } + } else { + console.error(`[JOIN] Owner has no private key! CANNOT DECRYPT.`); + // CRITICAL: Do not allow double encryption. Fail here. + throw error( + 500, + "Owner missing private key - cannot share authenticated note", + ); + } + } else { + console.log( + `[JOIN] Key is already raw (Length: ${encryptedDocKey.length})`, + ); + } + + // Debug Identity Fetching + for (const handle of joiningUsers) { + const id = await fetchUserIdentity(handle, requesting_server); + console.log( + ` [DEBUG] Fetched Identity for ${handle}:`, + JSON.stringify(id), + ); + if (id?.publicKey) { + console.log(` [DEBUG] Public Key for ${handle}: ${id.publicKey}`); + } + } + + const envelopes = await generateKeyEnvelopesForUsers( + rawDocKey, // Now passing the RAW key + joiningUsers, + requesting_server, + ); + + // Ensure documents entry exists (required for members FK constraint) + // Use the actual note ID (which may be a portable ID) + const noteId = note?.id || doc_id; + const docEntry = await db.query.documents.findFirst({ + where: eq(documents.id, noteId), + }); + + if (!docEntry) { + console.log(" Creating documents entry for:", noteId); + await db + .insert(documents) + .values({ + id: noteId, + hostServer: "local", + ownerId: note?.ownerId || "", + title: note?.title || "Untitled", + accessLevel: accessLevel, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoNothing(); + } + + // Also add the joining users as members + for (const envelope of envelopes) { + console.log(" Adding member:", envelope.user_id); + await db + .insert(members) + .values({ + docId: noteId, + userId: envelope.user_id, + deviceId: envelope.device_id, + role: "writer", + encryptedKeyEnvelope: envelope.encrypted_key, + createdAt: new Date(), + }) + .onConflictDoUpdate({ + target: [members.docId, members.userId, members.deviceId], + set: { + encryptedKeyEnvelope: envelope.encrypted_key, + role: "writer", + }, + }); + } return json({ doc_id, snapshot, - envelopes: [], // Empty for now - client will generate key + envelopes, title: note?.title || "Untitled", ownerId: note?.ownerId, accessLevel, diff --git a/src/routes/federation/doc/[doc_id]/ops/+server.ts b/src/routes/federation/doc/[doc_id]/ops/+server.ts index b678087..2df276a 100644 --- a/src/routes/federation/doc/[doc_id]/ops/+server.ts +++ b/src/routes/federation/doc/[doc_id]/ops/+server.ts @@ -2,7 +2,7 @@ import { json, error } from "@sveltejs/kit"; import { verify } from "$lib/crypto"; import { db } from "$lib/server/db"; import { federatedOps } from "$lib/server/db/schema"; -import { eq, gt, asc } from "drizzle-orm"; +import { eq, gt, asc, and } from "drizzle-orm"; import { signServerRequest } from "$lib/server/identity"; // Helper for verification (reuse from Join or export it? Duplicate for now to avoid logic split) @@ -39,40 +39,43 @@ export async function GET({ params, url }) { const sinceTs = since ? parseInt(since) : 0; const ops = await db.query.federatedOps.findMany({ - where: gt(federatedOps.lamportTs, sinceTs), // Actually need to filter by doc_id too - // TODO: fix query to use AND + where: and( + eq(federatedOps.docId, doc_id), + gt(federatedOps.lamportTs, sinceTs), + ), + orderBy: [asc(federatedOps.lamportTs)], }); - // Fix: - // where: and(eq(federatedOps.docId, doc_id), gt(federatedOps.lamportTs, sinceTs)) - - // Sort by lamportTs - // orderBy: [asc(federatedOps.lamportTs)] - return json({ - ops: [], // TODO: correct query above - server_version: Date.now(), // placeholder + ops, + server_version: Date.now(), }); } // PUSH Ops export async function POST({ params, request }) { const { doc_id } = params; + console.log(`[FED] Received ops push for ${doc_id}`); + const body = await request.json(); const { ops } = body; + console.log(`[FED] Ops count: ${ops?.length}`); - await verifyServerRequest(request, body); + try { + await verifyServerRequest(request, body); + console.log(`[FED] Verification successful`); + } catch (e) { + console.error(`[FED] Verification failed:`, e); + throw e; + } if (!Array.isArray(ops)) throw error(400); - for (const op of ops) { - // Verify op signature? - // Spec: "Receiving server verifies signatures" (of OP). - // Op structure: { doc_id, op_id, actor_id, signature, ... } - // Verify sig using User's device key? - // We need to fetch User/Device key. - // For MVP, just store. + // Validate that the document exists first? + // Ideally yes, but maybe we just accept ops for known docs. + for (const op of ops) { + console.log(`[FED] Inserting op ${op.op_id}`); await db .insert(federatedOps) .values({ @@ -81,11 +84,12 @@ export async function POST({ params, request }) { opId: op.op_id, actorId: op.actor_id, lamportTs: op.lamport_ts, - payload: op.encrypted_payload, + payload: op.encrypted_payload, // Note: client sends 'encrypted_payload' in JSON, but DB has 'payload' signature: op.signature, }) .onConflictDoNothing(); } + console.log(`[FED] Ops inserted successfully`); return json({ success: true }); } diff --git a/src/routes/federation/import/+page.server.ts b/src/routes/federation/import/+page.server.ts index d32f5db..ce3bdd4 100644 --- a/src/routes/federation/import/+page.server.ts +++ b/src/routes/federation/import/+page.server.ts @@ -37,28 +37,13 @@ export async function load({ url, locals }) { throw redirect(302, `/notes/${doc_id}`); } - // Perform Join - // 1. Fetch user's devices to request keys for? - // Actually, Server A (Host) needs to know which users to generate envelopes for. - // If User B is joining, we send User B's ID (federated ID: @user:domain). - // But Server A might not know User B's device keys yet? - // "Join" implies we are asking for keys. - // Usually we exchange keys first. - // Spec: "Join... We expect them to be allowed...". - - // Complex part: How does Server A know User B's device public key to encrypt the note key? - // Option A: User B published keys to Server A previously (via Join Request payload?). - // Option B: Server A queries Server B Identity endpoint `/.well-known/notes-identity/user`. - - // Let's assume Option B: Host looks up Joiner's identity. - // So we just send `users: ["bob"]` (local username or full handle?) -> Federated Handle `@bob:server-b.com`. - - const userHandle = `@${user.username}`; // Requesting for local user + // Construct the federated handle for the joining user + const userHandle = `@${user.username}:${identity.domain}`; // Sign request const payload = { requesting_server: identity.domain, - users: [userHandle], // List of users I am joining on behalf of + users: [userHandle], // Full federated handle }; const { signature, timestamp, domain } = await signServerRequest(payload); @@ -82,65 +67,69 @@ export async function load({ url, locals }) { if (!res.ok) { const text = await res.text(); console.error("Join failed:", text); - throw error(res.status as any, "Failed to join document on host server"); + throw error(res.status as any, `Failed to join document: ${text}`); } joinRes = await res.json(); - } catch (e) { + } catch (e: any) { console.error("Join error:", e); + if (e.status) throw e; // Re-throw if it's already an error response throw error(502, "Failed to contact host server"); } // Process Response - // { snapshot: ..., envelopes: [...] } + // { snapshot, envelopes: [{ user_id, device_id, encrypted_key }], title, accessLevel } + + // Find the envelope for our user + const myEnvelope = joinRes.envelopes?.find( + (env: any) => + env.user_id === userHandle || + env.user_id === `@${user.username}` || + env.user_id === user.username, + ); + + const encryptedKey = myEnvelope?.encrypted_key || ""; // Save Document Metadata await db.insert(documents).values({ id: doc_id, hostServer: host, - ownerId: "unknown", // or fetch from host - // ... + ownerId: joinRes.ownerId || "unknown", + title: joinRes.title || "Untitled", + accessLevel: joinRes.accessLevel || "authenticated", }); - // Save Content (Snapshot) - if (joinRes.snapshot) { - await db - .insert(notes) - .values({ - id: doc_id, - ownerId: user.id, // Local owner? Or proxy? - // If we are replica, ownerId might be irrelevant or we keep original owner ID string? - // Schema `notes.ownerId` is `text`. - loroSnapshot: joinRes.snapshot, - }) - .onConflictDoUpdate({ - target: notes.id, - set: { loroSnapshot: joinRes.snapshot }, - }); - } - - // Save Envelopes - // joinRes.envelopes: [{ user_id, device_id, encrypted_key }] - // We need to map these to local `members` table. - - for (const env of joinRes.envelopes) { - // user_id from host might be `@bob:server-b.com` or just `bob`? - // Hosted returns what we asked or canonical. - - // We need to store it for OUR local user. - // `members` table links to `users`? Schema check: `userId` is text, not reference? - // Let's check schema. + // Save Content (Snapshot) - use empty snapshot if none provided + await db + .insert(notes) + .values({ + id: doc_id, + ownerId: user.id, // Local user becomes local "owner" of this copy + title: joinRes.title || "Untitled", + encryptedKey, // The encrypted document key for this user + loroSnapshot: joinRes.snapshot || null, + accessLevel: joinRes.accessLevel || "authenticated", + }) + .onConflictDoUpdate({ + target: notes.id, + set: { + loroSnapshot: joinRes.snapshot || null, + encryptedKey, + updatedAt: new Date(), + }, + }); + // Save all envelopes to members table + for (const env of joinRes.envelopes || []) { await db .insert(members) .values({ docId: doc_id, - userId: user.id, // Map back to local ID? Or store federated ID? - // If `members.userId` is used for auth checks, it better match `locals.user.id`. - // But if it receives envelopes for multiple devices? - deviceId: env.device_id, - role: "writer", // Assume writer if joined? + userId: user.id, // Map to local user ID + deviceId: env.device_id || "primary", + role: "writer", encryptedKeyEnvelope: env.encrypted_key, + createdAt: new Date(), }) .onConflictDoNothing(); } diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte index 1cb4dba..564c392 100644 --- a/src/routes/notes/[id]/+page.svelte +++ b/src/routes/notes/[id]/+page.svelte @@ -1,10 +1,14 @@
@@ -19,13 +21,14 @@
{/each} -
+ +
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3d9f33e..89f6e43 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,11 +9,90 @@ import { setContext } from "svelte"; import { SIDEBAR_CONTEXT_KEY } from "$lib/components/sidebar-context"; + import { decryptWithPassword } from "$lib/crypto.ts"; + + // ... (previous imports) + + import { setupEncryption } from "$lib/remote/accounts.remote.ts"; + import { + generateSigningKeyPair, + generateEncryptionKeyPair, + encryptWithPassword, + } from "$lib/crypto.ts"; + + // ... (previous imports) + let { children, data } = $props(); // Sidebar collapse state let isCollapsed = $state(false); + // Vault State + let isVaultUnlocked = $state(false); + let unlockPassword = $state(""); + let unlockError = $state(""); + + // Setup Encryption State + let isSetupRequired = $derived(!!data.user && !data.user.privateKeyEncrypted); + let setupPassword = $state(""); + let setupError = $state(""); + let setupLoading = $state(false); + + async function handleSetupEncryption() { + if (!data.user || !setupPassword) return; + setupLoading = true; + setupError = ""; + + try { + // 1. Generate Keys + const signKeys = await generateSigningKeyPair(); + const encKeys = await generateEncryptionKeyPair(); + + // 2. Encrypt + const privateKeyEncrypted = await encryptWithPassword( + encKeys.privateKey, + setupPassword, + ); + + // 3. Submit + await setupEncryption({ + _password: setupPassword, + publicKey: signKeys.publicKey, + privateKeyEncrypted, + }); + + // 4. Auto-unlock locally + sessionStorage.setItem("notes_raw_private_key", encKeys.privateKey); + isVaultUnlocked = true; + + // Reload to ensure state is fresh + window.location.reload(); + } catch (e) { + console.error(e); + setupError = "Failed to setup encryption. Verify your password."; + } finally { + setupLoading = false; + } + } + + // Global Private Key State (exposed via Context?) + // For now, we rely on sessionStorage "notes_raw_private_key" being present. + + async function unlockVault() { + if (!unlockPassword || !data.user) return; + try { + const rawKey = await decryptWithPassword( + data.user.privateKeyEncrypted, + unlockPassword, + ); + sessionStorage.setItem("notes_raw_private_key", rawKey); + isVaultUnlocked = true; + unlockPassword = ""; // clear memory + } catch (e) { + unlockError = "Incorrect password"; + } + } + function toggleSidebar() { isCollapsed = !isCollapsed; } @@ -29,6 +108,24 @@ // Initialize from localStorage and handle responsive behavior onMount(() => { + (async () => { + if (data.user) { + // Try to auto-unlock if key is already in session + const existingKey = sessionStorage.getItem("notes_raw_private_key"); + if (existingKey) { + isVaultUnlocked = true; + } else { + // Try temporary password from login redirect + const tempPw = sessionStorage.getItem("notes_temp_password"); + if (tempPw) { + unlockPassword = tempPw; + await unlockVault(); + sessionStorage.removeItem("notes_temp_password"); + } + } + } + })(); + // Load saved state from localStorage const saved = localStorage.getItem("sidebarCollapsed"); if (saved !== null) { @@ -85,18 +182,96 @@ {#if data.user} -
- -
- {@render children()} + {#if isSetupRequired} +
+
+
+

Setup Encryption

+ +

+ Your account needs to be upgraded to support End-to-End Encryption. + Please confirm your password to generate your secure keys. +

+ + {#if setupError} +
{setupError}
+ {/if} + +
+ e.key === "Enter" && handleSetupEncryption()} + disabled={setupLoading} + /> +
+ +
+ + + + +
+
+
+
+ {:else if !isVaultUnlocked} +
+
+
+

Unlock Your Vault

+

Enter your password to decrypt your private key.

+ {#if unlockError} +
{unlockError}
+ {/if} +
+ e.key === "Enter" && unlockVault()} + /> +
+
+ + +
+ +
+
+
+
+
+ {:else} +
+ +
+ {@render children()} +
-
+ {/if} {:else} {@render children()} {/if} diff --git a/src/routes/api/notes/[id]/share/+server.ts b/src/routes/api/notes/[id]/share/+server.ts index 4814b7d..d16e24e 100644 --- a/src/routes/api/notes/[id]/share/+server.ts +++ b/src/routes/api/notes/[id]/share/+server.ts @@ -4,6 +4,7 @@ import { notes, noteShares, members, documents } from "$lib/server/db/schema"; import { eq, and } from "drizzle-orm"; import { requireLogin } from "$lib/server/auth"; import { env } from "$env/dynamic/private"; +import { decryptKeyForDevice } from "$lib/crypto"; import { fetchUserIdentity, encryptDocumentKeyForUser, @@ -17,8 +18,14 @@ import { */ export interface ShareSettings { - accessLevel: "private" | "invite_only" | "authenticated" | "open"; + accessLevel: + | "private" + | "invite_only" + | "authenticated" + | "open" + | "password_protected"; invitedUsers?: string[]; // Federated handles like @user:domain.com + passwordEncryptedKey?: string; // Encrypted with the password } // GET current share settings @@ -46,6 +53,7 @@ export async function GET({ params, locals }) { return json({ accessLevel: note.accessLevel || "private", invitedUsers: shares.map((s) => s.sharedWithUser), + // We do NOT return the passwordEncryptedKey to the owner here, they don't need it (they have the original key) }); } @@ -55,11 +63,18 @@ export async function POST({ params, request, locals }) { const { id: noteId } = params; const body = await request.json(); - const { accessLevel, invitedUsers } = body as ShareSettings; + const { accessLevel, invitedUsers, passwordEncryptedKey } = + body as ShareSettings; // Validate access level if ( - !["private", "invite_only", "authenticated", "open"].includes(accessLevel) + ![ + "private", + "invite_only", + "authenticated", + "open", + "password_protected", + ].includes(accessLevel) ) { throw error(400, "Invalid access level"); } @@ -110,22 +125,29 @@ export async function POST({ params, request, locals }) { // Try to fetch user's public key and encrypt document key try { + console.log(`[SHARE] Fetching identity for ${userHandle}...`); const identity = await fetchUserIdentity(userHandle, serverDomain); + if (identity) { + console.log( + `[SHARE] Found identity for ${userHandle}:`, + identity.handle, + ); const encrypted = encryptDocumentKeyForUser( encryptedDocKey, identity, ); + if (encrypted) { encryptedKey = encrypted; - successfulInvites.push(userHandle); + successfulInvites.push(identity.handle); // Use canonical handle // Also add to members table for federation await db .insert(members) .values({ docId: noteId, - userId: identity.handle || userHandle, + userId: identity.handle, // Canonical handle deviceId: "primary", role: "writer", encryptedKeyEnvelope: encryptedKey, @@ -133,14 +155,19 @@ export async function POST({ params, request, locals }) { }) .onConflictDoNothing(); } else { + console.error(`[SHARE] Failed to encrypt key for ${userHandle}`); failedInvites.push(userHandle); } } else { // User not found - still add share, key will be generated on join + console.warn(`[SHARE] User not found: ${userHandle}`); failedInvites.push(userHandle); } } catch (err) { - console.error(`Failed to encrypt key for ${userHandle}:`, err); + console.error( + `[SHARE] Error processing invite for ${userHandle}:`, + err, + ); failedInvites.push(userHandle); } @@ -170,6 +197,8 @@ export async function POST({ params, request, locals }) { .update(documents) .set({ accessLevel, + // Only update if provided (don't overwrite with undefined) + ...(passwordEncryptedKey ? { passwordEncryptedKey } : {}), updatedAt: new Date(), }) .where(eq(documents.id, noteId)); diff --git a/src/routes/api/server-identity/+server.ts b/src/routes/api/server-identity/+server.ts new file mode 100644 index 0000000..a5266dc --- /dev/null +++ b/src/routes/api/server-identity/+server.ts @@ -0,0 +1,12 @@ +import { json } from "@sveltejs/kit"; +import { getServerIdentity } from "$lib/server/identity"; + +export async function GET() { + const identity = await getServerIdentity(); + + return json({ + domain: identity.domain, + publicKey: identity.publicKey, // Signing + encryptionPublicKey: identity.encryptionPublicKey, // Broker + }); +} diff --git a/src/routes/federation/doc/[doc_id]/join/+server.ts b/src/routes/federation/doc/[doc_id]/join/+server.ts index 4cca924..11af55a 100644 --- a/src/routes/federation/doc/[doc_id]/join/+server.ts +++ b/src/routes/federation/doc/[doc_id]/join/+server.ts @@ -63,53 +63,21 @@ export async function POST({ params, request }) { console.log(" remoteServer verified:", remoteServer?.domain); // 1. Check if doc exists - // The doc_id may be a full portable ID (e.g., bG9jYWxob3N0OjUxNzM~uuid) or just a UUID - // Try the full ID first, then try to parse and use UUID as fallback - - // First try with the raw doc_id from params + // The doc_id MUST be a full portable ID (e.g., bG9jYWxob3N0OjUxNzM~uuid) console.log(" Searching for doc_id:", doc_id); - let doc = await db.query.documents.findFirst({ - where: eq(documents.id, doc_id), - }); - console.log(" documents.findFirst(doc_id):", doc?.id || "NOT FOUND"); - - let note = await db.query.notes.findFirst({ - where: eq(notes.id, doc_id), - }); - console.log(" notes.findFirst(doc_id):", note?.id || "NOT FOUND"); // Try with decoded doc_id (in case it was URL-encoded) const decodedDocId = decodeURIComponent(doc_id); - if (!note && !doc && decodedDocId !== doc_id) { - console.log(" Trying decoded doc_id:", decodedDocId); - doc = await db.query.documents.findFirst({ - where: eq(documents.id, decodedDocId), - }); - console.log(" documents.findFirst(decoded):", doc?.id || "NOT FOUND"); - note = await db.query.notes.findFirst({ - where: eq(notes.id, decodedDocId), - }); - console.log(" notes.findFirst(decoded):", note?.id || "NOT FOUND"); - } - - // If not found with full ID, the ID might already exist as just a UUID (legacy) - if (!note && !doc) { - // Try parsing the portable ID to extract the UUID - const { uuid } = parseNoteId(decodedDocId); - console.log(" Parsed UUID from portable ID:", uuid); - if (uuid && uuid !== decodedDocId) { - doc = await db.query.documents.findFirst({ - where: eq(documents.id, uuid), - }); - console.log(" documents.findFirst(uuid):", doc?.id || "NOT FOUND"); + let doc = await db.query.documents.findFirst({ + where: eq(documents.id, decodedDocId), + }); + console.log(" documents.findFirst(decoded):", doc?.id || "NOT FOUND"); - note = await db.query.notes.findFirst({ - where: eq(notes.id, uuid), - }); - console.log(" notes.findFirst(uuid):", note?.id || "NOT FOUND"); - } - } + let note = await db.query.notes.findFirst({ + where: eq(notes.id, decodedDocId), + }); + console.log(" notes.findFirst(decoded):", note?.id || "NOT FOUND"); if (!note && !doc) { // List all notes in DB for debugging @@ -131,7 +99,7 @@ export async function POST({ params, request }) { // Require pre-existing membership for private/invite-only notes const memberRows = await db.query.members.findMany({ where: and( - eq(members.docId, doc_id), + eq(members.docId, decodedDocId), inArray(members.userId, joiningUsers), ), }); @@ -142,20 +110,7 @@ export async function POST({ params, request }) { "This note is private. You must be invited to access it.", ); } - - // Return existing envelopes for invited users - const snapshot = note?.loroSnapshot || null; - return json({ - doc_id, - snapshot, - envelopes: memberRows.map((m) => ({ - user_id: m.userId, - device_id: m.deviceId, - encrypted_key: m.encryptedKeyEnvelope, - })), - title: note?.title || "Untitled", - ownerId: note?.ownerId, - }); + // Permission granted! Fall through to generate fresh envelopes. } // 3. For authenticated/open notes, generate encrypted keys for joining users @@ -183,51 +138,105 @@ export async function POST({ params, request }) { const serverIdentity = await getServerIdentity(); - // Decrypt the doc key first! - // Decrypt the doc key first! - let rawDocKey = encryptedDocKey; - console.log(`[JOIN] encryptedDocKey Length: ${encryptedDocKey.length}`); + // 4. Try to use Server Escrow (Key Broker) + let rawDocKey = ""; - if (encryptedDocKey.length > 44) { - if (owner.privateKeyEncrypted) { - console.log( - `[JOIN] Owner PrivKey Length: ${owner.privateKeyEncrypted.length}`, - ); + // Special Handling for Open Public Notes + if (accessLevel === "public" || accessLevel === "open") { + console.log( + "[JOIN] Public Note Access. Decrypting for anonymous access...", + ); + if (doc?.serverEncryptedKey) { try { - console.log(`[JOIN] Decrypting owner key for re-encryption...`); - rawDocKey = decryptKeyForDevice( - encryptedDocKey, - owner.privateKeyEncrypted, + rawDocKey = await decryptKeyForDevice( + doc.serverEncryptedKey, + serverIdentity.encryptionPrivateKey, ); - console.log(`[JOIN] Decrypted Raw Key Length: ${rawDocKey.length}`); } catch (e) { - console.error(`[JOIN] Failed to decrypt owner key:`, e); - throw error(500, "Failed to decrypt note key for sharing"); + console.error("[JOIN] Failed to decrypt public note key:", e); + throw error(500, "Failed to unlock public note"); } + } else if (encryptedDocKey.length <= 44) { + rawDocKey = encryptedDocKey; // Legacy public + } + + // If we have the raw key, return it immediately for the anonymous user + if (rawDocKey) { + return json({ + doc_id: decodedDocId, + snapshot, + envelopes: [], // No envelopes needed for public Key + rawKey: rawDocKey, // Send RAW key to anonymous user + title: note?.title || "Untitled", + ownerId: note?.ownerId, + accessLevel, + }); + } + } + + // Check if we have the key escrowed in the documents table + if (doc?.serverEncryptedKey) { + console.log( + "[JOIN] Found serverEncryptedKey. Attempting to broker key exchange...", + ); + try { + rawDocKey = await decryptKeyForDevice( + doc.serverEncryptedKey, + serverIdentity.encryptionPrivateKey, + ); + console.log( + "[JOIN] Successfully decrypted Note Key using Server Identity.", + ); + } catch (e) { + console.error("[JOIN] Server failed to decrypt escrowed key:", e); + } + } + + // Fallback: If no server key, check if existing doc key is already raw (public/legacy) + if (!rawDocKey) { + if (encryptedDocKey.length <= 44) { + console.log( + `[JOIN] Key appears to be raw (Length: ${encryptedDocKey.length})`, + ); + rawDocKey = encryptedDocKey; } else { - console.error(`[JOIN] Owner has no private key! CANNOT DECRYPT.`); - // CRITICAL: Do not allow double encryption. Fail here. + // The key is encrypted (E2EE) and we don't have a broker copy. + // The server CANNOT decrypt the note key to re-encrypt it for the joining user. + console.warn( + `[JOIN] Request for E2EE note ${note?.id}. Server cannot fulfill automatically (No Escrow).`, + ); throw error( - 500, - "Owner missing private key - cannot share authenticated note", + 424, + "E2EE_KEY_UNAVAILABLE: Server cannot decrypt note key. The owner must be online to approve or the note must be shared via client-side flow.", + ); + } + } + + // Special Handling for Password Protected Notes + if (accessLevel === "password_protected") { + console.log("[JOIN] Password Protected Note."); + if (doc?.passwordEncryptedKey) { + return json({ + doc_id: decodedDocId, + snapshot, + envelopes: [], + passwordEncryptedKey: doc.passwordEncryptedKey, + title: note?.title || "Untitled", + ownerId: note?.ownerId, + accessLevel, + }); + } else { + // If password key is missing, it's an error state for this mode + throw error( + 424, + "PASSWORD_KEY_UNAVAILABLE: Note is password protected but no password key was found.", ); } - } else { - console.log( - `[JOIN] Key is already raw (Length: ${encryptedDocKey.length})`, - ); } // Debug Identity Fetching for (const handle of joiningUsers) { const id = await fetchUserIdentity(handle, requesting_server); - console.log( - ` [DEBUG] Fetched Identity for ${handle}:`, - JSON.stringify(id), - ); - if (id?.publicKey) { - console.log(` [DEBUG] Public Key for ${handle}: ${id.publicKey}`); - } } const envelopes = await generateKeyEnvelopesForUsers( @@ -238,7 +247,7 @@ export async function POST({ params, request }) { // Ensure documents entry exists (required for members FK constraint) // Use the actual note ID (which may be a portable ID) - const noteId = note?.id || doc_id; + const noteId = note?.id || decodedDocId; const docEntry = await db.query.documents.findFirst({ where: eq(documents.id, noteId), }); @@ -282,7 +291,7 @@ export async function POST({ params, request }) { } return json({ - doc_id, + doc_id: decodedDocId, snapshot, envelopes, title: note?.title || "Untitled", diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts new file mode 100644 index 0000000..411b9fb --- /dev/null +++ b/src/routes/logout/+server.ts @@ -0,0 +1,13 @@ +import { redirect } from "@sveltejs/kit"; +import { invalidateSession, deleteSessionTokenCookie } from "$lib/server/auth"; + +export async function POST(event) { + if (!event.locals.session) { + return redirect(302, "/login"); + } + + await invalidateSession(event.locals.session.token); + deleteSessionTokenCookie(event.cookies); + + return redirect(302, "/login"); +} diff --git a/src/routes/notes/[id]/+page.server.ts b/src/routes/notes/[id]/+page.server.ts index 91f75d4..ca05369 100644 --- a/src/routes/notes/[id]/+page.server.ts +++ b/src/routes/notes/[id]/+page.server.ts @@ -4,7 +4,7 @@ import { env } from "$env/dynamic/private"; export const load: PageServerLoad = async ({ params, locals }) => { const { id } = params; - const currentDomain = env.SERVER_DOMAIN || "localhost:5173"; + const currentDomain = env["SERVER_DOMAIN"] || "localhost:5173"; // Parse note ID to check origin const { origin, uuid } = parseNoteId(id); diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte index 3a3f1a0..b081063 100644 --- a/src/routes/notes/[id]/+page.svelte +++ b/src/routes/notes/[id]/+page.svelte @@ -23,11 +23,11 @@ let notesList = $state([]); let isLoadingNotes = $state(true); let id = $derived(page.params.id); - const userPrivateKey = data.user?.privateKeyEncrypted; let loroManager = $derived( id !== undefined ? loroManagers.get(id) : undefined, ); + // TODO: Use codemirror-server-render to SSR the editor let editorContent = $state(""); @@ -61,6 +61,12 @@ let joinError = $state(null); let redirectError = $state(null); + // Password Protection State + let showPasswordPrompt = $state(false); + let passwordInput = $state(""); + let passwordEncryptedBlob = $state(null); + let passwordError = $state(null); + // Auto-join foreign notes when authenticated $effect(() => { if ( @@ -70,7 +76,8 @@ !note && data.originServer && !joinedNotes.has(id) && - !isJoining + !isJoining && + !showPasswordPrompt // Don't auto-join if we are prompting for password ) { // This is a foreign note we haven't joined yet console.log(`Auto-joining foreign note from ${data.originServer}`); @@ -80,10 +87,29 @@ unawaited( joinFederatedNote({ noteId: id, originServer: data.originServer }) - .then(async () => { + .then(async (res) => { + if ( + res && + res.status === "needs_password" && + res.passwordEncryptedKey + ) { + console.log("Note requires password."); + passwordEncryptedBlob = res.passwordEncryptedKey; + showPasswordPrompt = true; + isJoining = false; + // Don't mark as joined yet + joinedNotes.delete(id); + return; + } + console.log("Successfully joined federated note"); isJoining = false; - // Invalidate data to reload notes list without full page refresh + + // Force refresh notes list to include the new note + const updatedNotes = await getNotes(); + notesList = updatedNotes; + + // Invalidate data to reload everything else await invalidateAll(); }) .catch((err) => { @@ -97,9 +123,65 @@ } }); + async function handlePasswordSubmit() { + if ( + !passwordInput || + !passwordEncryptedBlob || + !data.user || + !data.user.publicKey || + !data.originServer + ) + return; + + passwordError = null; + isJoining = true; + + try { + // 1. Decrypt the blob using the password + const { decryptWithPassword, encryptKeyForUser } = + await import("$lib/crypto"); + let rawKey = ""; + try { + rawKey = await decryptWithPassword( + passwordEncryptedBlob, + passwordInput, + ); + } catch (e) { + console.error("Password decryption failed:", e); + passwordError = "Incorrect password"; + isJoining = false; + return; + } + + // 2. Encrypt for myself + const preComputedKey = await encryptKeyForUser( + rawKey, + data.user.publicKey, + ); + + // 3. Complete Join + await joinFederatedNote({ + noteId: id!, + originServer: data.originServer, + preComputedKey, + }); + + // Success! + showPasswordPrompt = false; + const updatedNotes = await getNotes(); + notesList = updatedNotes; + invalidateAll(); + } catch (e) { + console.error("Failed to complete password join:", e); + passwordError = "Failed to unlock note. Please try again."; + } finally { + isJoining = false; + } + } + // Initialize Loro manager for the current note $effect(() => { - if (!id || !note || !userPrivateKey || !data.user) return; + if (!id || !note || !data.user) return; if (!loroManagers.has(id)) { console.log(`Initializing Loro manager for ${id}`); @@ -110,7 +192,16 @@ // If it's short (raw key), use it directly. let noteKey = note.encryptedKey; if (note.encryptedKey.length > 60) { - noteKey = await decryptKey(note.encryptedKey, userPrivateKey); + const rawPrivKey = sessionStorage.getItem( + "notes_raw_private_key", + ); + if (!rawPrivKey) { + console.warn( + "No raw private key found, cannot decrypt note key", + ); + return; + } + noteKey = await decryptKey(note.encryptedKey, rawPrivKey); } // Create manager @@ -121,7 +212,8 @@ // onUpdate: save snapshot (optional, mostly for backup since Ops are source of truth) // But we do update 'updatedAt' and maybe 'loroSnapshot' column? // The updateNote command handles updating the snapshot column. - if (note.ownerId === data.user.id) { + // Re-check data.user here as it might have changed or TS doesn't know + if (data.user && note.ownerId === data.user.id) { await updateNote({ noteId: id, loroSnapshot: snapshot }); } else { // Federated/Shared notes: We don't save snapshots to 'notes' table (as we don't own it). @@ -204,10 +296,10 @@
{#if note} - {#if !note?.isFolder} + {#if note && !note.isFolder && data.user} window.location.reload()}>Retry
+ {:else if showPasswordPrompt} +
+

Password Protected Note

+

+ This note requires a password to access. +

+ + {#if passwordError} +
{passwordError}
+ {/if} + + e.key === "Enter" && handlePasswordSubmit()} + /> + +
+ + +
+
{:else if !data.user}

diff --git a/src/routes/settings/account/+page.server.ts b/src/routes/settings/account/+page.server.ts new file mode 100644 index 0000000..6cc540a --- /dev/null +++ b/src/routes/settings/account/+page.server.ts @@ -0,0 +1,6 @@ +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + // Just ensure auth + // ... +}; diff --git a/src/routes/settings/account/+page.svelte b/src/routes/settings/account/+page.svelte new file mode 100644 index 0000000..fa38661 --- /dev/null +++ b/src/routes/settings/account/+page.svelte @@ -0,0 +1,143 @@ + + +
+

Account Settings

+ +
+
+

Change Password

+

+ This will re-encrypt your private key with your new password. You must + verify your old password to proceed (handled by server session check). + Wait - actually, since we are re-encrypting the RAW key in memory, we + don't STRICTLY need the old password if the vault is already unlocked. + But good practice implies asking for it. For this MVP, we will rely on + the fact that you are logged in + vault unlocked. +

+ + {#if statusMessage} +
{statusMessage}
+ {/if} + +
+ + + + +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+
From a827ad61c58bcf7879024c168bbc1b056ab7c351 Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Wed, 10 Dec 2025 15:00:54 -0600 Subject: [PATCH 20/32] fancy shmancy perms stuff --- src/hooks.server.ts | 4 +- src/lib/components/codemirror/Editor.svelte | 6 +- src/lib/loro.ts | 14 +- src/lib/remote/federation.remote.ts | 188 +++++++++--------- src/routes/+layout.svelte | 10 +- .../client/doc/[doc_id]/events/+server.ts | 25 ++- .../client/doc/[doc_id]/push/+server.ts | 154 +++++++++----- src/routes/notes/[id]/+page.svelte | 44 +++- 8 files changed, 285 insertions(+), 160 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 3c5c7c3..346bd8f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -13,7 +13,9 @@ const handleAuth: Handle = async ({ event, resolve }) => { !event.route.id?.startsWith("/(auth)") && !event.route.id?.startsWith("/federation") && !event.route.id?.startsWith("/.well-known") && - !event.route.id?.startsWith("/notes") + !event.route.id?.startsWith("/notes") && + !event.route.id?.startsWith("/client") && + !event.route.id?.startsWith("/api") ) { return new Response("Redirect", { status: 303, diff --git a/src/lib/components/codemirror/Editor.svelte b/src/lib/components/codemirror/Editor.svelte index cff7957..e31d9e3 100644 --- a/src/lib/components/codemirror/Editor.svelte +++ b/src/lib/components/codemirror/Editor.svelte @@ -127,7 +127,7 @@ let loroExtensions = $state([]); $effect(() => { - if (manager !== undefined && user !== undefined) { + if (manager !== undefined) { const ephemeral = new EphemeralStore(); const undoManager = new UndoManager(manager.doc, {}); @@ -135,7 +135,9 @@ manager.doc, { ephemeral, - user: { name: user.username, colorClassName: "bg-primary" }, + user: user + ? { name: user.username, colorClassName: "bg-primary" } + : { name: "Anonymous", colorClassName: "bg-base-content" }, }, undoManager, LoroNoteManager.getTextFromDoc, diff --git a/src/lib/loro.ts b/src/lib/loro.ts index 0bfe3a4..948ecec 100644 --- a/src/lib/loro.ts +++ b/src/lib/loro.ts @@ -1,4 +1,5 @@ import { decryptData, encryptData } from "$lib/crypto"; +import { encodeBase64, decodeBase64 } from "@oslojs/encoding"; import { syncSchemaJson } from "$lib/remote/notes.schemas.ts"; import { sync } from "$lib/remote/sync.remote.ts"; import { Schema } from "effect"; @@ -78,7 +79,7 @@ export class LoroNoteManager { const manager = new LoroNoteManager(noteId, noteKey, onUpdate); if (encryptedSnapshot) { - const encryptedBytes = Uint8Array.fromBase64(encryptedSnapshot); + const encryptedBytes = decodeBase64(encryptedSnapshot); const decrypted = await decryptData(encryptedBytes, manager.#noteKey); manager.doc.import(decrypted); } @@ -148,7 +149,7 @@ export class LoroNoteManager { console.warn("[Loro] Received op without payload:", op); continue; } - const updateBytes = Uint8Array.fromBase64(base64); + const updateBytes = decodeBase64(base64); this.doc.import(updateBytes); } // console.debug(`[Loro] Applied ${ops.length} ops`); @@ -157,6 +158,11 @@ export class LoroNoteManager { } }; + this.#eventSource.onopen = () => { + console.log("[Loro] SSE connected"); + this.connectionState.set("connected"); + }; + this.#eventSource.onerror = (error) => { console.error("SSE connection error:", error); // Browser will auto-reconnect usually, but let's be explicit about state @@ -198,7 +204,7 @@ export class LoroNoteManager { // Loro updates are CRDT blobs. // For federation Op Log, we wrap the blob. - const payload = update.toBase64(); + const payload = encodeBase64(update); const actorId = this.doc.peerIdStr; // string? // Loro API check: `doc.peerIdStr` exists. @@ -229,7 +235,7 @@ export class LoroNoteManager { mode: "snapshot", }) as Uint8Array; const encrypted = await encryptData(snapshot, this.#noteKey); - return encrypted.toBase64(); + return encodeBase64(encrypted); } /** diff --git a/src/lib/remote/federation.remote.ts b/src/lib/remote/federation.remote.ts index 7500baf..3aa1d96 100644 --- a/src/lib/remote/federation.remote.ts +++ b/src/lib/remote/federation.remote.ts @@ -1,4 +1,4 @@ -import { command } from "$app/server"; +import { command, getRequestEvent } from "$app/server"; import { db } from "$lib/server/db/index.ts"; import { documents, members } from "$lib/server/db/schema.ts"; import { requireLogin } from "$lib/server/auth.ts"; @@ -24,55 +24,58 @@ export const joinFederatedNote = command( console.log(" noteId:", noteId); console.log(" originServer:", originServer); - const { user } = requireLogin(); - // ... (rest of setup) - - const currentDomain = env["SERVER_DOMAIN"] || "localhost:5173"; - const { uuid, origin } = parseNoteId(noteId); + const event = getRequestEvent(); + const user = event?.locals.user; try { - // Check if already joined (check both full ID and uuid) - let existingDoc = await db.query.documents.findFirst({ - where: eq(documents.id, noteId), - with: { - members: { - where: (members) => eq(members.userId, user.id), - }, - }, - }); - - if (!existingDoc) { - existingDoc = await db.query.documents.findFirst({ - where: eq(documents.id, uuid), - with: { members: { where: (m) => eq(m.userId, user.id) } }, - }); - } + const currentDomain = env["SERVER_DOMAIN"] || "localhost:5173"; + const { uuid, origin } = parseNoteId(noteId); + + // Skip DB check if no user + if (user) { + try { + // Check if already joined (check both full ID and uuid) + let existingDoc = await db.query.documents.findFirst({ + where: eq(documents.id, noteId), + with: { + members: { + where: (members) => eq(members.userId, user!.id), + }, + }, + }); + + if (!existingDoc) { + existingDoc = await db.query.documents.findFirst({ + where: eq(documents.id, uuid), + with: { members: { where: (m) => eq(m.userId, user!.id) } }, + }); + } - const memberEntry = existingDoc?.members[0]; - const hasKey = !!memberEntry?.encryptedKeyEnvelope; + const memberEntry = existingDoc?.members[0]; + const hasKey = !!memberEntry?.encryptedKeyEnvelope; - if (existingDoc && hasKey) { - console.log(`Already joined note ${noteId} (Key found)`); - return { success: true, alreadyJoined: true }; + if (existingDoc && hasKey) { + console.log(`Already joined note ${noteId} (Key found)`); + return { success: true, alreadyJoined: true }; + } + } catch (e) { + console.error("DB check failed", e); + } } - // If we have a pre-computed key, use it directly without re-fetching signatures/remote if possible. - // But we still need to fetch remote to verify metadata if not exists? - // Actually, if we have preComputedKey, we assume the Client did the verification? - // No, Client only got the key from a previous failed attempt. We still need to create `documents` and `members` entries properly. - // So we PROCEED, but use `preComputedKey` instead of parsing envelopes. - // Call origin server's join endpoint const joinUrl = `http://${originServer}/federation/doc/${encodeURIComponent(noteId)}/join`; // Get server identity for signing const serverIdentity = await getServerIdentity(); const timestamp = Date.now().toString(); - const userHandle = `@${user.username}:${currentDomain}`; + const userHandle = user + ? `@${user.username}:${currentDomain}` + : `@anonymous:${currentDomain}`; const requestBody = { requesting_server: currentDomain, - users: [userHandle], + users: user ? [userHandle] : [], // Send empty user list if anonymous }; const message = `${currentDomain}:${timestamp}:${JSON.stringify(requestBody)}`; @@ -124,36 +127,28 @@ export const joinFederatedNote = command( // Check for RAW KEY first (Open Public) if (joinData.rawKey) { console.log(" [Federation] Note is Open Public. Using Raw Key."); - // We need to ENCRYPT this raw key for the user, so it matches the expectation of 'encryptedKeyEnvelope' - // User expects: decrypt(envelope, userPrivateKey) -> rawKey - // So envelope = encrypt(rawKey, userPublicKey) - // But we are on Server. We don't have user's raw private key, but we have user's Public Key? - // Wait, server has user.publicKey (Ed25519) in `users` table, but we need encryption key? - // If user is local, `users` table has `publicKey`. - // Actually, `encryptKeyForUser` logic. - // Let's use `encryptKeyForUser` helper. - const { encryptKeyForUser } = await import("$lib/crypto"); - // Note: This assumes `user.publicKey` is suitable for encryption or we derive it. - if (user.publicKey) { + + if (user && user.publicKey) { + const { encryptKeyForUser } = await import("$lib/crypto"); encryptedKeyEnvelope = await encryptKeyForUser( joinData.rawKey, user.publicKey, ); } else { - // Fallback: store raw key? Leaky? - // No, we must encrypt. - console.error( - " [Federation] Cannot encrypt public key: User has no Public Key.", + // Anonymous: We don't need an envelope because we don't save to DB. + // But we might want to return the rawKey directly in the response (handled by ...joinData) + console.log( + " [Federation] Anonymous user: Skipping envelope creation.", ); } } else { // Normal E2EE Envelope Logic let myEnvelope = joinData.envelopes?.find( (e: any) => - e.user_id === userHandle || - e.user_id === user.id || - e.user_id === `@${user.username}` || - e.user_id === user.username, + (user && e.user_id === userHandle) || + (user && e.user_id === user.id) || + (user && e.user_id === `@${user.username}`) || + (user && e.user_id === user.username), ); if ( @@ -168,55 +163,56 @@ export const joinFederatedNote = command( } } - if (!encryptedKeyEnvelope) { - console.error(" [Federation] No encrypted key envelope found!"); - // Continue but without key? No, fatal for join. - // Unless it is a metadata-only join? - } + // If logged in, we expect an envelope/key to save + if (user) { + if (!encryptedKeyEnvelope) { + console.error(" [Federation] No encrypted key envelope found!"); + } - // Store document metadata locally - // ... (existing db insert logic) - await db - .insert(documents) - .values({ - id: noteId, - hostServer: originServer, - ownerId: joinData.ownerId || user.id, - title: joinData.title || "Federated Note", - accessLevel: joinData.accessLevel || "private", - createdAt: new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: documents.id, - set: { + // Store document metadata locally + // ... (existing db insert logic) + await db + .insert(documents) + .values({ + id: noteId, + hostServer: originServer, + ownerId: joinData.ownerId || user.id, title: joinData.title || "Federated Note", accessLevel: joinData.accessLevel || "private", + createdAt: new Date(), updatedAt: new Date(), - }, - }); - - // Store member relationship - await db - .insert(members) - .values({ - docId: noteId, - userId: user.id, - deviceId: "default", - role: joinData.role || "writer", - encryptedKeyEnvelope: encryptedKeyEnvelope, - createdAt: new Date(), - }) - .onConflictDoUpdate({ - target: [members.docId, members.userId, members.deviceId], - set: { - encryptedKeyEnvelope: encryptedKeyEnvelope, + }) + .onConflictDoUpdate({ + target: documents.id, + set: { + title: joinData.title || "Federated Note", + accessLevel: joinData.accessLevel || "private", + updatedAt: new Date(), + }, + }); + + // Store member relationship + await db + .insert(members) + .values({ + docId: noteId, + userId: user.id, + deviceId: "default", role: joinData.role || "writer", - }, - }); + encryptedKeyEnvelope: encryptedKeyEnvelope, + createdAt: new Date(), + }) + .onConflictDoUpdate({ + target: [members.docId, members.userId, members.deviceId], + set: { + encryptedKeyEnvelope: encryptedKeyEnvelope, + role: joinData.role || "writer", + }, + }); + } console.log(`Successfully joined note ${uuid} from ${originServer}`); - return { success: true, alreadyJoined: false }; + return { success: true, alreadyJoined: false, ...joinData }; } catch (err) { console.error("Federation join error:", err); if (err instanceof Error) { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 89f6e43..c3077dc 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -104,7 +104,15 @@ toggleSidebar, }); - const notesList = $derived(data.user ? await getNotes() : []); + let notesList = $state([]); + + $effect(() => { + if (data.user) { + getNotes().then((notes) => (notesList = notes)); + } else { + notesList = []; + } + }); // Initialize from localStorage and handle responsive behavior onMount(() => { diff --git a/src/routes/client/doc/[doc_id]/events/+server.ts b/src/routes/client/doc/[doc_id]/events/+server.ts index 72666fd..94f5bb5 100644 --- a/src/routes/client/doc/[doc_id]/events/+server.ts +++ b/src/routes/client/doc/[doc_id]/events/+server.ts @@ -4,20 +4,39 @@ import { eq, gt, asc, and } from "drizzle-orm"; import type { RequestHandler } from "./$types"; import { error } from "@sveltejs/kit"; import { notePubSub } from "$lib/server/pubsub"; +import { parseNoteId } from "$lib/noteId"; +import { env } from "$env/dynamic/private"; + export const GET: RequestHandler = async ({ params, url }) => { const { doc_id } = params; + console.log("HIT-SSE: Handler invoked for", doc_id); const since = url.searchParams.get("since"); // Default to 0 (beginning of time) to fetch full history if 'since' is not provided. - // This ensures that when a client connects (especially for the first time), - // it receives all existing ops to reconstruct the document state. let lastTs = since ? parseInt(since) : 0; console.log(`[EVENTS] Connection request for ${doc_id}, since=${since}`); - const doc = await db.query.documents.findFirst({ + let doc = await db.query.documents.findFirst({ where: eq(documents.id, doc_id), }); + // If not in DB, check if it's a valid remote ID (Ephemeral/Anonymous access) + if (!doc) { + try { + const { origin } = parseNoteId(doc_id); + + if (origin) { + console.log( + `[EVENTS] Ephemeral note inferred from ID. Origin: ${origin}. Proxying...`, + ); + doc = { hostServer: origin } as any; + } + } catch (e) { + console.error("[EVENTS] ERROR parsing note ID:", e); + // Fallthrough to 404 + } + } + if (!doc) { console.error(`[EVENTS] Document not found: ${doc_id}`); throw error(404, "Document not found"); diff --git a/src/routes/client/doc/[doc_id]/push/+server.ts b/src/routes/client/doc/[doc_id]/push/+server.ts index 6acf3f7..5d03047 100644 --- a/src/routes/client/doc/[doc_id]/push/+server.ts +++ b/src/routes/client/doc/[doc_id]/push/+server.ts @@ -4,32 +4,53 @@ import { federatedOps, documents } from "$lib/server/db/schema"; import { eq } from "drizzle-orm"; import { signServerRequest } from "$lib/server/identity"; import { notePubSub } from "$lib/server/pubsub"; +import { parseNoteId } from "$lib/noteId"; export async function POST({ params, request, locals }) { const { doc_id } = params; const body = await request.json(); const { op } = body; - // Op structure: { op_id, actor_id, lamport_ts, encrypted_payload, signature } - if (!locals.user) { - throw error(401, "Unauthorized"); - } - - // Check if doc is remote - const doc = await db.query.documents.findFirst({ + let doc = await db.query.documents.findFirst({ where: eq(documents.id, doc_id), }); - if (doc && doc.hostServer !== "local") { + // Handle ephemeral/anonymous notes (not in DB) + if (!doc) { + try { + const { origin } = parseNoteId(doc_id); + if (origin) { + console.log(`[CLIENT] Infers ephemeral note origin: ${origin}`); + doc = { hostServer: origin, accessLevel: "public" } as any; + } + } catch (e) { + console.warn("Failed to parse ephemeral ID for push:", e); + } + } + + // Permission Check + if (!doc) { + throw error(404, "Note not found"); + } + + // If private and not logged in, deny + if (doc.accessLevel === "private" && !locals.user) { + throw error(401, "Unauthorized"); + } + // TODO: fine-grained auth for 'mixed' mode (e.g. public read, private write) + // For now, if it's 'public' or 'open', we allow anonymous writes. + + if (doc.hostServer !== "local") { // Proxy to remote server console.log( `[CLIENT] Proxying push to remote server: ${doc.hostServer} for ${doc_id}`, ); const remoteUrl = `http://${doc.hostServer}/federation/doc/${encodeURIComponent(doc_id)}/ops`; - const payload = { ops: [op] }; // Federation endpoint expects array of ops + const payload = { ops: [op] }; console.log(`[CLIENT] Signing request...`); + // We sign as the SERVER, not the user. const { signature, timestamp, @@ -50,49 +71,90 @@ export async function POST({ params, request, locals }) { console.log(`[CLIENT] Remote response status: ${res.status}`); if (!res.ok) { - const text = await res.text(); - console.error( - `[CLIENT] Failed to push to remote server: ${res.status}`, - text, - ); - throw error(500, "Failed to push to remote server"); + // If remote 401s, we 401? + throw error(res.status, "Remote push failed"); } - - // We successfully pushed to remote. - // Do we store it locally too? - // Yes, otherwise we won't see our own changes if we reload/poll? - // But strictly speaking, we should receive it back via sync/events. - // However, for latency, we might want to store it. - // BUT, if we store it, we might duplicate it when we poll? - // `onConflictDoNothing` handles duplicates. - // So safe to store locally too. } - // Store Op locally (even if remote, to cache/optimistic update) + // Store Op locally ONLY if we have a real local document record + // Ephemeral notes (doc.hostServer inferred but not in DB) cannot store ops locally due to FK. + // We check if the doc was actually found in DB. + // We can check if 'createdAt' exists or similar, but cleaner is to re-check specific flag or use original query result. + // Actually, 'doc' is mutated above. + // Let's rely on checking if it exists in DB. + + // Re-query or check if it has an ID/real fields? + // The 'doc' variable might be our mock object. + // Check if we can just generic 'try/catch' the insert. + try { - console.log( - `[CLIENT] Inserting op ${op.op_id} into federatedOps (docId: ${doc_id})`, - ); - // Construct normalized Op matching DB schema - const normalizedOp = { - id: op.op_id, - docId: doc_id, - opId: op.op_id, - actorId: op.actor_id, - lamportTs: op.lamport_ts, - payload: op.encrypted_payload, // Normalize to 'payload' - signature: op.signature, - createdAt: new Date(), - }; - - await db.insert(federatedOps).values(normalizedOp).onConflictDoNothing(); - console.log(`[CLIENT] Local insertion successful for ${op.op_id}`); - - // Publish to PubSub for real-time subscribers (Federation SSE & Local SSE) - notePubSub.publish(doc_id, [normalizedOp]); + // Check if doc exists in DB using a quick query or assume from context. + // If we just pushed to remote, we might be done. + // But if we are the HOST, we MUST insert. + // If doc.hostServer is local, then 'doc' MUST be from DB (since we can't infer local origin from ID for *new* notes without DB record usually, unless we are being hacked). + // Actually, if !doc, we only inferred if origin != null. + + // Simplest: Try insert. If FK violation, ignore? + // But we don't want to throw 500. + + // Only insert if we think it's in the DB. + // Since we don't distinguish easily with the 'doc' var reuse, let's just attempt insert and catch specific error, or only insert if hostServer is local? + // No, we cache remote ops too IF we have the doc stub. + + // Better: Helper variable 'existsInDb'. + const existsInDb = !!(await db.query.documents.findFirst({ + where: eq(documents.id, doc_id), + columns: { id: true }, + })); + + if (existsInDb) { + console.log( + `[CLIENT] Inserting op ${op.op_id} into federatedOps (docId: ${doc_id})`, + ); + const normalizedOp = { + id: op.op_id, + docId: doc_id, + opId: op.op_id, + actorId: op.actor_id, + lamportTs: op.lamport_ts, + payload: op.encrypted_payload, + signature: op.signature, + createdAt: new Date(), + }; + + await db.insert(federatedOps).values(normalizedOp).onConflictDoNothing(); + console.log(`[CLIENT] Local insertion successful for ${op.op_id}`); + + // Publish to PubSub + notePubSub.publish(doc_id, [normalizedOp]); + } else { + console.log( + `[CLIENT] Skipping local storage for ephemeral/remote note ${doc_id}`, + ); + // But we DO need to publish to PubSub so the Client SSE (which is listening) gets the echo back! + // The Client SSE subscribes to notePubSub. + // So anonymous user sees their own change reflected immediately? + // Or do they wait for remote roundtrip? + // If we proxy, the remote executes. + // Does the remote send it back via SSE? + // Yes, if we are subscribed. + + // Optimistic local update via PubSub even if not in DB? + // notePubSub is memory-based. So YES/safe. + notePubSub.publish(doc_id, [ + { + ...op, + payload: op.encrypted_payload, // map for client compatibility + } as any, + ]); + } } catch (err) { console.error(`[CLIENT] Local insertion failed for ${op.op_id}:`, err); - throw error(500, "Failed to store operation locally"); + // If we successfully proxied, maybe don't fail the whole request? + // If local, we must fail. + if (doc.hostServer === "local") { + throw error(500, "Failed to store operation locally"); + } } return json({ success: true }); diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte index b081063..66e2976 100644 --- a/src/routes/notes/[id]/+page.svelte +++ b/src/routes/notes/[id]/+page.svelte @@ -70,7 +70,7 @@ // Auto-join foreign notes when authenticated $effect(() => { if ( - data.user && + // Remove data.user check to allow anonymous !data.isLocal && id && !note && @@ -106,11 +106,41 @@ isJoining = false; // Force refresh notes list to include the new note - const updatedNotes = await getNotes(); - notesList = updatedNotes; + try { + if (data.user) { + const updatedNotes = await getNotes(); + notesList = updatedNotes; + } else { + throw new Error("Anonymous user cannot fetch notes"); + } + } catch (e) { + // Anonymous or failure: Construct ephemeral note + console.log("Using ephemeral note for anonymous/failed fetch"); + if (res && res.rawKey) { + notesList = [ + { + id: res.doc_id || id, + title: res.title || "Shared Note", + ownerId: res.ownerId || "", + encryptedKey: res.rawKey, // Use raw key directly + isFolder: false, + order: 0, + parentId: null, + createdAt: new Date(), + updatedAt: new Date(), + content: "", + accessLevel: res.accessLevel || "public", + loroSnapshot: res.snapshot || null, + serverEncryptedKey: null, + }, + ]; + } + } - // Invalidate data to reload everything else - await invalidateAll(); + // Invalidate data to reload everything else (only if logged in) + if (data.user) { + await invalidateAll(); + } }) .catch((err) => { console.error("Federation join failed:", err); @@ -181,7 +211,7 @@ // Initialize Loro manager for the current note $effect(() => { - if (!id || !note || !data.user) return; + if (!id || !note) return; if (!loroManagers.has(id)) { console.log(`Initializing Loro manager for ${id}`); @@ -296,7 +326,7 @@
{#if note} - {#if note && !note.isFolder && data.user} + {#if note && !note.isFolder} Date: Wed, 10 Dec 2025 16:36:13 -0600 Subject: [PATCH 21/32] feat: Add drafted design spec for sharing and permissions. --- SHARING.md | 350 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 SHARING.md diff --git a/SHARING.md b/SHARING.md new file mode 100644 index 0000000..6d22bff --- /dev/null +++ b/SHARING.md @@ -0,0 +1,350 @@ +# **Federated Notes App — Structure, Permissions, and Sharing Spec** + +## **Status**: + +**_Draft_** + +# Please Approve or Edit + +--- + +## **1. Core Design Invariants** + +The system MUST obey the following rules at all times: + +1. Spaces define visibility +2. Visibility has exactly one source +3. Notes never define visibility +4. Per-note permissions restrict actions only +5. Structure never implies permission +6. Permission never implies structure +7. One space has exactly one authority(server in the federated system) of record + +Any feature that violates one of these invariants is invalid. + +--- + +## **2. Spaces** + +### **2.1 Definition** + +A **Space** is the only internal visibility and collaboration boundary. + +### **2.2 Properties** + + Space + - space_id + - authority_id + - members: Map + - root_folder_id + +### **2.3 Rules** + +- Every space has exactly one authority of record +- Spaces cannot be nested +- Spaces may include members from multiple federations +- All notes in a space are owned by the space authority +- All space members can see all notes in the space +- Spaces may not include notes owned by other authorities + +### **2.4 Purpose** + +Answers the question: + +> Who can see all content here by default? + +--- + +## **3. Folders (Within Spaces)** + +### **3.1 Definition** + +Folders are structural containers inside a space. + +### **3.2 Rules** + +- Folders inherit space visibility +- Folders may be nested arbitrarily +- Folders cannot be shared +- Folders cannot alter permissions +- Folders cannot contain notes from other spaces + +### **3.3 Purpose** + +Answers the question: + +> How is content organized within the space? + +--- + +## **4. Notes** + +### **4.1 Definition** + +A **Note** is the smallest unit of content and the smallest externally shareable unit. + +### **4.2 Properties** + + Note + - note_id + - space_id + - authority_id + - content + - metadata + +### **4.3 Rules** + +- A note belongs to exactly one space +- A note is visible to all space members +- A note may be included in zero or more Share Groups +- A note may have per-note action restrictions +- A note cannot exist in multiple spaces simultaneously + +--- + +## **5. Per-Note Action Permissions** + +### **5.1 Definition** + +Per-note permissions restrict **actions**, not visibility. + +### **5.2 Allowed Restrictions** + +- Read +- Comment +- Suggest +- Edit +- Lock +- Prevent delete +- Prevent move +- Prevent rename + +### **5.3 Forbidden Capabilities** + +- Restricting visibility +- Adding new viewers +- Creating sub-audiences +- Partial space visibility + +### **5.4 Resolution Order** + + Effective Action Permission = + least_privileged( + External Share Role (if applicable), + Note Policy, + Space Role + ) + +### **5.5 Rule** + +If a user can see a note, per-note policies only determine what they may do to it. + +--- + +## **6. Collections** + +### **6.1 Definition** + +Collections are **local, per-user organizational folders for spaces**. + +### **6.2 Rules** + +- Collections are client-side only +- Collections are not shared +- Collections have no permissions +- Collections do not affect visibility +- Collections are not part of URLs or linking + +### **6.3 Invariant** + +Removing collections does not affect collaboration or access. + +--- + +## **7. File Tree** + +### **7.1 Purpose** + +The file tree displays structural truth only. + +### **7.2 Displays** + +- Spaces +- Folders +- Notes + +### **7.3 Does Not Display** + +- External share groupings +- Permission bundles +- Visibility differences + +### **7.4 Annotations** + +Notes may display informational badges indicating: + +- External sharing +- Locked or restricted actions + +These annotations do not alter structure. + +--- + +## **8. External Shares** + +## **9\. Share Groups** + +### **9.1 Definition** + +An **is the only wat\* y to share a norte mm, re ,,h o outside of a space.f +A **Share Group\*\* is a first-class permission object representing an external share. + +### **9.2 Properties** + + ShareGroup + - share_group_id + - owner_space_id + - note_ids[] + - user_ids[] + - role (viewer | commenter | editor) + +### **.3 Rules** + +- Only listed notes are visible +- Only listed users receive access +- No inheritance +- No structure +- Not represented in the file tree + +--- + +## **10\. Visibility Resolution (Authoritative)** + +A user may see a note if and only if: + + user ∈ space.members + OR + (user ∈ share_group.users AND note ∈ share_group.notes) + +Exactly one visibility source applies. + +--- + +## **11\. External User Experience** + +### **11.1 Entry Point** + +. +**Shared with Me** + +### **11.2 Display Model** + +Each Share Group appears as a grouped entry. + + Shared with Me + Project – Teacher Review + - notes.md + - research.md + - summary.md + +### **11.3 Restrictions** + +External users: + +- Cannot browse spaces or folders +- Cannot see sibling notes +- Cannot resolve non-shared links +- Cannot view backlinks or graphs + +--- + +## **12\. Private Notes** + +### **12.1 Definition** + +A private note is a note in a single-user space. + +### **12.2 Sharing** + +Private notes are shared exclusively via Share Groups. + +There is no separate private-sharing mechanism. + +--- + +## **13\. Moving Notes Between Spaces** + +. + +### **13.1 Definition** + +Moving a note between spaces changes its ownership and visibility. + +### **13.2 Same-Authority Move** + +- space_id is updated +- note_id remains unchanged +- Note becomes visible to destination space members +- All external shares are revoked + +### **13.3 Cross-Authority Move** + +- Conte. is exported +- New note is created under the destination authority +- New note_id is assigned +- Original note may be deleted explicitly +- Links are rewritten where possible + +### **13.4 UX Requirement** + +Users must be warned on move: + +> Moving this note will make it visible to all members of the destination space. +> External shares will be removed. + +--- + +## **15\. Explicitly Disallowed** + +The system will never support: + +- Nested spaces +- Folder-level sharing +- Structure-derived permissions +- Partial visibility inside a space +- Cross-authority notes within a space +- Implicit sharing +- Share Groups as navigable containers + +--- + +## **16\. Model Summary** + +. +**Layer** + +**Responsibility** + +Spaces + +Visibility and ownership + +Folders + +Structure + +Notes +. +Content + +Note Policies + +Action restrictions + +Collections + +Personal navigation + +Share Groups + +Explicit visibility exceptions From 16f2680cb4e054f4955fd8eaa7f2a5580d6c34d2 Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:24:12 -0600 Subject: [PATCH 22/32] start cleaning --- src/lib/components/Sidebar.svelte | 9 +- src/lib/components/codemirror/Toolbar.svelte | 207 +++++++------------ src/lib/components/sidebar-context.ts | 5 +- src/lib/loro.ts | 8 +- src/routes/+layout.svelte | 5 +- 5 files changed, 87 insertions(+), 147 deletions(-) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 895aaf4..a0ffd96 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -1,5 +1,4 @@
- {#if sidebarCtx?.isCollapsed} + {#if sidebarCtx.isCollapsed}
{/if} - - {#each sortedGroups as group, index (index)} - {#if shouldShowAsButtons(index)} -
- {#each group.tools as tool (tool.title)} - {@const Icon = tool.icon} - - {/each} -
-
+ + {#each sortedGroups as group, i (group.label ?? group.priority)} + {@const isLast = i === sortedGroups.length - 1} + {@const priorityClass = + group.priority === 1 + ? "" + : group.priority === 2 + ? "hidden @md:flex" + : "hidden @lg:flex"} +
+ {#each group.tools as tool (tool.title)} + {@const Icon = tool.icon} + + {/each} +
+ {#if !isLast} +
{/if} {/each} - - {#if collapsedGroups.length > 0} - diff --git a/src/lib/components/sidebar-context.ts b/src/lib/components/sidebar-context.ts index bd8c70c..f874446 100644 --- a/src/lib/components/sidebar-context.ts +++ b/src/lib/components/sidebar-context.ts @@ -1,6 +1,9 @@ -export const SIDEBAR_CONTEXT_KEY = Symbol("sidebar-context"); +import { createContext } from "svelte"; export interface SidebarContext { isCollapsed: boolean; toggleSidebar: () => void; } + +export const [getSidebarContext, setSidebarContext] = + createContext(); diff --git a/src/lib/loro.ts b/src/lib/loro.ts index 948ecec..fc50b7c 100644 --- a/src/lib/loro.ts +++ b/src/lib/loro.ts @@ -263,16 +263,16 @@ export class LoroNoteManager { * Get version history with user attribution * Returns an array of version snapshots */ - getHistory(): Array<{ + getHistory(): { version: number; timestamp: Date; preview: string; - }> { - const history: Array<{ + }[] { + const history: { version: number; timestamp: Date; preview: string; - }> = []; + }[] = []; // Get current version const currentVersion = this.doc.version(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c3077dc..950f721 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,8 +6,7 @@ import favicon from "$lib/assets/favicon.svg"; import Sidebar from "$lib/components/Sidebar.svelte"; import { getNotes } from "$lib/remote/notes.remote.ts"; - import { setContext } from "svelte"; - import { SIDEBAR_CONTEXT_KEY } from "$lib/components/sidebar-context"; + import { setSidebarContext } from "$lib/components/sidebar-context.js"; import { decryptWithPassword } from "$lib/crypto.ts"; @@ -97,7 +96,7 @@ isCollapsed = !isCollapsed; } - setContext(SIDEBAR_CONTEXT_KEY, { + setSidebarContext({ get isCollapsed() { return isCollapsed; }, From ef0f52a5094b0a2dd3ae4882e199d7c6201b6a64 Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:06:57 -0600 Subject: [PATCH 23/32] feat: versions and times and shtuffies --- package.json | 1 + pnpm-lock.yaml | 15 +++ src/lib/components/HistoryPanel.svelte | 37 ++---- src/lib/loro.ts | 79 ++++++++----- src/lib/remote/accounts.remote.ts | 4 +- src/lib/utils/time.ts | 28 +++++ src/routes/+page.svelte | 18 +-- src/routes/notes/[id]/+page.svelte | 150 +++++++------------------ 8 files changed, 158 insertions(+), 174 deletions(-) create mode 100644 src/lib/utils/time.ts diff --git a/package.json b/package.json index 9c9c95d..805c0de 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "loro-codemirror": "^0.3.3", "loro-crdt": "^1.10.0", "svelte": "^5.44.0", + "temporal-polyfill": "^0.3.0", "tiptap-markdown": "^0.9.0", "typescript-svelte-plugin": "^0.3.50" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd49989..a3c36f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: svelte: specifier: ^5.44.0 version: 5.45.7 + temporal-polyfill: + specifier: ^0.3.0 + version: 0.3.0 tiptap-markdown: specifier: ^0.9.0 version: 0.9.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) @@ -3136,6 +3139,12 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + temporal-polyfill@0.3.0: + resolution: {integrity: sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==} + + temporal-spec@0.3.0: + resolution: {integrity: sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -6510,6 +6519,12 @@ snapshots: tapable@2.3.0: {} + temporal-polyfill@0.3.0: + dependencies: + temporal-spec: 0.3.0 + + temporal-spec@0.3.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) diff --git a/src/lib/components/HistoryPanel.svelte b/src/lib/components/HistoryPanel.svelte index e91d4c7..9b63bdc 100644 --- a/src/lib/components/HistoryPanel.svelte +++ b/src/lib/components/HistoryPanel.svelte @@ -1,7 +1,7 @@ {#if isOpen} @@ -115,20 +95,22 @@
- {#if entry.author} + {#if entry.peerId}
- {(entry.author ?? "?").charAt(0).toUpperCase()} + {entry.peerId.slice(0, 2).toUpperCase()}
- {entry.author} + {entry.peerId.slice(0, 8)} {:else} Unknown {/if}
- {formatTime(entry.timestamp)} + {formatRelativeTime(entry.timestamp)}
@@ -158,9 +140,10 @@ {#if selectedVersion !== null && selectedVersion !== history[0]?.version} + {@const cachedVersion = selectedVersion}

- Last updated: {new Date( - data.randomNote.updatedAt, - ).toLocaleDateString()} + Last updated: {formatRelativeTime( + Temporal.Instant.fromEpochMilliseconds( + data.randomNote.updatedAt.getTime(), + ), + )}

diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte index 66e2976..e15c403 100644 --- a/src/routes/notes/[id]/+page.svelte +++ b/src/routes/notes/[id]/+page.svelte @@ -1,27 +1,32 @@
{name[0]?.toUpperCase()}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 950f721..fc8ae2c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,29 +2,80 @@ import "./layout.css"; import { onNavigate } from "$app/navigation"; - import { onMount } from "svelte"; import favicon from "$lib/assets/favicon.svg"; import Sidebar from "$lib/components/Sidebar.svelte"; - import { getNotes } from "$lib/remote/notes.remote.ts"; import { setSidebarContext } from "$lib/components/sidebar-context.js"; - - import { decryptWithPassword } from "$lib/crypto.ts"; - - // ... (previous imports) - import { setupEncryption } from "$lib/remote/accounts.remote.ts"; + import { getNotes } from "$lib/remote/notes.remote.ts"; import { + decryptWithPassword, + encryptWithPassword, generateSigningKeyPair, generateEncryptionKeyPair, - encryptWithPassword, } from "$lib/crypto.ts"; - - // ... (previous imports) + import { PersistedState } from "runed"; + import { onMount } from "svelte"; + import { unawaited } from "$lib/unawaited.js"; let { children, data } = $props(); - // Sidebar collapse state - let isCollapsed = $state(false); + // User's explicit preference (persisted to localStorage) + const collapsedDesktop = new PersistedState("sidebarCollapsed", false); + // Mobile state (always starts collapsed) + let collapsedMobile = $state(true); + + // Track window width + let innerWidth = $state(0); + let isDesktop = $derived(innerWidth >= 768); + + // Derived visual state + let isCollapsed = $derived( + isDesktop ? collapsedDesktop.current : collapsedMobile, + ); + + // Handle transitions between breakpoints + let wasDesktop = $state(true); + + $effect(() => { + if (innerWidth === 0) return; + + // Desktop -> Mobile: Always collapse + if (wasDesktop && !isDesktop) { + collapsedMobile = true; + } + + // Mobile -> Desktop: If mobile was open, keep open + if (!wasDesktop && isDesktop) { + if (!collapsedMobile) { + collapsedDesktop.current = false; + } + } + + wasDesktop = isDesktop; + }); + + function toggleSidebar() { + if (isDesktop) { + collapsedDesktop.current = !collapsedDesktop.current; + } else { + collapsedMobile = !collapsedMobile; + } + } + + // Keyboard shortcut: Ctrl+\ (or Cmd+\ on Mac) + function handleKeydown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key === "\\") { + e.preventDefault(); + toggleSidebar(); + } + } + + setSidebarContext({ + get isCollapsed() { + return isCollapsed; + }, + toggleSidebar, + }); // Vault State let isVaultUnlocked = $state(false); @@ -87,89 +138,34 @@ sessionStorage.setItem("notes_raw_private_key", rawKey); isVaultUnlocked = true; unlockPassword = ""; // clear memory - } catch (e) { + } catch { unlockError = "Incorrect password"; } } - function toggleSidebar() { - isCollapsed = !isCollapsed; - } - - setSidebarContext({ - get isCollapsed() { - return isCollapsed; - }, - toggleSidebar, - }); - - let notesList = $state([]); - - $effect(() => { - if (data.user) { - getNotes().then((notes) => (notesList = notes)); - } else { - notesList = []; - } - }); + const notesList = $derived(data.user ? await getNotes() : []); // Initialize from localStorage and handle responsive behavior onMount(() => { - (async () => { - if (data.user) { - // Try to auto-unlock if key is already in session - const existingKey = sessionStorage.getItem("notes_raw_private_key"); - if (existingKey) { - isVaultUnlocked = true; - } else { - // Try temporary password from login redirect - const tempPw = sessionStorage.getItem("notes_temp_password"); - if (tempPw) { - unlockPassword = tempPw; - await unlockVault(); - sessionStorage.removeItem("notes_temp_password"); + unawaited( + (async () => { + if (data.user) { + // Try to auto-unlock if key is already in session + const existingKey = sessionStorage.getItem("notes_raw_private_key"); + if (existingKey) { + isVaultUnlocked = true; + } else { + // Try temporary password from login redirect + const tempPw = sessionStorage.getItem("notes_temp_password"); + if (tempPw) { + unlockPassword = tempPw; + await unlockVault(); + sessionStorage.removeItem("notes_temp_password"); + } } } - } - })(); - - // Load saved state from localStorage - const saved = localStorage.getItem("sidebarCollapsed"); - if (saved !== null) { - isCollapsed = saved === "true"; - } else { - // Auto-collapse on mobile screens - isCollapsed = window.innerWidth < 768; - } - - // Handle window resize for automatic collapse - const handleResize = () => { - if (window.innerWidth < 768 && !isCollapsed) { - isCollapsed = true; - } - }; - - window.addEventListener("resize", handleResize); - - // Keyboard shortcut: Ctrl+B (or Cmd+B on Mac) - const handleKeydown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "b") { - e.preventDefault(); - toggleSidebar(); - } - }; - - window.addEventListener("keydown", handleKeydown); - - return () => { - window.removeEventListener("resize", handleResize); - window.removeEventListener("keydown", handleKeydown); - }; - }); - - // Save to localStorage whenever state changes - $effect(() => { - localStorage.setItem("sidebarCollapsed", String(isCollapsed)); + })(), + ); }); onNavigate((navigation) => { @@ -184,6 +180,8 @@ }); + + diff --git a/src/routes/api/sync/[noteId]/+server.ts b/src/routes/api/sync/[noteId]/+server.ts index 0581705..9c794a9 100644 --- a/src/routes/api/sync/[noteId]/+server.ts +++ b/src/routes/api/sync/[noteId]/+server.ts @@ -43,7 +43,7 @@ export const GET = async ({ params, locals }) => { keepAliveInterval = setInterval(() => { try { c.enqueue(encoder.encode(`: keep-alive\n\n`)); - } catch (e) { + } catch { clearInterval(keepAliveInterval); } }, 15000); From f768bc62ca756f9a999f5a86eb2670a025d19e62 Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:30:26 -0600 Subject: [PATCH 25/32] feat: improve history --- AGENTS.md | 39 +------- src/lib/components/HistoryPanel.svelte | 105 +++++++++++++++----- src/lib/components/MembersModal.svelte | 11 +- src/lib/components/ProfilePicture.svelte | 4 +- src/lib/components/Sidebar.svelte | 2 +- src/lib/components/codemirror/Editor.svelte | 29 ++---- 6 files changed, 99 insertions(+), 91 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2db9ea2..ddafa9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,39 +183,6 @@ Lints specified files and returns errors/warnings. Use this to validate your cod **Usage:** `mcp_eslint_lint-files` with an array of absolute file paths. -### 4. Serena MCP Server (`mcp_oraios_serena_*`) - -Intelligent code navigation and symbolic editing: - -#### Code Exploration - -- `get_symbols_overview` - Get high-level view of symbols in a file (use this FIRST) -- `find_symbol` - Find and read specific symbols by name path -- `find_referencing_symbols` - Find where symbols are used -- `search_for_pattern` - Flexible regex-based code search - -#### Code Editing - -- `replace_symbol_body` - Replace entire symbol body (methods, classes, etc.) -- `insert_after_symbol` - Insert code after a symbol -- `insert_before_symbol` - Insert code before a symbol -- `rename_symbol` - Rename symbols throughout codebase - -#### Memory Management - -- `write_memory` - Save project information for future reference -- `read_memory` - Retrieve saved project context -- `edit_memory` - Update existing memories -- `delete_memory` - Remove outdated memories - -#### Project Management - -- `activate_project` - Switch between registered projects -- `get_current_config` - View current configuration - -> [!TIP] -> Use symbolic tools to read only necessary code. Start with `get_symbols_overview` before reading full files. - ### 5. Socket MCP Server (`mcp__extension_so_depscore`) Dependency security and quality scoring: @@ -504,14 +471,14 @@ Since Svelte 5.25, you can reassign `$derived` values to temporarily override th When using SvelteKit remote functions, you can use `.updates()` with `.withOverride()` to optimistically update the cache of a query. ```typescript -import { getPosts, createPost } from '$lib/remote/posts.remote'; +import { getPosts, createPost } from "$lib/remote/posts.remote"; async function handleSubmit() { - const newPost = { id: 'temp', title: 'New Post' }; + const newPost = { id: "temp", title: "New Post" }; // Optimistically update the getPosts query cache await createPost(newPost).updates( - getPosts().withOverride((posts) => [newPost, ...posts]) + getPosts().withOverride((posts) => [newPost, ...posts]), ); } ``` diff --git a/src/lib/components/HistoryPanel.svelte b/src/lib/components/HistoryPanel.svelte index 9b63bdc..39ea839 100644 --- a/src/lib/components/HistoryPanel.svelte +++ b/src/lib/components/HistoryPanel.svelte @@ -2,18 +2,22 @@ import { Clock, RotateCcw, User } from "@lucide/svelte"; import type { HistoryEntry, LoroNoteManager } from "$lib/loro.ts"; import { formatRelativeTime } from "$lib/utils/time.ts"; + import ProfilePicture from "./ProfilePicture.svelte"; interface Props { manager: LoroNoteManager | undefined; isOpen: boolean; - onClose: () => void; } - let { manager, isOpen, onClose }: Props = $props(); + let { manager, isOpen = $bindable() }: Props = $props(); let history = $state([]); let selectedVersion = $state(null); - let unsubscribe: (() => void) | null = null; + let unsubscribe: (() => void) | undefined; + let drawerDialog: HTMLDialogElement; + let restoreDialog: HTMLDialogElement; + let versionToRestore = $state(null); + let isClosing = $state(false); // Load history from Loro document function loadHistory() { @@ -37,22 +41,53 @@ } return () => { - if (unsubscribe) { - unsubscribe(); - unsubscribe = null; - } + unsubscribe?.(); + unsubscribe = undefined; }; }); - function restoreVersion(version: number) { - // TODO: Implement version restoration using Loro's checkout functionality - console.log("Restoring version:", version); + // Sync isOpen with dialog + $effect(() => { + if (isOpen) { + isClosing = false; + if (!drawerDialog.open) drawerDialog.showModal(); + } else { + if (drawerDialog.open) { + isClosing = true; + setTimeout(() => { + drawerDialog.close(); + isClosing = false; + }, 300); + } + } + }); + + function promptRestore(version: number) { + versionToRestore = version; + restoreDialog.showModal(); + } + + function confirmRestore() { + if (versionToRestore !== null) { + // TODO: Implement version restoration using Loro's checkout functionality + console.log("Restoring version:", versionToRestore); + versionToRestore = null; + } } -{#if isOpen} + { + isOpen = false; + }} +>
Version History
{/if}
+ + - - -{/if} + + + + diff --git a/src/lib/components/MembersModal.svelte b/src/lib/components/MembersModal.svelte index 2bbf0ef..57b3b4c 100644 --- a/src/lib/components/MembersModal.svelte +++ b/src/lib/components/MembersModal.svelte @@ -1,5 +1,6 @@
- {name[0]?.toUpperCase()} + {name.toUpperCase()}
diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index a0ffd96..7a83344 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -328,7 +328,7 @@ role="button" class="btn h-auto min-h-0 gap-3 rounded-lg px-3 py-2 normal-case btn-ghost hover:bg-base-200" > - + {user?.username ?? "Anonymous"} diff --git a/src/lib/components/codemirror/Editor.svelte b/src/lib/components/codemirror/Editor.svelte index e31d9e3..33e3cc9 100644 --- a/src/lib/components/codemirror/Editor.svelte +++ b/src/lib/components/codemirror/Editor.svelte @@ -38,8 +38,6 @@ noteTitle, }: Props = $props(); - // ... (existing code) ... - import { LoroExtensions } from "loro-codemirror"; import Codemirror from "./Codemirror.svelte"; import HistoryPanel from "$lib/components/HistoryPanel.svelte"; @@ -126,7 +124,7 @@ let loroExtensions = $state([]); - $effect(() => { + $effect.pre(() => { if (manager !== undefined) { const ephemeral = new EphemeralStore(); const undoManager = new UndoManager(manager.doc, {}); @@ -146,12 +144,18 @@ return () => { ephemeral.destroy(); }; - } else { - loroExtensions = []; - return; } + + return; }); + let extensions = $derived([ + coreExtensions, + wikilinksExtension(notesList), + loroExtensions, + editorTheme, + ]); + const tools = [ { priority: 1, @@ -246,13 +250,6 @@ ], }, ]; - - let extensions = $derived([ - coreExtensions, - wikilinksExtension(notesList), - loroExtensions, - editorTheme, - ]);
@@ -260,11 +257,7 @@ - (isHistoryOpen = false)} - /> + Date: Wed, 10 Dec 2025 13:30:11 -0600 Subject: [PATCH 26/32] improvements --- src/lib/components/TreeItem.svelte | 8 +++++--- src/lib/components/codemirror/Codemirror.svelte | 13 ++++++------- src/routes/notes/[id]/+page.svelte | 13 +++++++------ 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/lib/components/TreeItem.svelte b/src/lib/components/TreeItem.svelte index 8111363..41bc4f9 100644 --- a/src/lib/components/TreeItem.svelte +++ b/src/lib/components/TreeItem.svelte @@ -209,8 +209,9 @@
- - {#if isOpen} -
- - - {#if isOwner} - - -
- - - {:else} -
- - - {/if} -
- {/if} -
diff --git a/src/lib/components/ShareModal.svelte b/src/lib/components/ShareModal.svelte index b8d9df3..aac67ec 100644 --- a/src/lib/components/ShareModal.svelte +++ b/src/lib/components/ShareModal.svelte @@ -5,8 +5,8 @@ Lock, Globe, UserPlus, - Loader2, Check, + LoaderCircle, } from "@lucide/svelte"; import { decryptKey, encryptWithPassword } from "$lib/crypto"; @@ -342,7 +342,7 @@ {#if invitedUsers.length > 0}
- {#each invitedUsers as user} + {#each invitedUsers as user (user)}
@@ -412,7 +412,7 @@ disabled={saving || loading} > {#if saving} - + Saving... {:else if success} diff --git a/src/lib/remote/accounts.remote.ts b/src/lib/remote/accounts.remote.ts index 72cad8b..f8245c1 100644 --- a/src/lib/remote/accounts.remote.ts +++ b/src/lib/remote/accounts.remote.ts @@ -87,7 +87,7 @@ export const login = form( const redirectTo = url.searchParams.get("redirectTo") ?? "/"; // Validate redirectTo to prevent open redirect attacks const safeRedirect = redirectTo.startsWith("/") ? redirectTo : "/"; - throw redirect(302, safeRedirect); + redirect(302, safeRedirect); }, ); diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte index 5423b21..27776e9 100644 --- a/src/routes/(auth)/login/+page.svelte +++ b/src/routes/(auth)/login/+page.svelte @@ -16,7 +16,19 @@
{/each} -
+ { + // Cache password for the layout to use for decryption + if (_password) { + sessionStorage.setItem("notes_temp_password", _password); + } + + // TODO: do we even need this? + await submit(); + })} + >