From f57889189356b875e765a29d6a1c5c06d0fed3d3 Mon Sep 17 00:00:00 2001 From: ParkerH27 Date: Mon, 8 Dec 2025 20:03:49 -0600 Subject: [PATCH 1/9] feat: Implement initial federation features including identity, shared document sync, and note sharing UI. --- package.json | 3 + pnpm-lock.yaml | 1405 ++++++++--------- scripts/logout.ts | 27 + src/hooks.server.ts | 7 +- src/lib/components/ShareModal.svelte | 226 +++ src/lib/components/codemirror/Editor.svelte | 68 +- src/lib/crypto.ts | 310 ++-- src/lib/loro.ts | 60 +- src/lib/noteId.ts | 45 + src/lib/remote/federation.remote.ts | 102 ++ src/lib/remote/notes.remote.ts | 5 +- src/lib/server/db/schema.ts | 83 +- src/lib/server/identity.ts | 60 + src/routes/+page.svelte | 23 +- .../notes-identity/[handle]/+server.ts | 42 + .../.well-known/notes-server/+server.ts | 10 + .../client/doc/[doc_id]/events/+server.ts | 54 + .../client/doc/[doc_id]/push/+server.ts | 35 + .../federation/doc/[doc_id]/join/+server.ts | 131 ++ .../federation/doc/[doc_id]/ops/+server.ts | 91 ++ src/routes/federation/import/+page.server.ts | 149 ++ src/routes/notes/[id]/+page.server.ts | 26 +- src/routes/notes/[id]/+page.svelte | 232 ++- 23 files changed, 2160 insertions(+), 1034 deletions(-) create mode 100644 scripts/logout.ts create mode 100644 src/lib/components/ShareModal.svelte create mode 100644 src/lib/noteId.ts create mode 100644 src/lib/remote/federation.remote.ts create mode 100644 src/lib/server/identity.ts create mode 100644 src/routes/.well-known/notes-identity/[handle]/+server.ts create mode 100644 src/routes/.well-known/notes-server/+server.ts create mode 100644 src/routes/client/doc/[doc_id]/events/+server.ts create mode 100644 src/routes/client/doc/[doc_id]/push/+server.ts create mode 100644 src/routes/federation/doc/[doc_id]/join/+server.ts create mode 100644 src/routes/federation/doc/[doc_id]/ops/+server.ts create mode 100644 src/routes/federation/import/+page.server.ts diff --git a/package.json b/package.json index fbc690a..9f2370b 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "@milkdown/theme-nord": "^7.17.1", "@milkdown/utils": "^7.17.1", "@modelcontextprotocol/sdk": "^1.22.0", + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", "@node-rs/argon2": "^2.0.2", "@prosemark/core": "^0.0.4", "@prosemark/paste-rich-text": "^0.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9b6e78..bd49989 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 1.1.0 '@benrbray/prosemirror-math': specifier: ^1.0.0 - version: 1.0.0(katex@0.16.25)(prosemirror-commands@1.7.1)(prosemirror-history@1.5.0)(prosemirror-inputrules@1.5.1)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.10.5)(prosemirror-view@1.41.3) + version: 1.0.0(katex@0.16.27)(prosemirror-commands@1.7.1)(prosemirror-history@1.5.0)(prosemirror-inputrules@1.5.1)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.10.5)(prosemirror-view@1.41.4) '@codemirror/autocomplete': specifier: ^6.20.0 version: 6.20.0 @@ -46,55 +46,64 @@ importers: version: 6.1.3 '@codemirror/view': specifier: ^6.38.8 - version: 6.38.8 + version: 6.39.0 '@effect/platform': specifier: ^0.93.3 - version: 0.93.3(effect@3.19.6) + version: 0.93.6(effect@3.19.9) '@lezer/highlight': specifier: ^1.2.3 version: 1.2.3 '@lezer/markdown': specifier: ^1.6.0 - version: 1.6.0 + version: 1.6.1 '@lucide/svelte': specifier: ^0.554.0 - version: 0.554.0(svelte@5.44.0) + version: 0.554.0(svelte@5.45.7) '@milkdown-lab/plugin-split-editing': specifier: ^1.3.1 - version: 1.3.1(@milkdown/core@7.17.1)(@milkdown/prose@7.17.1) + version: 1.3.1(@milkdown/core@7.17.3)(@milkdown/prose@7.17.3) '@milkdown/core': specifier: ^7.17.1 - version: 7.17.1 + version: 7.17.3 '@milkdown/ctx': specifier: ^7.17.1 - version: 7.17.1 + version: 7.17.3 '@milkdown/plugin-history': specifier: ^7.17.1 - version: 7.17.1 + version: 7.17.3 '@milkdown/plugin-listener': specifier: ^7.17.1 - version: 7.17.1 + version: 7.17.3 '@milkdown/preset-commonmark': specifier: ^7.17.1 - version: 7.17.1 + version: 7.17.3 '@milkdown/preset-gfm': specifier: ^7.17.1 - version: 7.17.1 + version: 7.17.3 '@milkdown/theme-nord': specifier: ^7.17.1 - version: 7.17.1 + version: 7.17.3 '@milkdown/utils': specifier: ^7.17.1 - version: 7.17.1 + version: 7.17.3 '@modelcontextprotocol/sdk': specifier: ^1.22.0 - version: 1.22.0 + version: 1.24.3(zod@4.1.13) + '@noble/ciphers': + specifier: ^2.1.1 + version: 2.1.1 + '@noble/curves': + specifier: ^2.0.1 + version: 2.0.1 + '@noble/hashes': + specifier: ^2.0.1 + version: 2.0.1 '@node-rs/argon2': specifier: ^2.0.2 version: 2.0.2 '@prosemark/core': specifier: ^0.0.4 - version: 0.0.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.0)(@codemirror/lang-markdown@6.5.0)(@codemirror/language-data@6.5.2)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/highlight@1.2.3) + version: 0.0.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.0)(@codemirror/lang-markdown@6.5.0)(@codemirror/language-data@6.5.2)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.39.0)(@lezer/highlight@1.2.3) '@prosemark/paste-rich-text': specifier: ^0.0.2 version: 0.0.2 @@ -103,16 +112,16 @@ importers: version: 0.0.5(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.0)(@codemirror/lang-markdown@6.5.0)(@codemirror/language-data@6.5.2)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@lezer/highlight@1.2.3) '@tiptap/core': specifier: ^3.11.0 - version: 3.11.0(@tiptap/pm@3.11.0) + version: 3.13.0(@tiptap/pm@3.13.0) '@tiptap/extension-mathematics': specifier: ^3.11.0 - version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)(katex@0.16.25) + version: 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(katex@0.16.27) '@tiptap/extension-typography': specifier: ^3.11.0 - version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + version: 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) '@tiptap/starter-kit': specifier: ^3.11.0 - version: 3.11.0 + version: 3.13.0 bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -130,25 +139,25 @@ importers: version: 17.2.3 effect: specifier: ^3.19.6 - version: 3.19.6 + version: 3.19.9 katex: specifier: ^0.16.25 - version: 0.16.25 + version: 0.16.27 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) + version: 0.3.3(@codemirror/state@6.5.2)(@codemirror/view@6.39.0)(loro-crdt@1.10.3) loro-crdt: specifier: ^1.10.0 - version: 1.10.0 + version: 1.10.3 svelte: specifier: ^5.44.0 - version: 5.44.0 + version: 5.45.7 tiptap-markdown: specifier: ^0.9.0 - version: 0.9.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + version: 0.9.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) typescript-svelte-plugin: specifier: ^0.3.50 - version: 0.3.50(svelte@5.44.0)(typescript@5.9.3) + version: 0.3.50(svelte@5.45.7)(typescript@5.9.3) devDependencies: '@effect/language-service': specifier: ^0.56.0 @@ -170,25 +179,25 @@ importers: version: 1.1.0 '@sveltejs/adapter-auto': specifier: ^7.0.0 - version: 7.0.0(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1))) + version: 7.0.0(@sveltejs/kit@2.49.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))) '@sveltejs/kit': specifier: ^2.49.0 - version: 2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + version: 2.49.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) '@sveltejs/vite-plugin-svelte': specifier: ^6.2.1 - version: 6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + version: 6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.1.17) '@tailwindcss/vite': specifier: ^4.1.17 - version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + version: 4.1.17(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) '@types/node': specifier: ^24.10.1 version: 24.10.1 drizzle-kit: specifier: ^0.31.7 - version: 0.31.7 + version: 0.31.8 drizzle-orm: specifier: ^0.44.7 version: 0.44.7(@libsql/client@0.15.15) @@ -197,25 +206,25 @@ importers: version: 9.39.1(jiti@2.6.1) eslint-plugin-svelte: specifier: ^3.13.0 - version: 3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.44.0) + version: 3.13.1(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.7) globals: specifier: ^16.5.0 version: 16.5.0 playwright: specifier: ^1.56.1 - version: 1.56.1 + version: 1.57.0 prettier: specifier: ^3.6.2 - version: 3.6.2 + version: 3.7.4 prettier-plugin-svelte: specifier: ^3.4.0 - version: 3.4.0(prettier@3.6.2)(svelte@5.44.0) + version: 3.4.0(prettier@3.7.4)(svelte@5.45.7) prettier-plugin-tailwindcss: specifier: ^0.7.1 - version: 0.7.1(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.44.0))(prettier@3.6.2) + version: 0.7.2(prettier-plugin-svelte@3.4.0(prettier@3.7.4)(svelte@5.45.7))(prettier@3.7.4) svelte-check: specifier: ^4.3.4 - version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3) + version: 4.3.4(picomatch@4.0.3)(svelte@5.45.7)(typescript@5.9.3) tailwindcss: specifier: ^4.1.17 version: 4.1.17 @@ -224,19 +233,19 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.47.0 - version: 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + version: 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.2.4 - version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + version: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) vite-plugin-devtools-json: specifier: ^1.0.0 - version: 1.0.0(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + version: 1.0.0(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) vite-plugin-top-level-await: specifier: ^1.6.0 - version: 1.6.0(rollup@4.53.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + version: 1.6.0(rollup@4.53.3)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) vite-plugin-wasm: specifier: ^3.5.0 - version: 3.5.0(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + version: 3.5.0(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) packages: @@ -353,8 +362,8 @@ packages: '@codemirror/theme-one-dark@6.1.3': resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} - '@codemirror/view@6.38.8': - resolution: {integrity: sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==} + '@codemirror/view@6.39.0': + resolution: {integrity: sha512-pn7UA5RDNLFpdM4PTyqwb1qQ/hQ3brwUKYAlJGrg3972VHJotgXrVBdBAWcbMkOjERXX609fmqfRldnGkC96kw==} '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -363,10 +372,10 @@ packages: resolution: {integrity: sha512-gvJaHoeXMHAoA6+Xyj9Vdq52yDCs+ECLbKpHvxHtdJP/C0D9b3JFEfLjdVuw37zoWcYS856um4rgEYHlW2LSEQ==} hasBin: true - '@effect/platform@0.93.3': - resolution: {integrity: sha512-s88zctkeXba24Mjy7MEFMuam1p5sXmsG7uQjPIDE6EiC+2IFUQd8976TtangiU0e8qu0SALpjIH1P1QyC7/1og==} + '@effect/platform@0.93.6': + resolution: {integrity: sha512-I5lBGQWzWXP4zlIdPs7z7WHmEFVBQhn+74emr/h16GZX96EEJ6I1rjGaKyZF7mtukbMuo9wEckDPssM8vskZ/w==} peerDependencies: - effect: ^3.19.4 + effect: ^3.19.8 '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -704,8 +713,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.1': @@ -752,8 +761,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lezer/common@1.3.0': - resolution: {integrity: sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==} + '@lezer/common@1.4.0': + resolution: {integrity: sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==} '@lezer/cpp@1.1.3': resolution: {integrity: sha512-ykYvuFQKGsRi6IcE+/hCSGUhb/I4WPjd3ELhEblm2wS2cOznDFzO+ubK2c+ioysOnlZ3EduV+MVQFCPzAIoY3w==} @@ -779,11 +788,11 @@ packages: '@lezer/json@1.0.3': resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} - '@lezer/lr@1.4.3': - resolution: {integrity: sha512-yenN5SqAxAPv/qMnpWW0AT7l+SxVrgG+u0tNsRQWqbrz66HIl8DnEbBObvy21J5K7+I1v7gsAnlE2VQ5yYVSeA==} + '@lezer/lr@1.4.4': + resolution: {integrity: sha512-LHL17Mq0OcFXm1pGQssuGTQFPPdxARjKM8f7GA5+sGtHi0K3R84YaSbmche0+RKWHnCsx9asEe5OWOI4FHfe4A==} - '@lezer/markdown@1.6.0': - resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==} + '@lezer/markdown@1.6.1': + resolution: {integrity: sha512-72ah+Sml7lD8Wn7lnz9vwYmZBo9aQT+I2gjK/0epI+gjdwUbWw3MJ/ZBGEqG1UfrIauRqH37/c5mVHXeCTGXtA==} '@lezer/php@1.0.5': resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} @@ -878,47 +887,48 @@ packages: '@milkdown/core': '>=7.0.0' '@milkdown/prose': '>=7.0.0' - '@milkdown/core@7.17.1': - resolution: {integrity: sha512-JzmBd3YBxsrZUnXG4AM3jtOCyDhg8nMUeBwtVoS6h3qF1pduZZzXnkpjyAsDc2ZDGjnhlTOS9mNcyQUfDj1PXQ==} + '@milkdown/core@7.17.3': + resolution: {integrity: sha512-jaXERBsH+I8wtJ20nHs30zoAQ8O3BWmO1DzuiCx3tmq6VB1M/xQQmU0yTiZBkL5SlC52AH+6YQgpC7L40BT02A==} - '@milkdown/ctx@7.17.1': - resolution: {integrity: sha512-TPPo54lkSUFtWtd9L9nujQznsguJ1QEHGbt09Y7KcGVJC92jIHyeAgxRwRBR3EYoe0HhSJI3eaxxzI/VtcODEg==} + '@milkdown/ctx@7.17.3': + resolution: {integrity: sha512-fIj/GkJhr218VtIbAvhEgakZD2joGXCfAh9Ay1XwM2DR2RnjxtnJuywjdUnhyswupTmYmp/k0mUHSFUa8qI9yQ==} - '@milkdown/exception@7.17.1': - resolution: {integrity: sha512-JbIWyfQXKuDfLGTQDXM9asYTtiLF2qMf9oRGtMb0PuQrs7I1Z2zhwx/ee112ZZMxHtCXWDYnFrTi8LXhE1ZqGg==} + '@milkdown/exception@7.17.3': + resolution: {integrity: sha512-LDtyId/VZdOcVsctRg7gBGtq1C1zxBghKlOTdB50Uc36Hp2QAqc2IjD4cv3TwQblneUxs0X16MXnxCA9Q8bw2g==} - '@milkdown/plugin-history@7.17.1': - resolution: {integrity: sha512-yyqYxnvbE19c1qWtKFX3llvsjZ06AAvJU5Eb+RNnZFuqpfXyPkgRwh2T1Wo+WnlFVCjiotRee46y8BLQlLCqdQ==} + '@milkdown/plugin-history@7.17.3': + resolution: {integrity: sha512-mAlEqWF45lCr/xqZ/ZhFovI+DHi4kr+WIY8kLP5v6VXRwrt4OWJapzXVSJ37sN+n+DSfVRxlyCKPCDRHX5B9kQ==} - '@milkdown/plugin-listener@7.17.1': - resolution: {integrity: sha512-v0ga+efth4D2Cr+ffCvM7BHSoqOQX+ySy/gzRH2yiBH0dLnLHgJP5V5O6U4Z4r8UH/Wx0BgZ0FIRngUEhh/Aqw==} + '@milkdown/plugin-listener@7.17.3': + resolution: {integrity: sha512-plThVsl9JDopMfienFxlEpzq/k9sAqaG3VF9kknVR/ELLsJ6Qq07n1wXBHiC4P9TZqzLzR+WNOmeMNj0lNI4jg==} - '@milkdown/preset-commonmark@7.17.1': - resolution: {integrity: sha512-kWiH91tJ1qdzGM3F7Yr6HmsnyLLbwQqpYo2P6QJCkOzWhABOwTx5jQuGwdgKA7qt+wk0TK7AisXSCcbHiwUwfg==} + '@milkdown/preset-commonmark@7.17.3': + resolution: {integrity: sha512-f875wPTwg5kxKYuDWs0S9AFVcaY0PjQ1YpO0NsAdVgDANwurUpqUY9/KoRKJGAFDRiE59Yq5YD9Lx5vnQ07OuQ==} - '@milkdown/preset-gfm@7.17.1': - resolution: {integrity: sha512-clHa77NLTbf9FQBbKa1ucMFDUojnIbqeg9edIS9XL1LrByGZXaaLZUvRhCktFHwg9qloF778vX4McW3tLHVgfQ==} + '@milkdown/preset-gfm@7.17.3': + resolution: {integrity: sha512-IY+Hyhe6cCUF2Fi+KNfzbFdTaepKCkYNbbv65Dze7Dkb0VgoPfyiRdzznKiX7luXJnZv8Y+QHr+8KXirxzKVfg==} - '@milkdown/prose@7.17.1': - resolution: {integrity: sha512-0Kr42QUR1mAh45R7AQPqct5MeGCRWvV4qfQN92a8DpLYIsvi9XpEGAB7m/KUUxYTBCrRN2ZHpcd+p9vhP0tldA==} + '@milkdown/prose@7.17.3': + resolution: {integrity: sha512-ZgHUJGKWwIo52TrBzeStz7wsrKBineJ1b1FJMtsJjC9RElXaYzZZEmT7vP1aItknX8UXDr1m0a4fWKcCtykcjw==} - '@milkdown/theme-nord@7.17.1': - resolution: {integrity: sha512-axiJjpBIi54z6EFZS/zvmp9Xeo0W2ZaO5jyyAC62yUOsm5clyTaq4vep6v0CDLoOyAtzIgCwRdKyO+C6tbpHeA==} + '@milkdown/theme-nord@7.17.3': + resolution: {integrity: sha512-gdCLQQ6N0NB1BLasjFGzf4jxbokA5DuI2Nl33kinvtJmzQQMuTx2fZqhztollLQnlMf9odGqX6gu5rD6xWRjkg==} - '@milkdown/transformer@7.17.1': - resolution: {integrity: sha512-RfKo+v1R0UWlLgpbKSFSTtKc3Hd7RAd0vRLR/d5ku4/5IpOGYplGEUsxF77qVgik6/yiox79R3HqkQNJ5e4/3Q==} + '@milkdown/transformer@7.17.3': + resolution: {integrity: sha512-XIeMV/X6R9YhaYSTytZGOwVMsVCXBgXVaP9OpABMceXR9hYfbrZZlurXirTMJYayYZ3IVZZJHQ56/wQ4URD2pg==} - '@milkdown/utils@7.17.1': - resolution: {integrity: sha512-QTjbaxv+ZOB4a1BaQULkeJExyIvMnQw69UKf9QoM/E8iY2q1c8kppnN6i6ZeN9ZkCh4lXu+r7w/LH6zSFXrsdA==} + '@milkdown/utils@7.17.3': + resolution: {integrity: sha512-8JWqfhdupzoZMv7btqYAAXRyqhaOGffh2zpSoD+t9enL0MFvIHsenWS8W8h7XRTL6E6+N6kFZ1qyBHMg6E5Idg==} '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} - '@modelcontextprotocol/sdk@1.22.0': - resolution: {integrity: sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==} + '@modelcontextprotocol/sdk@1.24.3': + resolution: {integrity: sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 peerDependenciesMeta: '@cfworker/json-schema': optional: true @@ -959,6 +969,18 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@node-rs/argon2-android-arm-eabi@2.0.2': resolution: {integrity: sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==} engines: {node: '>= 10'} @@ -1046,18 +1068,6 @@ packages: resolution: {integrity: sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==} engines: {node: '>= 10'} - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - '@oslojs/asn1@1.0.0': resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} @@ -1222,8 +1232,8 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - '@sveltejs/acorn-typescript@1.0.7': - resolution: {integrity: sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==} + '@sveltejs/acorn-typescript@1.0.8': + resolution: {integrity: sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==} peerDependencies: acorn: ^8.9.0 @@ -1232,8 +1242,8 @@ packages: peerDependencies: '@sveltejs/kit': ^2.0.0 - '@sveltejs/kit@2.49.0': - resolution: {integrity: sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==} + '@sveltejs/kit@2.49.1': + resolution: {integrity: sha512-vByReCTTdlNM80vva8alAQC80HcOiHLkd8XAxIiKghKSHcqeNfyhp3VsYAV8VSiPKu4Jc8wWCfsZNAIvd1uCqA==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -1433,143 +1443,143 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 - '@tiptap/core@3.11.0': - resolution: {integrity: sha512-kmS7ZVpHm1EMnW1Wmft9H5ZLM7E0G0NGBx+aGEHGDcNxZBXD2ZUa76CuWjIhOGpwsPbELp684ZdpF2JWoNi4Dg==} + '@tiptap/core@3.13.0': + resolution: {integrity: sha512-iUelgiTMgPVMpY5ZqASUpk8mC8HuR9FWKaDzK27w9oWip9tuB54Z8mePTxNcQaSPb6ErzEaC8x8egrRt7OsdGQ==} peerDependencies: - '@tiptap/pm': ^3.11.0 + '@tiptap/pm': ^3.13.0 - '@tiptap/extension-blockquote@3.11.0': - resolution: {integrity: sha512-0H8WVW6Vn4GJ7sQ6wfyDgUU+DqM8fp62g8N0fFPiEhoYtpIYUmCqGhpKnqYR0tet6ofFa648XmA6n2VX7sugzw==} + '@tiptap/extension-blockquote@3.13.0': + resolution: {integrity: sha512-K1z/PAIIwEmiWbzrP//4cC7iG1TZknDlF1yb42G7qkx2S2X4P0NiqX7sKOej3yqrPjKjGwPujLMSuDnCF87QkQ==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-bold@3.11.0': - resolution: {integrity: sha512-V/c3XYO09Le9GlBGq1MK4c97Fffi0GADQTbZ+LFoi65nUrAwutn5wYnXBcEyWQI6RmFWVDJTieamqtc4j9teyw==} + '@tiptap/extension-bold@3.13.0': + resolution: {integrity: sha512-VYiDN9EEwR6ShaDLclG8mphkb/wlIzqfk7hxaKboq1G+NSDj8PcaSI9hldKKtTCLeaSNu6UR5nkdu/YHdzYWTw==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-bullet-list@3.11.0': - resolution: {integrity: sha512-IKdb1C3bHA1sGPiUcntkL+wHebRg71K5+tgaaRnMw0qmtcpcOQb5zhQOSm5bXUsgCk/WgT04dkZPnpn6Gg1PvQ==} + '@tiptap/extension-bullet-list@3.13.0': + resolution: {integrity: sha512-fFQmmEUoPzRGiQJ/KKutG35ZX21GE+1UCDo8Q6PoWH7Al9lex47nvyeU1BiDYOhcTKgIaJRtEH5lInsOsRJcSA==} peerDependencies: - '@tiptap/extension-list': ^3.11.0 + '@tiptap/extension-list': ^3.13.0 - '@tiptap/extension-code-block@3.11.0': - resolution: {integrity: sha512-y01RJVbygDJWYXxZ0SiCYwvUF2X91RANCLSdb8X0qiwVPgNOzsDrrzS/iqoXkiYmM93pJw+ZWelEZxRvxEwsrg==} + '@tiptap/extension-code-block@3.13.0': + resolution: {integrity: sha512-kIwfQ4iqootsWg9e74iYJK54/YMIj6ahUxEltjZRML5z/h4gTDcQt2eTpnEC8yjDjHeUVOR94zH9auCySyk9CQ==} peerDependencies: - '@tiptap/core': ^3.11.0 - '@tiptap/pm': ^3.11.0 + '@tiptap/core': ^3.13.0 + '@tiptap/pm': ^3.13.0 - '@tiptap/extension-code@3.11.0': - resolution: {integrity: sha512-5OpR5O4bveHe1KG9CJsto86NgkuerYq3OLY78vzh9uFCLdv7xgXA2aZYJfRMhbZ7hKsR7hHg1etBJUCk+TKsMg==} + '@tiptap/extension-code@3.13.0': + resolution: {integrity: sha512-sF5raBni6iSVpXWvwJCAcOXw5/kZ+djDHx1YSGWhopm4+fsj0xW7GvVO+VTwiFjZGKSw+K5NeAxzcQTJZd3Vhw==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-document@3.11.0': - resolution: {integrity: sha512-N2G3cwL2Dtur/CgD/byJmFx9T5no6fTO/U462VP3rthQYrRA1AB3TCYqtlwJkmyoxRTNd4qIg4imaPl8ej6Heg==} + '@tiptap/extension-document@3.13.0': + resolution: {integrity: sha512-RjU7hTJwjKXIdY57o/Pc+Yr8swLkrwT7PBQ/m+LCX5oO/V2wYoWCjoBYnK5KSHrWlNy/aLzC33BvLeqZZ9nzlQ==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-dropcursor@3.11.0': - resolution: {integrity: sha512-gW/QMGAyiXGSpO+X/lTeiBQn1Or8T8UVB3y9Cv2Lh6zx0SWU+FA28EH+y6s3fm872reN4dH/9rEvMuJjhU/BEw==} + '@tiptap/extension-dropcursor@3.13.0': + resolution: {integrity: sha512-m7GPT3c/83ni+bbU8c+3dpNa8ug+aQ4phNB1Q52VQG3oTonDJnZS7WCtn3lB/Hi1LqoqMtEHwhepU2eD+JeXqQ==} peerDependencies: - '@tiptap/extensions': ^3.11.0 + '@tiptap/extensions': ^3.13.0 - '@tiptap/extension-gapcursor@3.11.0': - resolution: {integrity: sha512-lXGEZiYX7k/pEFr8BgDE91vqjLTwuf+qhHLTgIpfhbt562nShLPIDj9Vzu3xrR4fwUAMiUNiLyaeInb8j3I4kg==} + '@tiptap/extension-gapcursor@3.13.0': + resolution: {integrity: sha512-KVxjQKkd964nin+1IdM2Dvej/Jy4JTMcMgq5seusUhJ9T9P8F9s2D5Iefwgkps3OCzub/aF+eAsZe+1P5KSIgA==} peerDependencies: - '@tiptap/extensions': ^3.11.0 + '@tiptap/extensions': ^3.13.0 - '@tiptap/extension-hard-break@3.11.0': - resolution: {integrity: sha512-NJEHTj++kFOayQXKSQSi9j9eAG33eSiJqai2pf4U+snW94fmb8cYLUurDmfYRe20O6EzBSX0X3GjVlkOz+5b7A==} + '@tiptap/extension-hard-break@3.13.0': + resolution: {integrity: sha512-nH1OBaO+/pakhu+P1jF208mPgB70IKlrR/9d46RMYoYbqJTNf4KVLx5lHAOHytIhjcNg+MjyTfJWfkK+dyCCyg==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-heading@3.11.0': - resolution: {integrity: sha512-4Eo67Yo7vsYLkizcMoGdZAR9aHbC7FFTrqfNEd4Em3ajRi0iNqyWMaI90UCYlitDdRdqFlq/njWrMqBOLUgaWQ==} + '@tiptap/extension-heading@3.13.0': + resolution: {integrity: sha512-8VKWX8waYPtUWN97J89em9fOtxNteh6pvUEd0htcOAtoxjt2uZjbW5N4lKyWhNKifZBrVhH2Cc2NUPuftCVgxw==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-horizontal-rule@3.11.0': - resolution: {integrity: sha512-FugFHZG+oiMBV6k42hn9NOA4wRNc2b9UeEIMR+XwEMpWJInV4VwSwDvu8JClgkDo8z7FEnker9e51DZ00CLWqg==} + '@tiptap/extension-horizontal-rule@3.13.0': + resolution: {integrity: sha512-ZUFyORtjj22ib8ykbxRhWFQOTZjNKqOsMQjaAGof30cuD2DN5J5pMz7Haj2fFRtLpugWYH+f0Mi+WumQXC3hCw==} peerDependencies: - '@tiptap/core': ^3.11.0 - '@tiptap/pm': ^3.11.0 + '@tiptap/core': ^3.13.0 + '@tiptap/pm': ^3.13.0 - '@tiptap/extension-italic@3.11.0': - resolution: {integrity: sha512-WP6wL2b//8bLVdeUCWOpYA7nUStvrAMMD0nRn0F9CEW+l7vH6El2PZFhHmJ9uqXo5MnyugBpARiwgxfoAlef5w==} + '@tiptap/extension-italic@3.13.0': + resolution: {integrity: sha512-XbVTgmzk1kgUMTirA6AGdLTcKHUvEJoh3R4qMdPtwwygEOe7sBuvKuLtF6AwUtpnOM+Y3tfWUTNEDWv9AcEdww==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-link@3.11.0': - resolution: {integrity: sha512-RoUkGqowVMKLE76KktNOGhzNMyKtwrSDRqeYCe1ODPuOMZvDGexOE8cIuA4A1ODkgN6ji9qE/9Sf8uhpZdH39Q==} + '@tiptap/extension-link@3.13.0': + resolution: {integrity: sha512-LuFPJ5GoL12GHW4A+USsj60O90pLcwUPdvEUSWewl9USyG6gnLnY/j5ZOXPYH7LiwYW8+lhq7ABwrDF2PKyBbA==} peerDependencies: - '@tiptap/core': ^3.11.0 - '@tiptap/pm': ^3.11.0 + '@tiptap/core': ^3.13.0 + '@tiptap/pm': ^3.13.0 - '@tiptap/extension-list-item@3.11.0': - resolution: {integrity: sha512-KXTTSBH/T/WW8O1YhK/lVmwlSGh2w2VVucUkMLhgk1VPchahAkn2LfgbgKrCRG/F8M8Jlfvz67iJDo6+bbNqew==} + '@tiptap/extension-list-item@3.13.0': + resolution: {integrity: sha512-63NbcS/XeQP2jcdDEnEAE3rjJICDj8y1SN1h/MsJmSt1LusnEo8WQ2ub86QELO6XnD3M04V03cY6Knf6I5mTkw==} peerDependencies: - '@tiptap/extension-list': ^3.11.0 + '@tiptap/extension-list': ^3.13.0 - '@tiptap/extension-list-keymap@3.11.0': - resolution: {integrity: sha512-vm1zGdEqcbQnrGlVXchk1ibmTsyxyfGcGPVWsc4MG+UAFcNfcpAnvCar71BF4RGGPtpzOWdqGkvJENyh0L5/Hw==} + '@tiptap/extension-list-keymap@3.13.0': + resolution: {integrity: sha512-P+HtIa1iwosb1feFc8B/9MN5EAwzS+/dZ0UH0CTF2E4wnp5Z9OMxKl1IYjfiCwHzZrU5Let+S/maOvJR/EmV0g==} peerDependencies: - '@tiptap/extension-list': ^3.11.0 + '@tiptap/extension-list': ^3.13.0 - '@tiptap/extension-list@3.11.0': - resolution: {integrity: sha512-4Ane7VCVZ+GFOQNuy2nMP+SoWH7EemC3geTTqvgHm1H0tbSosxLJAVaZ9dF06F35RJmYCm+jLJUhRVd156eCRQ==} + '@tiptap/extension-list@3.13.0': + resolution: {integrity: sha512-MMFH0jQ4LeCPkJJFyZ77kt6eM/vcKujvTbMzW1xSHCIEA6s4lEcx9QdZMPpfmnOvTzeoVKR4nsu2t2qT9ZXzAw==} peerDependencies: - '@tiptap/core': ^3.11.0 - '@tiptap/pm': ^3.11.0 + '@tiptap/core': ^3.13.0 + '@tiptap/pm': ^3.13.0 - '@tiptap/extension-mathematics@3.11.0': - resolution: {integrity: sha512-Zen7O1Y/LuzPLQEpMlY9GkW0hWI8qL9RcpL1M+vW935JPM3dPFdrAs5U/iSu4CDHOsypuORyd9CM50RROPNEvw==} + '@tiptap/extension-mathematics@3.13.0': + resolution: {integrity: sha512-26tJc+L0pkQhKcMZKlpUtDx/B8LSM5YitaxYbtsxN/fTLzznq+NkwK6VgxlNyseHEcpTxDzOw4jTkV0CDWo4mQ==} peerDependencies: - '@tiptap/core': ^3.11.0 - '@tiptap/pm': ^3.11.0 + '@tiptap/core': ^3.13.0 + '@tiptap/pm': ^3.13.0 katex: ^0.16.4 - '@tiptap/extension-ordered-list@3.11.0': - resolution: {integrity: sha512-kO8GH4w4Xil+qPiHJLAyILdGHF9hCjkhoVtPD8YEfqK6Qx3bZql5FPySCQNs+MU6rLSCCdam8SUPGY/+SCufqA==} + '@tiptap/extension-ordered-list@3.13.0': + resolution: {integrity: sha512-QuDyLzuK/3vCvx9GeKhgvHWrGECBzmJyAx6gli2HY+Iil7XicbfltV4nvhIxgxzpx3LDHLKzJN9pBi+2MzX60g==} peerDependencies: - '@tiptap/extension-list': ^3.11.0 + '@tiptap/extension-list': ^3.13.0 - '@tiptap/extension-paragraph@3.11.0': - resolution: {integrity: sha512-hxgjZOXOqstRTWv+QjWJjK23rD5qzIV9ePlhX3imLeq/MgX0aU9VBDaG5SGKbSjaBNQnpLw6+sABJi3CDP6Z5A==} + '@tiptap/extension-paragraph@3.13.0': + resolution: {integrity: sha512-9csQde1i0yeZI5oQQ9e1GYNtGL2JcC2d8Fwtw9FsGC8yz2W0h+Fmk+3bc2kobbtO5LGqupSc1fKM8fAg5rSRDg==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-strike@3.11.0': - resolution: {integrity: sha512-XVP/WMYLrqLBfUsGPu2H9MrOUZLhGUaxtZ3hSRffDi/lsw53x/coZ9eO0FxOB9R7z2ksHWmticIs+0YnKt9LNQ==} + '@tiptap/extension-strike@3.13.0': + resolution: {integrity: sha512-VHhWNqTAMOfrC48m2FcPIZB0nhl6XHQviAV16SBc+EFznKNv9tQUsqQrnuQ2y6ZVfqq5UxvZ3hKF/JlN/Ff7xw==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-text@3.11.0': - resolution: {integrity: sha512-ELAYm2BuChzZOqDG9B0k3W6zqM4pwNvXkam28KgHGiT2y7Ni68Rb+NXp16uVR+5zR6hkqnQ/BmJSKzAW59MXpA==} + '@tiptap/extension-text@3.13.0': + resolution: {integrity: sha512-VcZIna93rixw7hRkHGCxDbL3kvJWi80vIT25a2pXg0WP1e7Pi3nBYvZIL4SQtkbBCji9EHrbZx3p8nNPzfazYw==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-typography@3.11.0': - resolution: {integrity: sha512-a9WcHxfeuWlGw58oOXHJk8sI0xTLSoPraZCztK77Pn69ry2GiNz0pFoL7WakThaqzFnlXtwDcl0TQTV9aqDjQA==} + '@tiptap/extension-typography@3.13.0': + resolution: {integrity: sha512-Pvxc0Mu3fIgcqOVpU5DqK55F+/ShvX020HmbsPY+Z7SED9fkan5QVn3n2nm13A2TQ+RWDlPAnHe7Gh0d/KsL5Q==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extension-underline@3.11.0': - resolution: {integrity: sha512-D3PsS/84RlQKFjd5eerMIUioC0mNh4yy1RRV/WbXx6ugu+6T+0hT42gNk9Ap8pDsVQZCk0SHfDyBEUFC2KOwKw==} + '@tiptap/extension-underline@3.13.0': + resolution: {integrity: sha512-VDQi+UYw0tFnfghpthJTFmtJ3yx90kXeDwFvhmT8G+O+si5VmP05xYDBYBmYCix5jqKigJxEASiBL0gYOgMDEg==} peerDependencies: - '@tiptap/core': ^3.11.0 + '@tiptap/core': ^3.13.0 - '@tiptap/extensions@3.11.0': - resolution: {integrity: sha512-g43beA73ZMLezez1st9LEwYrRHZ0FLzlsSlOZKk7sdmtHLmuqWHf4oyb0XAHol1HZIdGv104rYaGNgmQXr1ecQ==} + '@tiptap/extensions@3.13.0': + resolution: {integrity: sha512-i7O0ptSibEtTy+2PIPsNKEvhTvMaFJg1W4Oxfnbuxvaigs7cJV9Q0lwDUcc7CPsNw2T1+44wcxg431CzTvdYoA==} peerDependencies: - '@tiptap/core': ^3.11.0 - '@tiptap/pm': ^3.11.0 + '@tiptap/core': ^3.13.0 + '@tiptap/pm': ^3.13.0 - '@tiptap/pm@3.11.0': - resolution: {integrity: sha512-plCQDLCZIOc92cizB8NNhBRN0szvYR3cx9i5IXo6v9Xsgcun8KHNcJkesc2AyeqdIs0BtOJZaqQ9adHThz8UDw==} + '@tiptap/pm@3.13.0': + resolution: {integrity: sha512-WKR4ucALq+lwx0WJZW17CspeTpXorbIOpvKv5mulZica6QxqfMhn8n1IXCkDws/mCoLRx4Drk5d377tIjFNsvQ==} - '@tiptap/starter-kit@3.11.0': - resolution: {integrity: sha512-8kMMYqVSZ2Oqji+mY1o9meTjCRWp4DplFegu7APqDEQRhlb6mBI0wNuazYb7FKJIHJTtf0F6cYglJrxpu9c/fA==} + '@tiptap/starter-kit@3.13.0': + resolution: {integrity: sha512-Ojn6sRub04CRuyQ+9wqN62JUOMv+rG1vXhc2s6DCBCpu28lkCMMW+vTe7kXJcEdbot82+5swPbERw9vohswFzg==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1631,63 +1641,63 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.47.0': - resolution: {integrity: sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==} + '@typescript-eslint/eslint-plugin@8.49.0': + resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.47.0 + '@typescript-eslint/parser': ^8.49.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.47.0': - resolution: {integrity: sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==} + '@typescript-eslint/parser@8.49.0': + resolution: {integrity: sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.47.0': - resolution: {integrity: sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==} + '@typescript-eslint/project-service@8.49.0': + resolution: {integrity: sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.47.0': - resolution: {integrity: sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==} + '@typescript-eslint/scope-manager@8.49.0': + resolution: {integrity: sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.47.0': - resolution: {integrity: sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==} + '@typescript-eslint/tsconfig-utils@8.49.0': + resolution: {integrity: sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.47.0': - resolution: {integrity: sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==} + '@typescript-eslint/type-utils@8.49.0': + resolution: {integrity: sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.47.0': - resolution: {integrity: sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==} + '@typescript-eslint/types@8.49.0': + resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.47.0': - resolution: {integrity: sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==} + '@typescript-eslint/typescript-estree@8.49.0': + resolution: {integrity: sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.47.0': - resolution: {integrity: sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==} + '@typescript-eslint/utils@8.49.0': + resolution: {integrity: sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.47.0': - resolution: {integrity: sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==} + '@typescript-eslint/visitor-keys@8.49.0': + resolution: {integrity: sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} accepts@2.0.0: @@ -1746,8 +1756,8 @@ packages: bind-event-listener@3.0.0: resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==} - body-parser@2.2.0: - resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + body-parser@2.2.1: + resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==} engines: {node: '>=18'} brace-expansion@1.1.12: @@ -1756,10 +1766,6 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1899,21 +1905,21 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devalue@5.5.0: - resolution: {integrity: sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==} + devalue@5.6.0: + resolution: {integrity: sha512-BaD1s81TFFqbD6Uknni42TrolvEWA1Ih5L+OiHWmi4OYMJVwAYPGtha61I9KxTf52OvVHozHyjPu8zljqdF3uA==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - dompurify@3.3.0: - resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} - drizzle-kit@0.31.7: - resolution: {integrity: sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A==} + drizzle-kit@0.31.8: + resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==} hasBin: true drizzle-orm@0.44.7: @@ -2015,8 +2021,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@3.19.6: - resolution: {integrity: sha512-Eh1E/CI+xCAcMSDC5DtyE29yWJINC0zwBbwHappQPorjKyS69rCA8qzpsHpfhKnPDYgxdg8zkknii8mZ+6YMQA==} + effect@3.19.9: + resolution: {integrity: sha512-taMXnfG/p+j7AmMOHHQaCHvjqwu9QBO3cxuZqL2dMG/yWcEMw0ZHruHe9B49OxtfKH/vKKDDKRhZ+1GJ2p5R5w==} emojilib@2.4.0: resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} @@ -2071,8 +2077,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-plugin-svelte@3.13.0: - resolution: {integrity: sha512-2ohCCQJJTNbIpQCSDSTWj+FN0OVfPmSO03lmSNT7ytqMaWF6kpT86LdzDqtm4sh7TVPl/OEWJ/d7R87bXP2Vjg==} + eslint-plugin-svelte@3.13.1: + resolution: {integrity: sha512-Ng+kV/qGS8P/isbNYVE3sJORtubB+yLEcYICMkUWNaDTb0SwZni/JhAYXh/Dz/q2eThUwWY0VMPZ//KYD1n3eQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.1 || ^9.0.0 @@ -2114,8 +2120,8 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} - esrap@2.1.3: - resolution: {integrity: sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==} + esrap@2.2.1: + resolution: {integrity: sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -2147,8 +2153,8 @@ packages: peerDependencies: express: '>= 4.11' - express@5.1.0: - resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} extend@3.0.2: @@ -2161,10 +2167,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -2174,9 +2176,6 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2194,13 +2193,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@2.1.0: - resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} - engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} find-my-way-ts@0.1.6: resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} @@ -2252,10 +2247,6 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -2275,9 +2266,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2294,10 +2282,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.0: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} @@ -2333,10 +2317,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -2354,6 +2334,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -2373,8 +2356,8 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - katex@0.16.25: - resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==} + katex@0.16.27: + resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} hasBin: true keyv@4.5.4: @@ -2499,8 +2482,8 @@ packages: '@codemirror/view': ^6.7.0 loro-crdt: ^1.8.2 - loro-crdt@1.10.0: - resolution: {integrity: sha512-Fms27q9IaDANUe5OACQL6qLMhJasMXzjRkyK+NAIiPQXGBK2VAp6C7pAr9fzuKbL71YyDgA4Pv69RGwiScWSPg==} + loro-crdt@1.10.3: + resolution: {integrity: sha512-vzWkVw7mWrKTilPjrgAhhzjAyOn3/DaUPJxdK9lunpEI1Y+uQMDBt/pEtRiovKFtGXo4tUVfULnFc7H/ufGwkQ==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2566,10 +2549,6 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -2654,10 +2633,6 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} @@ -2780,25 +2755,21 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pkce-challenge@5.0.0: - resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - playwright-core@1.56.1: - resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} engines: {node: '>=18'} hasBin: true - playwright@1.56.1: - resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} engines: {node: '>=18'} hasBin: true @@ -2830,8 +2801,8 @@ packages: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} - postcss-selector-parser@7.1.0: - resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} postcss@8.5.6: @@ -2848,8 +2819,8 @@ packages: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 - prettier-plugin-tailwindcss@0.7.1: - resolution: {integrity: sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==} + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} engines: {node: '>=20.19'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' @@ -2903,8 +2874,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.6.2: - resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + prettier@3.7.4: + resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} engines: {node: '>=14'} hasBin: true @@ -2956,8 +2927,8 @@ packages: prosemirror-state@1.4.4: resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} - prosemirror-tables@1.8.1: - resolution: {integrity: sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==} + prosemirror-tables@1.8.3: + resolution: {integrity: sha512-wbqCR/RlRPRe41a4LFtmhKElzBEfBTdtAYWNIGHM6X2e24NN/MTNUKyXjjphfAfdQce37Kh/5yf765mLPYDe7Q==} prosemirror-trailing-node@3.0.0: resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} @@ -2969,8 +2940,8 @@ packages: prosemirror-transform@1.10.5: resolution: {integrity: sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==} - prosemirror-view@1.41.3: - resolution: {integrity: sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==} + prosemirror-view@1.41.4: + resolution: {integrity: sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==} proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} @@ -2991,9 +2962,6 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - raf-schd@4.0.3: resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} @@ -3035,10 +3003,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.53.3: resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3051,9 +3015,6 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -3149,9 +3110,9 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' - svelte-eslint-parser@1.4.0: - resolution: {integrity: sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.18.3} + svelte-eslint-parser@1.4.1: + resolution: {integrity: sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.24.0} peerDependencies: svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: @@ -3164,8 +3125,8 @@ packages: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 typescript: ^4.9.4 || ^5.0.0 - svelte@5.44.0: - resolution: {integrity: sha512-R7387No2zEGw4CtYtI2rgsui6BqjFARzoZFGLiLN5OPla0Pq4Ra2WwcP/zBomP3MYalhSNvF1fzDMuU0P0zPJw==} + svelte@5.45.7: + resolution: {integrity: sha512-A+vXOLTjErrPRkpbY0h+cZuXjRQS8RZeyF8cx8IU513ORL7ld8o9exQAYAabh8NgNW5bLcIKhrSNwgpj4ykb+w==} engines: {node: '>=18'} tailwindcss@4.1.17: @@ -3184,10 +3145,6 @@ packages: peerDependencies: '@tiptap/core': ^3.0.1 - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -3219,8 +3176,8 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.47.0: - resolution: {integrity: sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==} + typescript-eslint@8.49.0: + resolution: {integrity: sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -3302,8 +3259,8 @@ packages: peerDependencies: vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 - vite@7.2.4: - resolution: {integrity: sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==} + vite@7.2.7: + resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3385,11 +3342,6 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} - yaml@2.8.1: - resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} - engines: {node: '>= 14.6'} - hasBin: true - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3402,8 +3354,8 @@ packages: peerDependencies: zod: ^3.25 || ^4 - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3423,9 +3375,9 @@ snapshots: '@babel/runtime@7.28.4': {} - '@benrbray/prosemirror-math@1.0.0(katex@0.16.25)(prosemirror-commands@1.7.1)(prosemirror-history@1.5.0)(prosemirror-inputrules@1.5.1)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.10.5)(prosemirror-view@1.41.3)': + '@benrbray/prosemirror-math@1.0.0(katex@0.16.27)(prosemirror-commands@1.7.1)(prosemirror-history@1.5.0)(prosemirror-inputrules@1.5.1)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.10.5)(prosemirror-view@1.41.4)': dependencies: - katex: 0.16.25 + katex: 0.16.27 prosemirror-commands: 1.7.1 prosemirror-history: 1.5.0 prosemirror-inputrules: 1.5.1 @@ -3433,30 +3385,30 @@ snapshots: prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.3 + prosemirror-view: 1.41.4 '@codemirror/autocomplete@6.20.0': dependencies: '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.39.0 + '@lezer/common': 1.4.0 '@codemirror/commands@6.10.0': dependencies: '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.39.0 + '@lezer/common': 1.4.0 '@codemirror/lang-angular@0.1.4': dependencies: '@codemirror/lang-html': 6.4.11 '@codemirror/lang-javascript': 6.2.4 '@codemirror/language': 6.11.3 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@codemirror/lang-cpp@6.0.3': dependencies: @@ -3468,7 +3420,7 @@ snapshots: '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/css': 1.3.0 '@codemirror/lang-go@6.0.1': @@ -3476,7 +3428,7 @@ snapshots: '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/go': 1.0.1 '@codemirror/lang-html@6.4.11': @@ -3486,8 +3438,8 @@ snapshots: '@codemirror/lang-javascript': 6.2.4 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.39.0 + '@lezer/common': 1.4.0 '@lezer/css': 1.3.0 '@lezer/html': 1.3.12 @@ -3502,17 +3454,17 @@ snapshots: '@codemirror/language': 6.11.3 '@codemirror/lint': 6.9.2 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.39.0 + '@lezer/common': 1.4.0 '@lezer/javascript': 1.5.4 '@codemirror/lang-jinja@6.0.0': dependencies: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.11.3 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@codemirror/lang-json@6.0.2': dependencies: @@ -3523,9 +3475,9 @@ snapshots: dependencies: '@codemirror/lang-css': 6.3.1 '@codemirror/language': 6.11.3 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@codemirror/lang-liquid@6.3.0': dependencies: @@ -3533,10 +3485,10 @@ snapshots: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.39.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@codemirror/lang-markdown@6.5.0': dependencies: @@ -3544,16 +3496,16 @@ snapshots: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@lezer/common': 1.3.0 - '@lezer/markdown': 1.6.0 + '@codemirror/view': 6.39.0 + '@lezer/common': 1.4.0 + '@lezer/markdown': 1.6.1 '@codemirror/lang-php@6.0.2': dependencies: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/php': 1.0.5 '@codemirror/lang-python@6.2.1': @@ -3561,7 +3513,7 @@ snapshots: '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/python': 1.1.18 '@codemirror/lang-rust@6.0.2': @@ -3574,7 +3526,7 @@ snapshots: '@codemirror/lang-css': 6.3.1 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/sass': 1.1.0 '@codemirror/lang-sql@6.10.0': @@ -3582,33 +3534,33 @@ snapshots: '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@codemirror/lang-vue@0.1.3': dependencies: '@codemirror/lang-html': 6.4.11 '@codemirror/lang-javascript': 6.2.4 '@codemirror/language': 6.11.3 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@codemirror/lang-wast@6.0.2': dependencies: '@codemirror/language': 6.11.3 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@codemirror/lang-xml@6.1.0': dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.39.0 + '@lezer/common': 1.4.0 '@lezer/xml': 1.0.6 '@codemirror/lang-yaml@6.1.2': @@ -3616,9 +3568,9 @@ snapshots: '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/yaml': 1.0.3 '@codemirror/language-data@6.5.2': @@ -3650,10 +3602,10 @@ snapshots: '@codemirror/language@6.11.3': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.39.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 style-mod: 4.1.3 '@codemirror/legacy-modes@6.5.2': @@ -3663,13 +3615,13 @@ snapshots: '@codemirror/lint@6.9.2': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 + '@codemirror/view': 6.39.0 crelt: 1.0.6 '@codemirror/search@6.5.11': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 + '@codemirror/view': 6.39.0 crelt: 1.0.6 '@codemirror/state@6.5.2': @@ -3680,10 +3632,10 @@ snapshots: dependencies: '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 + '@codemirror/view': 6.39.0 '@lezer/highlight': 1.2.3 - '@codemirror/view@6.38.8': + '@codemirror/view@6.39.0': dependencies: '@codemirror/state': 6.5.2 crelt: 1.0.6 @@ -3694,9 +3646,9 @@ snapshots: '@effect/language-service@0.56.0': {} - '@effect/platform@0.93.3(effect@3.19.6)': + '@effect/platform@0.93.6(effect@3.19.9)': dependencies: - effect: 3.19.6 + effect: 3.19.9 find-my-way-ts: 0.1.6 msgpackr: 1.11.5 multipasta: 0.2.7 @@ -3900,7 +3852,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 debug: 4.4.3 @@ -3953,98 +3905,98 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lezer/common@1.3.0': {} + '@lezer/common@1.4.0': {} '@lezer/cpp@1.1.3': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/css@1.3.0': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/go@1.0.1': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/highlight@1.2.3': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/html@1.3.12': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/java@1.1.3': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/javascript@1.5.4': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/json@1.0.3': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 - '@lezer/lr@1.4.3': + '@lezer/lr@1.4.4': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 - '@lezer/markdown@1.6.0': + '@lezer/markdown@1.6.1': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 '@lezer/php@1.0.5': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/python@1.1.18': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/rust@1.0.2': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/sass@1.1.0': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/xml@1.0.6': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/yaml@1.0.3': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@libsql/client@0.15.15': dependencies: @@ -4108,95 +4060,95 @@ snapshots: '@libsql/win32-x64-msvc@0.5.22': optional: true - '@lucide/svelte@0.554.0(svelte@5.44.0)': + '@lucide/svelte@0.554.0(svelte@5.45.7)': dependencies: - svelte: 5.44.0 + svelte: 5.45.7 '@marijn/find-cluster-break@1.0.2': {} - '@milkdown-lab/plugin-split-editing@1.3.1(@milkdown/core@7.17.1)(@milkdown/prose@7.17.1)': + '@milkdown-lab/plugin-split-editing@1.3.1(@milkdown/core@7.17.3)(@milkdown/prose@7.17.3)': dependencies: '@codemirror/commands': 6.10.0 '@codemirror/lang-markdown': 6.5.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@milkdown/core': 7.17.1 - '@milkdown/ctx': 7.17.1 - '@milkdown/prose': 7.17.1 - '@milkdown/utils': 7.17.1 + '@codemirror/view': 6.39.0 + '@milkdown/core': 7.17.3 + '@milkdown/ctx': 7.17.3 + '@milkdown/prose': 7.17.3 + '@milkdown/utils': 7.17.3 style-mod: 4.1.3 transitivePeerDependencies: - supports-color - '@milkdown/core@7.17.1': + '@milkdown/core@7.17.3': dependencies: - '@milkdown/ctx': 7.17.1 - '@milkdown/exception': 7.17.1 - '@milkdown/prose': 7.17.1 - '@milkdown/transformer': 7.17.1 + '@milkdown/ctx': 7.17.3 + '@milkdown/exception': 7.17.3 + '@milkdown/prose': 7.17.3 + '@milkdown/transformer': 7.17.3 remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - '@milkdown/ctx@7.17.1': + '@milkdown/ctx@7.17.3': dependencies: - '@milkdown/exception': 7.17.1 + '@milkdown/exception': 7.17.3 - '@milkdown/exception@7.17.1': {} + '@milkdown/exception@7.17.3': {} - '@milkdown/plugin-history@7.17.1': + '@milkdown/plugin-history@7.17.3': dependencies: - '@milkdown/core': 7.17.1 - '@milkdown/ctx': 7.17.1 - '@milkdown/prose': 7.17.1 - '@milkdown/utils': 7.17.1 + '@milkdown/core': 7.17.3 + '@milkdown/ctx': 7.17.3 + '@milkdown/prose': 7.17.3 + '@milkdown/utils': 7.17.3 transitivePeerDependencies: - supports-color - '@milkdown/plugin-listener@7.17.1': + '@milkdown/plugin-listener@7.17.3': dependencies: - '@milkdown/core': 7.17.1 - '@milkdown/ctx': 7.17.1 - '@milkdown/prose': 7.17.1 + '@milkdown/core': 7.17.3 + '@milkdown/ctx': 7.17.3 + '@milkdown/prose': 7.17.3 '@types/lodash-es': 4.17.12 lodash-es: 4.17.21 transitivePeerDependencies: - supports-color - '@milkdown/preset-commonmark@7.17.1': + '@milkdown/preset-commonmark@7.17.3': dependencies: - '@milkdown/core': 7.17.1 - '@milkdown/ctx': 7.17.1 - '@milkdown/exception': 7.17.1 - '@milkdown/prose': 7.17.1 - '@milkdown/transformer': 7.17.1 - '@milkdown/utils': 7.17.1 + '@milkdown/core': 7.17.3 + '@milkdown/ctx': 7.17.3 + '@milkdown/exception': 7.17.3 + '@milkdown/prose': 7.17.3 + '@milkdown/transformer': 7.17.3 + '@milkdown/utils': 7.17.3 remark-inline-links: 7.0.0 unist-util-visit: 5.0.0 unist-util-visit-parents: 6.0.2 transitivePeerDependencies: - supports-color - '@milkdown/preset-gfm@7.17.1': + '@milkdown/preset-gfm@7.17.3': dependencies: - '@milkdown/core': 7.17.1 - '@milkdown/ctx': 7.17.1 - '@milkdown/exception': 7.17.1 - '@milkdown/preset-commonmark': 7.17.1 - '@milkdown/prose': 7.17.1 - '@milkdown/transformer': 7.17.1 - '@milkdown/utils': 7.17.1 + '@milkdown/core': 7.17.3 + '@milkdown/ctx': 7.17.3 + '@milkdown/exception': 7.17.3 + '@milkdown/preset-commonmark': 7.17.3 + '@milkdown/prose': 7.17.3 + '@milkdown/transformer': 7.17.3 + '@milkdown/utils': 7.17.3 prosemirror-safari-ime-span: 1.0.2 remark-gfm: 4.0.1 transitivePeerDependencies: - supports-color - '@milkdown/prose@7.17.1': + '@milkdown/prose@7.17.3': dependencies: - '@milkdown/exception': 7.17.1 + '@milkdown/exception': 7.17.3 prosemirror-changeset: 2.3.1 prosemirror-commands: 1.7.1 prosemirror-dropcursor: 1.8.2 @@ -4207,42 +4159,42 @@ snapshots: prosemirror-model: 1.25.4 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 - prosemirror-tables: 1.8.1 + prosemirror-tables: 1.8.3 prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.3 + prosemirror-view: 1.41.4 - '@milkdown/theme-nord@7.17.1': + '@milkdown/theme-nord@7.17.3': dependencies: - '@milkdown/core': 7.17.1 - '@milkdown/ctx': 7.17.1 - '@milkdown/prose': 7.17.1 + '@milkdown/core': 7.17.3 + '@milkdown/ctx': 7.17.3 + '@milkdown/prose': 7.17.3 clsx: 2.1.1 transitivePeerDependencies: - supports-color - '@milkdown/transformer@7.17.1': + '@milkdown/transformer@7.17.3': dependencies: - '@milkdown/exception': 7.17.1 - '@milkdown/prose': 7.17.1 + '@milkdown/exception': 7.17.3 + '@milkdown/prose': 7.17.3 remark: 15.0.1 unified: 11.0.5 transitivePeerDependencies: - supports-color - '@milkdown/utils@7.17.1': + '@milkdown/utils@7.17.3': dependencies: - '@milkdown/core': 7.17.1 - '@milkdown/ctx': 7.17.1 - '@milkdown/exception': 7.17.1 - '@milkdown/prose': 7.17.1 - '@milkdown/transformer': 7.17.1 + '@milkdown/core': 7.17.3 + '@milkdown/ctx': 7.17.3 + '@milkdown/exception': 7.17.3 + '@milkdown/prose': 7.17.3 + '@milkdown/transformer': 7.17.3 nanoid: 5.1.6 transitivePeerDependencies: - supports-color '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/sdk@1.22.0': + '@modelcontextprotocol/sdk@1.24.3(zod@4.1.13)': dependencies: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) @@ -4251,12 +4203,13 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.0.6 - express: 5.1.0 - express-rate-limit: 7.5.1(express@5.1.0) - pkce-challenge: 5.0.0 + express: 5.2.1 + express-rate-limit: 7.5.1(express@5.2.1) + jose: 6.1.3 + pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.0(zod@3.25.76) + zod: 4.1.13 + zod-to-json-schema: 3.25.0(zod@4.1.13) transitivePeerDependencies: - supports-color @@ -4287,6 +4240,14 @@ snapshots: '@neon-rs/load@0.0.4': {} + '@noble/ciphers@2.1.1': {} + + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/hashes@2.0.1': {} + '@node-rs/argon2-android-arm-eabi@2.0.2': optional: true @@ -4348,18 +4309,6 @@ snapshots: '@node-rs/argon2-win32-ia32-msvc': 2.0.2 '@node-rs/argon2-win32-x64-msvc': 2.0.2 - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - '@oslojs/asn1@1.0.0': dependencies: '@oslojs/binary': 1.0.0 @@ -4375,7 +4324,7 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@prosemark/core@0.0.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.0)(@codemirror/lang-markdown@6.5.0)(@codemirror/language-data@6.5.2)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/highlight@1.2.3)': + '@prosemark/core@0.0.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.0)(@codemirror/lang-markdown@6.5.0)(@codemirror/language-data@6.5.2)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.39.0)(@lezer/highlight@1.2.3)': dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.0 @@ -4385,27 +4334,27 @@ snapshots: '@codemirror/lint': 6.9.2 '@codemirror/search': 6.5.11 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.39.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/markdown': 1.6.0 + '@lezer/markdown': 1.6.1 ajv: 8.17.1 node-emoji: 2.2.0 '@prosemark/paste-rich-text@0.0.2': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 + '@codemirror/view': 6.39.0 '@types/turndown': 5.0.6 turndown: 7.2.2 '@prosemark/render-html@0.0.5(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.0)(@codemirror/lang-markdown@6.5.0)(@codemirror/language-data@6.5.2)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@lezer/highlight@1.2.3)': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - '@lezer/common': 1.3.0 - '@prosemark/core': 0.0.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.0)(@codemirror/lang-markdown@6.5.0)(@codemirror/language-data@6.5.2)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/highlight@1.2.3) - dompurify: 3.3.0 + '@codemirror/view': 6.39.0 + '@lezer/common': 1.4.0 + '@prosemark/core': 0.0.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.0)(@codemirror/lang-markdown@6.5.0)(@codemirror/language-data@6.5.2)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.39.0)(@lezer/highlight@1.2.3) + dompurify: 3.3.1 transitivePeerDependencies: - '@codemirror/autocomplete' - '@codemirror/commands' @@ -4492,23 +4441,23 @@ snapshots: '@standard-schema/spec@1.0.0': {} - '@sveltejs/acorn-typescript@1.0.7(acorn@8.15.0)': + '@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)': dependencies: acorn: 8.15.0 - '@sveltejs/adapter-auto@7.0.0(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))': + '@sveltejs/adapter-auto@7.0.0(@sveltejs/kit@2.49.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))': dependencies: - '@sveltejs/kit': 2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + '@sveltejs/kit': 2.49.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) - '@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1))': + '@sveltejs/kit@2.49.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@standard-schema/spec': 1.0.0 - '@sveltejs/acorn-typescript': 1.0.7(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 - devalue: 5.5.0 + devalue: 5.6.0 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 @@ -4516,27 +4465,27 @@ snapshots: sade: 1.8.1 set-cookie-parser: 2.7.2 sirv: 3.0.2 - svelte: 5.44.0 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + svelte: 5.45.7 + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) debug: 4.4.3 - svelte: 5.44.0 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + svelte: 5.45.7 + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.7)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) debug: 4.4.3 deepmerge: 4.3.1 magic-string: 0.30.21 - svelte: 5.44.0 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) - vitefu: 1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + svelte: 5.45.7 + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) + vitefu: 1.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) transitivePeerDependencies: - supports-color @@ -4660,122 +4609,122 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.17 - '@tailwindcss/vite@4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1))': + '@tailwindcss/vite@4.1.17(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@tailwindcss/node': 4.1.17 '@tailwindcss/oxide': 4.1.17 tailwindcss: 4.1.17 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) - '@tiptap/core@3.11.0(@tiptap/pm@3.11.0)': + '@tiptap/core@3.13.0(@tiptap/pm@3.13.0)': dependencies: - '@tiptap/pm': 3.11.0 + '@tiptap/pm': 3.13.0 - '@tiptap/extension-blockquote@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-blockquote@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-bold@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-bold@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-bullet-list@3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + '@tiptap/extension-bullet-list@3.13.0(@tiptap/extension-list@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/extension-list': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extension-list': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-code-block@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + '@tiptap/extension-code-block@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) - '@tiptap/pm': 3.11.0 + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/pm': 3.13.0 - '@tiptap/extension-code@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-code@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-document@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-document@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-dropcursor@3.11.0(@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + '@tiptap/extension-dropcursor@3.13.0(@tiptap/extensions@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/extensions': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extensions': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-gapcursor@3.11.0(@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + '@tiptap/extension-gapcursor@3.13.0(@tiptap/extensions@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/extensions': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extensions': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-hard-break@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-hard-break@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-heading@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-heading@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-horizontal-rule@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + '@tiptap/extension-horizontal-rule@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) - '@tiptap/pm': 3.11.0 + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/pm': 3.13.0 - '@tiptap/extension-italic@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-italic@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-link@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + '@tiptap/extension-link@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) - '@tiptap/pm': 3.11.0 + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/pm': 3.13.0 linkifyjs: 4.3.2 - '@tiptap/extension-list-item@3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + '@tiptap/extension-list-item@3.13.0(@tiptap/extension-list@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/extension-list': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extension-list': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-list-keymap@3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + '@tiptap/extension-list-keymap@3.13.0(@tiptap/extension-list@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/extension-list': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extension-list': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + '@tiptap/extension-list@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) - '@tiptap/pm': 3.11.0 + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/pm': 3.13.0 - '@tiptap/extension-mathematics@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)(katex@0.16.25)': + '@tiptap/extension-mathematics@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(katex@0.16.27)': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) - '@tiptap/pm': 3.11.0 - katex: 0.16.25 + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/pm': 3.13.0 + katex: 0.16.27 - '@tiptap/extension-ordered-list@3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + '@tiptap/extension-ordered-list@3.13.0(@tiptap/extension-list@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/extension-list': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extension-list': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-paragraph@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-paragraph@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-strike@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-strike@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-text@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-text@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-typography@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-typography@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-underline@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + '@tiptap/extension-underline@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + '@tiptap/extensions@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) - '@tiptap/pm': 3.11.0 + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/pm': 3.13.0 - '@tiptap/pm@3.11.0': + '@tiptap/pm@3.13.0': dependencies: prosemirror-changeset: 2.3.1 prosemirror-collab: 1.3.1 @@ -4791,37 +4740,37 @@ snapshots: prosemirror-schema-basic: 1.2.4 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 - prosemirror-tables: 1.8.1 - prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.3) + prosemirror-tables: 1.8.3 + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4) prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.3 - - '@tiptap/starter-kit@3.11.0': - dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) - '@tiptap/extension-blockquote': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extension-bold': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extension-bullet-list': 3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) - '@tiptap/extension-code': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extension-code-block': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) - '@tiptap/extension-document': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extension-dropcursor': 3.11.0(@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) - '@tiptap/extension-gapcursor': 3.11.0(@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) - '@tiptap/extension-hard-break': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extension-heading': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extension-horizontal-rule': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) - '@tiptap/extension-italic': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extension-link': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) - '@tiptap/extension-list': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) - '@tiptap/extension-list-item': 3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) - '@tiptap/extension-list-keymap': 3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) - '@tiptap/extension-ordered-list': 3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) - '@tiptap/extension-paragraph': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extension-strike': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extension-text': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extension-underline': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) - '@tiptap/extensions': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) - '@tiptap/pm': 3.11.0 + prosemirror-view: 1.41.4 + + '@tiptap/starter-kit@3.13.0': + dependencies: + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/extension-blockquote': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extension-bold': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extension-bullet-list': 3.13.0(@tiptap/extension-list@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) + '@tiptap/extension-code': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extension-code-block': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extension-document': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extension-dropcursor': 3.13.0(@tiptap/extensions@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) + '@tiptap/extension-gapcursor': 3.13.0(@tiptap/extensions@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) + '@tiptap/extension-hard-break': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extension-heading': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extension-horizontal-rule': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extension-italic': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extension-link': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extension-list': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extension-list-item': 3.13.0(@tiptap/extension-list@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) + '@tiptap/extension-list-keymap': 3.13.0(@tiptap/extension-list@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) + '@tiptap/extension-ordered-list': 3.13.0(@tiptap/extension-list@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) + '@tiptap/extension-paragraph': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extension-strike': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extension-text': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extension-underline': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) + '@tiptap/extensions': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/pm': 3.13.0 '@tybys/wasm-util@0.10.1': dependencies: @@ -4883,16 +4832,15 @@ snapshots: dependencies: '@types/node': 24.10.1 - '@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.47.0 - '@typescript-eslint/type-utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.47.0 + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.49.0 eslint: 9.39.1(jiti@2.6.1) - graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.1.0(typescript@5.9.3) @@ -4900,41 +4848,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.47.0 - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.47.0 + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.49.0 debug: 4.4.3 eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.47.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.49.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) - '@typescript-eslint/types': 8.47.0 + '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) + '@typescript-eslint/types': 8.49.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.47.0': + '@typescript-eslint/scope-manager@8.49.0': dependencies: - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/visitor-keys': 8.47.0 + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/visitor-keys': 8.49.0 - '@typescript-eslint/tsconfig-utils@8.47.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.49.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) @@ -4942,38 +4890,37 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.47.0': {} + '@typescript-eslint/types@8.49.0': {} - '@typescript-eslint/typescript-estree@8.47.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.49.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.47.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/visitor-keys': 8.47.0 + '@typescript-eslint/project-service': 8.49.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/visitor-keys': 8.49.0 debug: 4.4.3 - fast-glob: 3.3.3 - is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.7.3 + tinyglobby: 0.2.15 ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.47.0 - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.47.0': + '@typescript-eslint/visitor-keys@8.49.0': dependencies: - '@typescript-eslint/types': 8.47.0 + '@typescript-eslint/types': 8.49.0 eslint-visitor-keys: 4.2.1 accepts@2.0.0: @@ -5023,13 +4970,13 @@ snapshots: bind-event-listener@3.0.0: {} - body-parser@2.2.0: + body-parser@2.2.1: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.3 http-errors: 2.0.1 - iconv-lite: 0.6.3 + iconv-lite: 0.7.0 on-finished: 2.4.1 qs: 6.14.0 raw-body: 3.0.2 @@ -5046,10 +4993,6 @@ snapshots: dependencies: balanced-match: 1.0.2 - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - buffer-from@1.1.2: {} bytes@3.1.2: {} @@ -5091,7 +5034,7 @@ snapshots: '@codemirror/lint': 6.9.2 '@codemirror/search': 6.5.11 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 + '@codemirror/view': 6.39.0 color-convert@2.0.1: dependencies: @@ -5154,19 +5097,19 @@ snapshots: detect-libc@2.1.2: {} - devalue@5.5.0: {} + devalue@5.6.0: {} devlop@1.1.0: dependencies: dequal: 2.0.3 - dompurify@3.3.0: + dompurify@3.3.1: optionalDependencies: '@types/trusted-types': 2.0.7 dotenv@17.2.3: {} - drizzle-kit@0.31.7: + drizzle-kit@0.31.8: dependencies: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 @@ -5187,7 +5130,7 @@ snapshots: ee-first@1.1.1: {} - effect@3.19.6: + effect@3.19.9: dependencies: '@standard-schema/spec': 1.0.0 fast-check: 3.23.2 @@ -5278,7 +5221,7 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-plugin-svelte@3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.44.0): + eslint-plugin-svelte@3.13.1(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.7): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -5290,9 +5233,9 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.6) postcss-safe-parser: 7.0.1(postcss@8.5.6) semver: 7.7.3 - svelte-eslint-parser: 1.4.0(svelte@5.44.0) + svelte-eslint-parser: 1.4.1(svelte@5.45.7) optionalDependencies: - svelte: 5.44.0 + svelte: 5.45.7 transitivePeerDependencies: - ts-node @@ -5312,7 +5255,7 @@ snapshots: '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.1 + '@eslint/eslintrc': 3.3.3 '@eslint/js': 9.39.1 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -5358,7 +5301,7 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.1.3: + esrap@2.2.1: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5378,23 +5321,24 @@ snapshots: dependencies: eventsource-parser: 3.0.6 - express-rate-limit@7.5.1(express@5.1.0): + express-rate-limit@7.5.1(express@5.2.1): dependencies: - express: 5.1.0 + express: 5.2.1 - express@5.1.0: + express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.0 + body-parser: 2.2.1 content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 debug: 4.4.3 + depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.0 + finalhandler: 2.1.1 fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -5422,24 +5366,12 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} fast-uri@3.1.0: {} - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -5453,11 +5385,7 @@ snapshots: dependencies: flat-cache: 4.0.1 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@2.1.0: + finalhandler@2.1.1: dependencies: debug: 4.4.3 encodeurl: 2.0.0 @@ -5520,10 +5448,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -5536,8 +5460,6 @@ snapshots: graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5554,10 +5476,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.0: dependencies: safer-buffer: 2.1.2 @@ -5583,8 +5501,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-number@7.0.0: {} - is-plain-obj@4.1.0: {} is-promise@4.0.0: {} @@ -5597,6 +5513,8 @@ snapshots: jiti@2.6.1: {} + jose@6.1.3: {} + js-base64@3.7.8: {} js-yaml@4.1.1: @@ -5611,7 +5529,7 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - katex@0.16.25: + katex@0.16.27: dependencies: commander: 8.3.0 @@ -5712,13 +5630,13 @@ 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): + loro-codemirror@0.3.3(@codemirror/state@6.5.2)(@codemirror/view@6.39.0)(loro-crdt@1.10.3): dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.8 - loro-crdt: 1.10.0 + '@codemirror/view': 6.39.0 + loro-crdt: 1.10.3 - loro-crdt@1.10.0: {} + loro-crdt@1.10.3: {} magic-string@0.30.21: dependencies: @@ -5853,8 +5771,6 @@ snapshots: merge-descriptors@2.0.0: {} - merge2@1.4.1: {} - micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.2.0 @@ -6046,11 +5962,6 @@ snapshots: transitivePeerDependencies: - supports-color - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - mime-db@1.54.0: {} mime-types@3.0.2: @@ -6162,17 +6073,15 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} - picomatch@4.0.3: {} - pkce-challenge@5.0.0: {} + pkce-challenge@5.0.1: {} - playwright-core@1.56.1: {} + playwright-core@1.57.0: {} - playwright@1.56.1: + playwright@1.57.0: dependencies: - playwright-core: 1.56.1 + playwright-core: 1.57.0 optionalDependencies: fsevents: 2.3.2 @@ -6196,7 +6105,7 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-selector-parser@7.1.0: + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -6209,18 +6118,18 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.44.0): + prettier-plugin-svelte@3.4.0(prettier@3.7.4)(svelte@5.45.7): dependencies: - prettier: 3.6.2 - svelte: 5.44.0 + prettier: 3.7.4 + svelte: 5.45.7 - prettier-plugin-tailwindcss@0.7.1(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.44.0))(prettier@3.6.2): + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-svelte@3.4.0(prettier@3.7.4)(svelte@5.45.7))(prettier@3.7.4): dependencies: - prettier: 3.6.2 + prettier: 3.7.4 optionalDependencies: - prettier-plugin-svelte: 3.4.0(prettier@3.6.2)(svelte@5.44.0) + prettier-plugin-svelte: 3.4.0(prettier@3.7.4)(svelte@5.45.7) - prettier@3.6.2: {} + prettier@3.7.4: {} promise-limit@2.7.0: {} @@ -6242,20 +6151,20 @@ snapshots: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.3 + prosemirror-view: 1.41.4 prosemirror-gapcursor@1.4.0: dependencies: prosemirror-keymap: 1.2.3 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.3 + prosemirror-view: 1.41.4 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.3 + prosemirror-view: 1.41.4 rope-sequence: 1.3.4 prosemirror-inputrules@1.5.1: @@ -6288,7 +6197,7 @@ snapshots: prosemirror-safari-ime-span@1.0.2: dependencies: prosemirror-state: 1.4.4 - prosemirror-view: 1.41.3 + prosemirror-view: 1.41.4 prosemirror-schema-basic@1.2.4: dependencies: @@ -6304,29 +6213,29 @@ snapshots: dependencies: prosemirror-model: 1.25.4 prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.3 + prosemirror-view: 1.41.4 - prosemirror-tables@1.8.1: + prosemirror-tables@1.8.3: dependencies: prosemirror-keymap: 1.2.3 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.3 + prosemirror-view: 1.41.4 - prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.3): + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4): dependencies: '@remirror/core-constants': 3.0.0 escape-string-regexp: 4.0.0 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.3 + prosemirror-view: 1.41.4 prosemirror-transform@1.10.5: dependencies: prosemirror-model: 1.25.4 - prosemirror-view@1.41.3: + prosemirror-view@1.41.4: dependencies: prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 @@ -6347,8 +6256,6 @@ snapshots: dependencies: side-channel: 1.1.0 - queue-microtask@1.2.3: {} - raf-schd@4.0.3: {} range-parser@1.2.1: {} @@ -6409,8 +6316,6 @@ snapshots: resolve-pkg-maps@1.0.0: {} - reusify@1.1.0: {} - rollup@4.53.3: dependencies: '@types/estree': 1.0.8 @@ -6451,10 +6356,6 @@ snapshots: transitivePeerDependencies: - supports-color - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - sade@1.8.1: dependencies: mri: 1.2.0 @@ -6557,49 +6458,49 @@ snapshots: dependencies: has-flag: 4.0.0 - svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3): + svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.45.7)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.44.0 + svelte: 5.45.7 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.0(svelte@5.44.0): + svelte-eslint-parser@1.4.1(svelte@5.45.7): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 postcss: 8.5.6 postcss-scss: 4.0.9(postcss@8.5.6) - postcss-selector-parser: 7.1.0 + postcss-selector-parser: 7.1.1 optionalDependencies: - svelte: 5.44.0 + svelte: 5.45.7 - svelte2tsx@0.7.45(svelte@5.44.0)(typescript@5.9.3): + svelte2tsx@0.7.45(svelte@5.45.7)(typescript@5.9.3): dependencies: dedent-js: 1.0.1 scule: 1.3.0 - svelte: 5.44.0 + svelte: 5.45.7 typescript: 5.9.3 - svelte@5.44.0: + svelte@5.45.7: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.7(acorn@8.15.0) + '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) '@types/estree': 1.0.8 acorn: 8.15.0 aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.5.0 + devalue: 5.6.0 esm-env: 1.2.2 - esrap: 2.1.3 + esrap: 2.2.1 is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 @@ -6614,18 +6515,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tiptap-markdown@0.9.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)): + tiptap-markdown@0.9.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)): dependencies: - '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) '@types/markdown-it': 13.0.9 markdown-it: 14.1.0 markdown-it-task-lists: 2.1.1 prosemirror-markdown: 1.13.2 - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - toidentifier@1.0.1: {} totalist@3.0.1: {} @@ -6653,21 +6550,21 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - typescript-svelte-plugin@0.3.50(svelte@5.44.0)(typescript@5.9.3): + typescript-svelte-plugin@0.3.50(svelte@5.45.7)(typescript@5.9.3): dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - svelte2tsx: 0.7.45(svelte@5.44.0)(typescript@5.9.3) + svelte2tsx: 0.7.45(svelte@5.45.7)(typescript@5.9.3) transitivePeerDependencies: - svelte - typescript @@ -6733,27 +6630,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-devtools-json@1.0.0(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)): + vite-plugin-devtools-json@1.0.0(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)): dependencies: uuid: 11.1.0 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) - vite-plugin-top-level-await@1.6.0(rollup@4.53.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)): + vite-plugin-top-level-await@1.6.0(rollup@4.53.3)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)): dependencies: '@rollup/plugin-virtual': 3.0.2(rollup@4.53.3) '@swc/core': 1.15.3 '@swc/wasm': 1.15.3 uuid: 10.0.0 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) transitivePeerDependencies: - '@swc/helpers' - rollup - vite-plugin-wasm@3.5.0(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)): + vite-plugin-wasm@3.5.0(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)): dependencies: - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) - vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1): + vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -6766,11 +6663,10 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 - yaml: 2.8.1 - vitefu@1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)): + vitefu@1.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)): optionalDependencies: - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) w3c-keyname@2.2.8: {} @@ -6788,17 +6684,14 @@ snapshots: yaml@1.10.2: {} - yaml@2.8.1: - optional: true - yocto-queue@0.1.0: {} zimmerframe@1.1.4: {} - zod-to-json-schema@3.25.0(zod@3.25.76): + zod-to-json-schema@3.25.0(zod@4.1.13): dependencies: - zod: 3.25.76 + zod: 4.1.13 - zod@3.25.76: {} + zod@4.1.13: {} zwitch@2.0.4: {} diff --git a/scripts/logout.ts b/scripts/logout.ts new file mode 100644 index 0000000..7949ede --- /dev/null +++ b/scripts/logout.ts @@ -0,0 +1,27 @@ +import "dotenv/config"; +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { sessions } from "../src/lib/server/db/schema.js"; + +async function main() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL is not set in .env"); + process.exit(1); + } + + const client = createClient({ url }); + const db = drizzle(client); + + console.log("Clearing all sessions..."); + try { + const result = await db.delete(sessions); + console.log("Successfully deleted all sessions."); + } catch (error) { + console.error("Error clearing sessions:", error); + process.exit(1); + } + process.exit(0); +} + +main(); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 7ce13ee..3c5c7c3 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -9,7 +9,12 @@ const handleAuth: Handle = async ({ event, resolve }) => { event.locals.session = undefined; // Redirect to login if accessing protected routes - if (!event.route.id?.startsWith("/(auth)")) { + if ( + !event.route.id?.startsWith("/(auth)") && + !event.route.id?.startsWith("/federation") && + !event.route.id?.startsWith("/.well-known") && + !event.route.id?.startsWith("/notes") + ) { return new Response("Redirect", { status: 303, headers: { Location: "/login" }, diff --git a/src/lib/components/ShareModal.svelte b/src/lib/components/ShareModal.svelte new file mode 100644 index 0000000..0878ab8 --- /dev/null +++ b/src/lib/components/ShareModal.svelte @@ -0,0 +1,226 @@ + + +{#if isOpen} +
+
e.stopPropagation()} + > + +
+
+ +

Share "{noteTitle}"

+
+ +
+ + +
+ +
+ + + + + + + + + +
+ + + {#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 2/9] 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 8/9] 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 9/9] 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