From 95cedc355fc9878b97fe0279bf25e881b33a1b89 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Fri, 17 Jul 2026 00:22:01 +0530 Subject: [PATCH 1/4] feat: add account-gated publishing --- services/cloud-api/bun.lock | 205 +++++++++++ services/cloud-api/migrations/0001_shares.sql | 67 ++++ services/cloud-api/package.json | 20 ++ services/cloud-api/src/auth.ts | 38 +++ services/cloud-api/src/crypto.ts | 22 ++ services/cloud-api/src/http.ts | 54 +++ services/cloud-api/src/index.ts | 75 +++++ services/cloud-api/src/shares.ts | 257 ++++++++++++++ services/cloud-api/src/types.ts | 45 +++ services/cloud-api/src/validation.ts | 56 ++++ services/cloud-api/test/publishing.test.ts | 55 +++ services/cloud-api/tsconfig.json | 13 + services/cloud-api/wrangler.jsonc | 25 ++ site/app/globals.css | 152 +++++++++ site/app/login/page.tsx | 30 ++ site/app/s/[slug]/not-found.tsx | 28 ++ site/app/s/[slug]/page.tsx | 79 +++++ site/bun.lock | 198 +++++++++++ site/components/Faq.tsx | 2 +- site/components/Footer.tsx | 2 +- .../published/PublishedMarkdown.tsx | 44 +++ site/lib/published-note.ts | 59 ++++ site/package.json | 2 + site/wrangler.jsonc | 3 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 +- src-tauri/src/cloud.rs | 218 ++++++++++++ src-tauri/src/cloud_metadata.rs | 196 +++++++++++ src-tauri/src/commands.rs | 47 ++- src-tauri/src/error.rs | 9 + src-tauri/src/lib.rs | 6 + src-tauri/src/notes.rs | 9 +- src-tauri/tauri.conf.json | 2 +- src/components/editor/NoteEditor.tsx | 19 ++ src/components/editor/PublishNoteModal.tsx | 317 ++++++++++++++++++ src/components/layout/AppShell.tsx | 12 +- src/lib/ipc.ts | 10 + src/lib/types.ts | 18 + 38 files changed, 2391 insertions(+), 7 deletions(-) create mode 100644 services/cloud-api/bun.lock create mode 100644 services/cloud-api/migrations/0001_shares.sql create mode 100644 services/cloud-api/package.json create mode 100644 services/cloud-api/src/auth.ts create mode 100644 services/cloud-api/src/crypto.ts create mode 100644 services/cloud-api/src/http.ts create mode 100644 services/cloud-api/src/index.ts create mode 100644 services/cloud-api/src/shares.ts create mode 100644 services/cloud-api/src/types.ts create mode 100644 services/cloud-api/src/validation.ts create mode 100644 services/cloud-api/test/publishing.test.ts create mode 100644 services/cloud-api/tsconfig.json create mode 100644 services/cloud-api/wrangler.jsonc create mode 100644 site/app/login/page.tsx create mode 100644 site/app/s/[slug]/not-found.tsx create mode 100644 site/app/s/[slug]/page.tsx create mode 100644 site/components/published/PublishedMarkdown.tsx create mode 100644 site/lib/published-note.ts create mode 100644 src-tauri/src/cloud.rs create mode 100644 src-tauri/src/cloud_metadata.rs create mode 100644 src/components/editor/PublishNoteModal.tsx diff --git a/services/cloud-api/bun.lock b/services/cloud-api/bun.lock new file mode 100644 index 0000000..2057b78 --- /dev/null +++ b/services/cloud-api/bun.lock @@ -0,0 +1,205 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "markd-cloud-api", + "devDependencies": { + "@cloudflare/workers-types": "^5.20260716.1", + "@types/bun": "^1.3.0", + "typescript": "^5.8.3", + "wrangler": "^4.42.0", + }, + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260710.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-OqJl2eWF5+y9jarMm3YqqCTUe7Hd4ihogX5jyRU8iaAgOVyDr/Bk6aXpPCVUi1/MHzO93a18R/TmSTtzmB0sQw=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260710.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MYBqWgUblO+VlGvO73zYsH3hB9tdRj+yLyt5IHDFWryipb2l1efmNiWtAOkIhSRfypqLYGFrfpaDm2Hg00XVKw=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260710.1", "", { "os": "linux", "cpu": "x64" }, "sha512-lVWUgqI8qrkqvaCBGElu1kdaUFdAvaS2RD8K4qkCFP9hI3f5TCXumEs5qWSeZkvKum0+X/uJZ5hBFWsYI5SmoQ=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260710.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kDwDPItBjAI4JL0df9Fma2N+Qggbm77IB/DnroAkEGQ79fpR80sYMyuB/ZQKyjEk9f48Ocq7HCCLq59qVSyNqA=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260710.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GcLHy1oN1dfK6g1Z7UDV9f5xMGyTfPwcjWQ0sfWKH31IsoEVCRapnj3IC0PoIrDbnoo6irGPP0CwVs3WzdTajw=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260716.1", "", {}, "sha512-LqQPmGAvdpQxzZGAMlDI6fnCsTlr8nRQibWsREaERqD0PucwFl25aXpUskSob70uSY2n6K1sp6Te9xkmzSFgaw=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "miniflare": ["miniflare@4.20260710.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260710.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-x1LLRkU6o1p7hiKrB0TRnL0MJn6xFOT+/vrlEQINz5cRDKLP8ru4hBqWTIvXAetzr1acKAnmAaG84pQ4W/K14g=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "workerd": ["workerd@1.20260710.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260710.1", "@cloudflare/workerd-darwin-arm64": "1.20260710.1", "@cloudflare/workerd-linux-64": "1.20260710.1", "@cloudflare/workerd-linux-arm64": "1.20260710.1", "@cloudflare/workerd-windows-64": "1.20260710.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-U2sBPPrb9U97sBKnnMN6Kv8p65903P35nwMkPE9vSH/bRuRqkZ3a1EjUw3jV28RhiyXpkLF77Evzw8XimFxyTw=="], + + "wrangler": ["wrangler@4.111.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260710.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260710.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260710.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-bffpI9EyrnpKkF/1S+RaIv8oRD93GtbsA7TlfWwOsGJGB7VO3jVbdGzpC9TU7Bqom3z7jUxcte4Z9MPhaQ4HoQ=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + } +} diff --git a/services/cloud-api/migrations/0001_shares.sql b/services/cloud-api/migrations/0001_shares.sql new file mode 100644 index 0000000..5a8780d --- /dev/null +++ b/services/cloud-api/migrations/0001_shares.sql @@ -0,0 +1,67 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE entitlements ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + plan TEXT NOT NULL CHECK (plan IN ('free', 'cloud')) DEFAULT 'free', + status TEXT NOT NULL CHECK (status IN ('active', 'trialing', 'past_due', 'canceled')) DEFAULT 'active', + current_period_end INTEGER +) STRICT; + +CREATE TABLE shares ( + id TEXT PRIMARY KEY, + entry_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + object_key TEXT NOT NULL, + content_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + idempotency_key TEXT NOT NULL, + published_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + revoked_at INTEGER +) STRICT; + +CREATE UNIQUE INDEX shares_owner_idempotency + ON shares(user_id, idempotency_key); + +CREATE UNIQUE INDEX shares_active_entry + ON shares(user_id, entry_id) + WHERE status = 'active'; + +CREATE INDEX shares_public_slug + ON shares(slug, status); + +CREATE INDEX sessions_token_expiry + ON sessions(token_hash, expires_at); + +CREATE TRIGGER shares_free_limit_before_insert +BEFORE INSERT ON shares +WHEN NEW.status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM entitlements + WHERE user_id = NEW.user_id + AND plan = 'cloud' + AND status IN ('active', 'trialing') + ) + AND EXISTS ( + SELECT 1 FROM shares + WHERE user_id = NEW.user_id AND status = 'active' + ) +BEGIN + SELECT RAISE(ABORT, 'free_share_limit'); +END; diff --git a/services/cloud-api/package.json b/services/cloud-api/package.json new file mode 100644 index 0000000..0474013 --- /dev/null +++ b/services/cloud-api/package.json @@ -0,0 +1,20 @@ +{ + "name": "markd-cloud-api", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "deploy:check": "wrangler deploy --dry-run", + "db:migrate:local": "wrangler d1 migrations apply DB --local", + "db:migrate:remote": "wrangler d1 migrations apply DB --remote", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260716.1", + "@types/bun": "^1.3.0", + "typescript": "^5.8.3", + "wrangler": "^4.42.0" + } +} diff --git a/services/cloud-api/src/auth.ts b/services/cloud-api/src/auth.ts new file mode 100644 index 0000000..bc594d7 --- /dev/null +++ b/services/cloud-api/src/auth.ts @@ -0,0 +1,38 @@ +import { sha256 } from "./crypto"; +import { error } from "./http"; +import type { AccountPlan, AuthenticatedUser, Env } from "./types"; + +interface SessionRow { + user_id: string; + email: string; + plan: AccountPlan | null; +} + +export class AuthenticationError extends Error {} + +export async function authenticatedUser( + request: Request, + env: Env, +): Promise { + const authorization = request.headers.get("authorization") ?? ""; + const match = /^Bearer ([A-Za-z0-9_-]{32,256})$/.exec(authorization); + if (!match) throw new AuthenticationError(); + + const row = await env.DB.prepare( + `SELECT users.id AS user_id, users.email, entitlements.plan + FROM sessions + JOIN users ON users.id = sessions.user_id + LEFT JOIN entitlements ON entitlements.user_id = users.id + AND entitlements.status IN ('active', 'trialing') + WHERE sessions.token_hash = ? AND sessions.expires_at > ?`, + ) + .bind(await sha256(match[1]), Date.now()) + .first(); + if (!row) throw new AuthenticationError(); + + return { id: row.user_id, email: row.email, plan: row.plan ?? "free" }; +} + +export function authenticationRequired(): Response { + return error(401, "login_required", "Sign in to Markd before publishing a note."); +} diff --git a/services/cloud-api/src/crypto.ts b/services/cloud-api/src/crypto.ts new file mode 100644 index 0000000..302d8f9 --- /dev/null +++ b/services/cloud-api/src/crypto.ts @@ -0,0 +1,22 @@ +const encoder = new TextEncoder(); + +export async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +export function randomSlug(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +export function newId(prefix: "share"): string { + return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`; +} diff --git a/services/cloud-api/src/http.ts b/services/cloud-api/src/http.ts new file mode 100644 index 0000000..323adb5 --- /dev/null +++ b/services/cloud-api/src/http.ts @@ -0,0 +1,54 @@ +const JSON_HEADERS = { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "x-content-type-options": "nosniff", +} as const; + +export function json( + body: unknown, + status = 200, + headers?: HeadersInit, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...JSON_HEADERS, ...headers }, + }); +} + +export function error( + status: number, + code: string, + message: string, + extra?: Record, +): Response { + return json({ error: { code, message, ...extra } }, status); +} + +export async function readJson( + request: Request, + maxBytes: number, +): Promise { + const buffer = await request.arrayBuffer(); + if (buffer.byteLength > maxBytes) { + throw new RequestBodyError(413, "payload_too_large", "The note is too large to publish."); + } + try { + return JSON.parse(new TextDecoder().decode(buffer)); + } catch { + throw new RequestBodyError(400, "invalid_json", "The request body must be valid JSON."); + } +} + +export class RequestBodyError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +export function notFound(): Response { + return error(404, "not_found", "The published note was not found."); +} diff --git a/services/cloud-api/src/index.ts b/services/cloud-api/src/index.ts new file mode 100644 index 0000000..a5844b8 --- /dev/null +++ b/services/cloud-api/src/index.ts @@ -0,0 +1,75 @@ +import { error, json, RequestBodyError } from "./http"; +import { + createShare, + getOwnedShare, + getPublicShare, + revokeShare, + updateShare, +} from "./shares"; +import type { Env } from "./types"; +import { ValidationError } from "./validation"; +import { + authenticatedUser, + AuthenticationError, + authenticationRequired, +} from "./auth"; + +const SHARE_ID = /^\/v1\/shares\/(share_[a-f0-9]{32})$/; +const PUBLIC_SLUG = /^\/v1\/public\/shares\/([a-zA-Z0-9_-]{20,24})$/; + +async function route( + request: Request, + env: Env, + ctx: ExecutionContext, +): Promise { + const url = new URL(request.url); + + if (request.method === "GET" && url.pathname === "/health") { + return json({ ok: true }); + } + if (request.method === "GET" && url.pathname === "/v1/me") { + const user = await authenticatedUser(request, env); + return json({ user: { id: user.id, email: user.email, plan: user.plan } }); + } + if (request.method === "POST" && url.pathname === "/v1/shares") { + return createShare(request, env); + } + + const publicMatch = PUBLIC_SLUG.exec(url.pathname); + if (request.method === "GET" && publicMatch) { + return getPublicShare(env, publicMatch[1]); + } + + const shareMatch = SHARE_ID.exec(url.pathname); + if (shareMatch) { + if (request.method === "GET") { + return getOwnedShare(request, env, shareMatch[1]); + } + if (request.method === "PUT") { + return updateShare(request, env, ctx, shareMatch[1]); + } + if (request.method === "DELETE") { + return revokeShare(request, env, ctx, shareMatch[1]); + } + } + + return error(404, "route_not_found", "The requested endpoint does not exist."); +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + try { + return await route(request, env, ctx); + } catch (cause) { + if (cause instanceof AuthenticationError) return authenticationRequired(); + if (cause instanceof ValidationError) { + return error(400, cause.code, cause.message); + } + if (cause instanceof RequestBodyError) { + return error(cause.status, cause.code, cause.message); + } + console.error("Unhandled publishing API error", cause); + return error(500, "internal_error", "The publishing service could not complete the request."); + } + }, +} satisfies ExportedHandler; diff --git a/services/cloud-api/src/shares.ts b/services/cloud-api/src/shares.ts new file mode 100644 index 0000000..6d8531f --- /dev/null +++ b/services/cloud-api/src/shares.ts @@ -0,0 +1,257 @@ +import { newId, randomSlug, sha256 } from "./crypto"; +import { authenticatedUser } from "./auth"; +import { error, json, notFound, readJson } from "./http"; +import type { Env, ShareResponse, ShareRow } from "./types"; +import { idempotencyKey, MAX_REQUEST_BYTES, publishInput } from "./validation"; + +const SHARE_COLUMNS = ` + id, entry_id, user_id, slug, title, object_key, + content_hash, status, idempotency_key, published_at, updated_at, revoked_at +`; + +function response(row: ShareRow, siteOrigin: string): ShareResponse { + return { + id: row.id, + entryId: row.entry_id, + slug: row.slug, + url: `${siteOrigin.replace(/\/$/, "")}/s/${row.slug}`, + title: row.title, + contentHash: row.content_hash, + publishedAt: row.published_at, + updatedAt: row.updated_at, + }; +} + +async function findOwnedShare( + env: Env, + shareId: string, + userId: string, +): Promise { + return env.DB.prepare( + `SELECT ${SHARE_COLUMNS} FROM shares + WHERE id = ? AND user_id = ? AND status = 'active'`, + ) + .bind(shareId, userId) + .first(); +} + +export async function createShare(request: Request, env: Env): Promise { + const user = await authenticatedUser(request, env); + const requestKey = idempotencyKey(request); + const input = publishInput(await readJson(request, MAX_REQUEST_BYTES)); + + const replay = await env.DB.prepare( + `SELECT ${SHARE_COLUMNS} FROM shares + WHERE user_id = ? AND idempotency_key = ?`, + ) + .bind(user.id, requestKey) + .first(); + if (replay) return json({ share: response(replay, env.PUBLIC_SITE_ORIGIN) }); + + const currentEntry = await env.DB.prepare( + `SELECT ${SHARE_COLUMNS} FROM shares + WHERE user_id = ? AND entry_id = ? AND status = 'active' LIMIT 1`, + ) + .bind(user.id, input.entryId) + .first(); + if (currentEntry) { + return error(409, "already_published", "This note is already published.", { + share: response(currentEntry, env.PUBLIC_SITE_ORIGIN), + }); + } + + if (user.plan === "free") { + const active = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM shares WHERE user_id = ? AND status = 'active'", + ) + .bind(user.id) + .first<{ count: number }>(); + if ((active?.count ?? 0) >= 1) { + return error( + 402, + "cloud_subscription_required", + "The free plan includes one published note. Upgrade to Markd Cloud to publish more.", + { upgradeUrl: `${env.PUBLIC_SITE_ORIGIN.replace(/\/$/, "")}/login?intent=upgrade` }, + ); + } + } + + const id = newId("share"); + const slug = randomSlug(); + const contentHash = await sha256(input.markdown); + const objectKey = `shares/${id}/${contentHash}.md`; + const now = Date.now(); + + await env.PUBLISHED_NOTES.put(objectKey, input.markdown, { + httpMetadata: { contentType: "text/markdown; charset=utf-8" }, + customMetadata: { shareId: id, contentHash }, + }); + + try { + await env.DB.prepare( + `INSERT INTO shares ( + id, entry_id, user_id, slug, title, object_key, + content_hash, status, idempotency_key, published_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`, + ) + .bind( + id, + input.entryId, + user.id, + slug, + input.title, + objectKey, + contentHash, + requestKey, + now, + now, + ) + .run(); + } catch (cause) { + await env.PUBLISHED_NOTES.delete(objectKey); + const message = cause instanceof Error ? cause.message : String(cause); + if (message.includes("shares.user_id, shares.entry_id")) { + return error(409, "already_published", "This note is already published."); + } + if (message.includes("free_share_limit")) { + return error( + 402, + "cloud_subscription_required", + "The free plan includes one published note. Upgrade to Markd Cloud to publish more.", + { upgradeUrl: `${env.PUBLIC_SITE_ORIGIN.replace(/\/$/, "")}/login?intent=upgrade` }, + ); + } + throw cause; + } + + const row: ShareRow = { + id, + entry_id: input.entryId, + user_id: user.id, + slug, + title: input.title, + object_key: objectKey, + content_hash: contentHash, + status: "active", + idempotency_key: requestKey, + published_at: now, + updated_at: now, + revoked_at: null, + }; + return json({ share: response(row, env.PUBLIC_SITE_ORIGIN) }, 201); +} + +export async function getOwnedShare( + request: Request, + env: Env, + shareId: string, +): Promise { + const user = await authenticatedUser(request, env); + const row = await findOwnedShare(env, shareId, user.id); + return row + ? json({ share: response(row, env.PUBLIC_SITE_ORIGIN) }) + : notFound(); +} + +export async function updateShare( + request: Request, + env: Env, + ctx: ExecutionContext, + shareId: string, +): Promise { + const user = await authenticatedUser(request, env); + idempotencyKey(request); + const input = publishInput(await readJson(request, MAX_REQUEST_BYTES)); + const current = await findOwnedShare(env, shareId, user.id); + if (!current || current.entry_id !== input.entryId) return notFound(); + + const contentHash = await sha256(input.markdown); + if (contentHash === current.content_hash && input.title === current.title) { + return json({ share: response(current, env.PUBLIC_SITE_ORIGIN) }); + } + + const objectKey = `shares/${shareId}/${contentHash}.md`; + const now = Date.now(); + await env.PUBLISHED_NOTES.put(objectKey, input.markdown, { + httpMetadata: { contentType: "text/markdown; charset=utf-8" }, + customMetadata: { shareId, contentHash }, + }); + + try { + const result = await env.DB.prepare( + `UPDATE shares + SET title = ?, object_key = ?, content_hash = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND status = 'active'`, + ) + .bind(input.title, objectKey, contentHash, now, shareId, user.id) + .run(); + if (result.meta.changes !== 1) { + await env.PUBLISHED_NOTES.delete(objectKey); + return notFound(); + } + } catch (cause) { + await env.PUBLISHED_NOTES.delete(objectKey); + throw cause; + } + + if (current.object_key !== objectKey) { + ctx.waitUntil(env.PUBLISHED_NOTES.delete(current.object_key)); + } + return json({ + share: response( + { + ...current, + title: input.title, + object_key: objectKey, + content_hash: contentHash, + updated_at: now, + }, + env.PUBLIC_SITE_ORIGIN, + ), + }); +} + +export async function revokeShare( + request: Request, + env: Env, + ctx: ExecutionContext, + shareId: string, +): Promise { + const user = await authenticatedUser(request, env); + const current = await findOwnedShare(env, shareId, user.id); + if (!current) return notFound(); + + const now = Date.now(); + const result = await env.DB.prepare( + `UPDATE shares SET status = 'revoked', revoked_at = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND status = 'active'`, + ) + .bind(now, now, shareId, user.id) + .run(); + if (result.meta.changes !== 1) return notFound(); + ctx.waitUntil(env.PUBLISHED_NOTES.delete(current.object_key)); + return new Response(null, { status: 204 }); +} + +export async function getPublicShare( + env: Env, + slug: string, +): Promise { + const row = await env.DB.prepare( + `SELECT ${SHARE_COLUMNS} FROM shares + WHERE slug = ? AND status = 'active'`, + ) + .bind(slug) + .first(); + if (!row) return notFound(); + + const object = await env.PUBLISHED_NOTES.get(row.object_key); + if (!object?.body) return notFound(); + const markdown = await object.text(); + return json({ + title: row.title, + markdown, + publishedAt: row.published_at, + updatedAt: row.updated_at, + }); +} diff --git a/services/cloud-api/src/types.ts b/services/cloud-api/src/types.ts new file mode 100644 index 0000000..d5f9246 --- /dev/null +++ b/services/cloud-api/src/types.ts @@ -0,0 +1,45 @@ +export interface Env { + DB: D1Database; + PUBLISHED_NOTES: R2Bucket; + PUBLIC_SITE_ORIGIN: string; +} + +export interface PublishInput { + entryId: string; + title: string; + markdown: string; +} + +export type AccountPlan = "free" | "cloud"; + +export interface AuthenticatedUser { + id: string; + email: string; + plan: AccountPlan; +} + +export interface ShareRow { + id: string; + entry_id: string; + user_id: string; + slug: string; + title: string; + object_key: string; + content_hash: string; + status: "active" | "revoked"; + idempotency_key: string; + published_at: number; + updated_at: number; + revoked_at: number | null; +} + +export interface ShareResponse { + id: string; + entryId: string; + slug: string; + url: string; + title: string; + contentHash: string; + publishedAt: number; + updatedAt: number; +} diff --git a/services/cloud-api/src/validation.ts b/services/cloud-api/src/validation.ts new file mode 100644 index 0000000..0c2739f --- /dev/null +++ b/services/cloud-api/src/validation.ts @@ -0,0 +1,56 @@ +import type { PublishInput } from "./types"; + +export const MAX_REQUEST_BYTES = 600 * 1024; +export const MAX_MARKDOWN_BYTES = 512 * 1024; +const ENTRY_PATTERN = /^entry_[a-zA-Z0-9_-]{16,80}$/; +const IDEMPOTENCY_PATTERN = /^[a-zA-Z0-9_-]{16,100}$/; + +export class ValidationError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } +} + +export function idempotencyKey(request: Request): string { + const key = request.headers.get("idempotency-key") ?? ""; + if (!IDEMPOTENCY_PATTERN.test(key)) { + throw new ValidationError( + "invalid_idempotency_key", + "A valid idempotency key is required.", + ); + } + return key; +} + +export function publishInput(value: unknown): PublishInput { + if (!value || typeof value !== "object") { + throw new ValidationError("invalid_publish_request", "Publish details are required."); + } + const input = value as Record; + const entryId = typeof input.entryId === "string" ? input.entryId : ""; + const title = typeof input.title === "string" ? input.title.trim() : ""; + const markdown = typeof input.markdown === "string" ? input.markdown : ""; + + if (!ENTRY_PATTERN.test(entryId)) { + throw new ValidationError("invalid_entry_id", "The note identifier is invalid."); + } + if (!title || title.length > 200) { + throw new ValidationError( + "invalid_title", + "The published title must be between 1 and 200 characters.", + ); + } + if (!markdown.trim()) { + throw new ValidationError("empty_note", "An empty note cannot be published."); + } + if (new TextEncoder().encode(markdown).byteLength > MAX_MARKDOWN_BYTES) { + throw new ValidationError( + "note_too_large", + "Published Markdown must be 512 KB or smaller.", + ); + } + return { entryId, title, markdown }; +} diff --git a/services/cloud-api/test/publishing.test.ts b/services/cloud-api/test/publishing.test.ts new file mode 100644 index 0000000..614c878 --- /dev/null +++ b/services/cloud-api/test/publishing.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { randomSlug, sha256 } from "../src/crypto"; +import { + idempotencyKey, + publishInput, + ValidationError, +} from "../src/validation"; + +describe("publishing validation", () => { + test("accepts a valid publish snapshot", () => { + expect( + publishInput({ + entryId: "entry_1234567890abcdef", + title: " Project notes ", + markdown: "# Project\n\nHello.", + }), + ).toEqual({ + entryId: "entry_1234567890abcdef", + title: "Project notes", + markdown: "# Project\n\nHello.", + }); + }); + + test("rejects empty notes", () => { + expect(() => + publishInput({ + entryId: "entry_1234567890abcdef", + title: "Empty", + markdown: " ", + }), + ).toThrow(ValidationError); + }); + + test("requires an idempotency credential", () => { + const request = new Request("https://api.example.test/v1/shares", { + headers: { + "idempotency-key": "publish_1234567890abcdef", + }, + }); + expect(idempotencyKey(request)).toBe("publish_1234567890abcdef"); + }); +}); + +describe("publishing identifiers", () => { + test("generates an unguessable URL-safe slug", () => { + const slug = randomSlug(); + expect(slug).toMatch(/^[a-zA-Z0-9_-]{22}$/); + }); + + test("hashes content deterministically", async () => { + expect(await sha256("Markd")).toBe( + "5f760a58961babe4c488f61b3457e73fc9d9b78f5727b04ae80a8e470580eb6f", + ); + }); +}); diff --git a/services/cloud-api/tsconfig.json b/services/cloud-api/tsconfig.json new file mode 100644 index 0000000..a03a59c --- /dev/null +++ b/services/cloud-api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "WebWorker"], + "types": ["@cloudflare/workers-types", "bun"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src", "test"] +} diff --git a/services/cloud-api/wrangler.jsonc b/services/cloud-api/wrangler.jsonc new file mode 100644 index 0000000..605deb5 --- /dev/null +++ b/services/cloud-api/wrangler.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "markd-cloud-api", + "main": "src/index.ts", + "compatibility_date": "2026-07-16", + "workers_dev": true, + "observability": { + "enabled": true + }, + "vars": { + "PUBLIC_SITE_ORIGIN": "https://usemarkd.app" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "markd-cloud", + "migrations_dir": "migrations" + } + ], + "r2_buckets": [ + { + "binding": "PUBLISHED_NOTES" + } + ] +} diff --git a/site/app/globals.css b/site/app/globals.css index cd919d0..2bf095a 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -115,3 +115,155 @@ scroll-behavior: auto; } } + +/* Public notes use the marketing site's editorial type system, but keep the + reading surface deliberately quieter than the landing page. */ +.published-note { + color: var(--fg-soft); + font-size: 16px; + line-height: 1.82; + overflow-wrap: anywhere; +} + +.published-note > :first-child { + margin-top: 0; +} + +.published-note > :last-child { + margin-bottom: 0; +} + +.published-note h1, +.published-note h2, +.published-note h3, +.published-note h4 { + color: var(--fg); + font-family: var(--font-fraunces), ui-serif, Georgia, serif; + font-weight: 560; + letter-spacing: -0.025em; + line-height: 1.18; + margin: 2.2em 0 0.65em; +} + +.published-note h1 { font-size: 2rem; } +.published-note h2 { font-size: 1.65rem; } +.published-note h3 { font-size: 1.3rem; } +.published-note h4 { font-size: 1.08rem; } + +.published-note p, +.published-note ul, +.published-note ol, +.published-note blockquote, +.published-note pre, +.published-note table { + margin: 1.15em 0; +} + +.published-note ul, +.published-note ol { + padding-left: 1.4em; +} + +.published-note li { + margin: 0.35em 0; + padding-left: 0.2em; +} + +.published-note li::marker { + color: var(--faint); +} + +.published-note a { + color: var(--fg); + text-decoration: underline; + text-decoration-color: var(--border-strong); + text-decoration-thickness: 1px; + text-underline-offset: 3px; + transition: text-decoration-color 120ms ease-out; +} + +.published-note a:hover { + text-decoration-color: var(--fg); +} + +.published-note blockquote { + border-left: 2px solid var(--border-strong); + color: var(--fg-muted); + font-family: var(--font-fraunces), ui-serif, Georgia, serif; + font-size: 1.08em; + font-style: italic; + padding: 0.15em 0 0.15em 1.25em; +} + +.published-note code { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 5px; + color: var(--fg); + font-family: var(--font-jbmono), ui-monospace, monospace; + font-size: 0.82em; + padding: 0.15em 0.38em; +} + +.published-note pre { + background: var(--fg); + border-radius: 12px; + color: #efefe9; + overflow-x: auto; + padding: 1.15rem 1.25rem; +} + +.published-note pre code { + background: transparent; + border: 0; + color: inherit; + font-size: 12.5px; + line-height: 1.75; + padding: 0; +} + +.published-note hr { + border: 0; + border-top: 1px solid var(--border); + margin: 2.6em 0; +} + +.published-note table { + border-collapse: collapse; + display: block; + font-size: 14px; + overflow-x: auto; + width: 100%; +} + +.published-note th, +.published-note td { + border-bottom: 1px solid var(--border); + padding: 0.65rem 0.8rem; + text-align: left; +} + +.published-note th { + color: var(--fg); + font-weight: 650; +} + +.published-note input[type="checkbox"] { + accent-color: var(--fg); + margin-right: 0.5em; +} + +.published-note__asset-placeholder { + align-items: center; + background: var(--panel); + border: 1px dashed var(--border-strong); + border-radius: 10px; + color: var(--faint); + display: flex; + font-family: var(--font-jbmono), ui-monospace, monospace; + font-size: 11px; + justify-content: center; + margin: 1.4rem 0; + min-height: 120px; + padding: 1rem; +} diff --git a/site/app/login/page.tsx b/site/app/login/page.tsx new file mode 100644 index 0000000..ac4361b --- /dev/null +++ b/site/app/login/page.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; + +export default function LoginPage() { + return ( +
+
+

+ Markd account +

+

+ Sign in to publish +

+

+ Account sign-in is being connected to the desktop app. Publishing stays + disabled until a verified Markd session is available. +

+
+ Free accounts will include one active public note. Markd Cloud accounts + will include unlimited publishing and cross-device sync. +
+ + Back to Markd + +
+
+ ); +} diff --git a/site/app/s/[slug]/not-found.tsx b/site/app/s/[slug]/not-found.tsx new file mode 100644 index 0000000..4fa90a4 --- /dev/null +++ b/site/app/s/[slug]/not-found.tsx @@ -0,0 +1,28 @@ +import { Wordmark } from "@/components/Logo"; + +export default function PublishedNoteNotFound() { + return ( +
+
+ + + +

+ Published note +

+

+ This note is no longer available. +

+

+ It may have been unpublished, or the link may be incorrect. +

+ + Visit Markd + +
+
+ ); +} diff --git a/site/app/s/[slug]/page.tsx b/site/app/s/[slug]/page.tsx new file mode 100644 index 0000000..93fc764 --- /dev/null +++ b/site/app/s/[slug]/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { Wordmark } from "@/components/Logo"; +import { PublishedMarkdown } from "@/components/published/PublishedMarkdown"; +import { + getPublishedNote, + noteDescription, +} from "@/lib/published-note"; + +export const dynamic = "force-dynamic"; + +interface SharePageProps { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ params }: SharePageProps): Promise { + const { slug } = await params; + const note = await getPublishedNote(slug); + if (!note) return { title: "Published note · Markd", robots: { index: false } }; + + const description = noteDescription(note.markdown) || "A note published with Markd."; + return { + title: `${note.title} · Markd`, + description, + robots: { index: false, follow: false }, + openGraph: { + title: note.title, + description, + type: "article", + siteName: "Markd", + }, + twitter: { card: "summary", title: note.title, description }, + }; +} + +export default async function SharePage({ params }: SharePageProps) { + const { slug } = await params; + const note = await getPublishedNote(slug); + if (!note) notFound(); + + const published = new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(note.publishedAt)); + + return ( +
+
+ + + + + Public note + +
+ +
+
+

+ Published {published} +

+

+ {note.title} +

+
+ + +
+ + +
+ ); +} diff --git a/site/bun.lock b/site/bun.lock index b863e45..6983735 100644 --- a/site/bun.lock +++ b/site/bun.lock @@ -12,6 +12,8 @@ "ogl": "^1.0.11", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", }, "devDependencies": { @@ -429,6 +431,18 @@ "@tsconfig/node18": ["@tsconfig/node18@1.0.3", "", {}, "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], @@ -437,6 +451,10 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -457,6 +475,8 @@ "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], @@ -477,8 +497,18 @@ "caniuse-lite": ["caniuse-lite@1.0.30001803", "", {}, "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], @@ -491,6 +521,8 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="], @@ -509,12 +541,18 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -549,8 +587,12 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -559,6 +601,8 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -609,6 +653,12 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -621,8 +671,20 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -659,20 +721,110 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -725,6 +877,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -741,6 +895,8 @@ "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], @@ -753,6 +909,16 @@ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -789,14 +955,22 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -813,6 +987,10 @@ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-tqdm": ["ts-tqdm@0.8.6", "", {}, "sha512-3X3M1PZcHtgQbnwizL+xU8CAgbYbeLHrrDwL9xxcZZrV5J+e7loJm1XrXozHjSkl44J0Zg0SgA8rXbh83kCkcQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -827,12 +1005,28 @@ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -863,6 +1057,8 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], @@ -907,6 +1103,8 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], diff --git a/site/components/Faq.tsx b/site/components/Faq.tsx index e254ec6..1f847d6 100644 --- a/site/components/Faq.tsx +++ b/site/components/Faq.tsx @@ -3,7 +3,7 @@ import { Plus } from "lucide-react"; const FAQS: { q: string; a: string }[] = [ { q: "Where are my notes stored?", - a: "On your Mac, as plain Markdown files in a folder you choose. No database, no cloud, no account — open the folder in Finder anytime. The app sends nothing anywhere.", + a: "On your Mac, as plain Markdown files in a folder you choose. No database or account is required — open the folder in Finder anytime. Markd only uploads a separate copy when you explicitly publish a note.", }, { q: "Is there sync?", diff --git a/site/components/Footer.tsx b/site/components/Footer.tsx index 286b443..ac5945e 100644 --- a/site/components/Footer.tsx +++ b/site/components/Footer.tsx @@ -55,7 +55,7 @@ export function Footer() { transition={{ duration: 0.5, ease: EASE_OUT, delay: 0.15 }} className="mx-auto mt-5 max-w-md text-pretty text-[15px] leading-7 text-muted-foreground" > - No account, no cloud, no lock-in. Just the last notes app + No required account, no lock-in. Just the last notes app you'll download. Probably. Grab it and start writing. ) { + if (!isSafeLink(href)) return {children}; + const external = /^https?:/i.test(href ?? ""); + return ( + + {children} + + ); +} + +export function PublishedMarkdown({ markdown }: { markdown: string }) { + return ( +
+ ( + + {alt || "Image attachment"} + + ), + }} + > + {markdown} + +
+ ); +} diff --git a/site/lib/published-note.ts b/site/lib/published-note.ts new file mode 100644 index 0000000..7bff45b --- /dev/null +++ b/site/lib/published-note.ts @@ -0,0 +1,59 @@ +import { cache } from "react"; + +export interface PublishedNote { + title: string; + markdown: string; + publishedAt: number; + updatedAt: number; +} + +function apiOrigin(): string { + const origin = process.env.MARKD_CLOUD_API_URL; + if (!origin) { + throw new Error("MARKD_CLOUD_API_URL is not configured"); + } + return origin.replace(/\/$/, ""); +} + +function isPublishedNote(value: unknown): value is PublishedNote { + if (!value || typeof value !== "object") return false; + const note = value as Record; + return ( + typeof note.title === "string" && + typeof note.markdown === "string" && + typeof note.publishedAt === "number" && + typeof note.updatedAt === "number" + ); +} + +export const getPublishedNote = cache( + async (slug: string): Promise => { + const response = await fetch( + `${apiOrigin()}/v1/public/shares/${encodeURIComponent(slug)}`, + { + cache: "no-store", + headers: { accept: "application/json" }, + }, + ); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`Publishing API returned ${response.status}`); + } + const body: unknown = await response.json(); + if (!isPublishedNote(body)) { + throw new Error("Publishing API returned an invalid note"); + } + return body; + }, +); + +export function noteDescription(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/[#>*_`~|-]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 180); +} diff --git a/site/package.json b/site/package.json index 79b027c..d1cbdc0 100644 --- a/site/package.json +++ b/site/package.json @@ -21,6 +21,8 @@ "ogl": "^1.0.11", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0" }, "devDependencies": { diff --git a/site/wrangler.jsonc b/site/wrangler.jsonc index 1903ee3..07f89ce 100644 --- a/site/wrangler.jsonc +++ b/site/wrangler.jsonc @@ -4,6 +4,9 @@ "main": ".open-next/worker.js", "compatibility_date": "2025-10-01", "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "vars": { + "MARKD_CLOUD_API_URL": "https://api.usemarkd.app" + }, "assets": { "directory": ".open-next/assets", "binding": "ASSETS" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 614bfbf..8f935c7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -11,6 +11,7 @@ dependencies = [ "scraper", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8860a30..b103a74 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,11 +24,12 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1.11", features = ["v4"] } thiserror = "2" -reqwest = { version = "0.12", default-features = false, features = ["native-tls", "gzip"] } +reqwest = { version = "0.12", default-features = false, features = ["native-tls", "gzip", "json"] } scraper = "0.23" trash = "5" regex = "1" tauri-plugin-global-shortcut = "2" +sha2 = "0.10" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs new file mode 100644 index 0000000..875f962 --- /dev/null +++ b/src-tauri/src/cloud.rs @@ -0,0 +1,218 @@ +use std::path::Path; +use std::time::Duration; + +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::cloud_metadata::{self, PublishedShare}; +use crate::error::{AppError, AppResult}; + +const API_BASE: &str = "https://api.usemarkd.app"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PublishRequest<'a> { + entry_id: &'a str, + title: &'a str, + markdown: &'a str, +} + +#[derive(Debug, Deserialize)] +struct ShareEnvelope { + share: PublishedShare, +} + +#[derive(Debug, Deserialize)] +struct ErrorEnvelope { + error: CloudError, +} + +#[derive(Debug, Deserialize)] +struct CloudError { + code: String, + message: String, + #[serde(default)] + share: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PublishedNoteStatus { + pub account: Option, + pub share: Option, + pub is_outdated: bool, + pub free_share_limit: u8, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudAccount { + pub email: String, + pub plan: String, +} + +fn access_token(_app: &tauri::AppHandle) -> AppResult { + Err(AppError::CloudLoginRequired( + "Sign in to Markd before publishing a note.".to_string(), + )) +} + +fn client() -> AppResult { + Client::builder() + .timeout(Duration::from_secs(20)) + .user_agent("Markd/0.1.5") + .build() + .map_err(|error| AppError::Network(error.to_string())) +} + +fn content_hash(content: &str) -> String { + format!("{:x}", Sha256::digest(content.as_bytes())) +} + +fn request_key() -> String { + format!("publish_{}", Uuid::new_v4().simple()) +} + +async fn parse_response(response: reqwest::Response) -> AppResult { + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| AppError::Network(error.to_string()))?; + if status.is_success() { + return serde_json::from_str::(&body) + .map(|envelope| envelope.share) + .map_err(|error| AppError::Cloud(format!("invalid publishing response: {error}"))); + } + + let error = serde_json::from_str::(&body) + .map(|envelope| envelope.error) + .unwrap_or(CloudError { + code: "publishing_failed".to_string(), + message: format!("publishing service returned {status}"), + share: None, + }); + if status == StatusCode::CONFLICT && error.code == "already_published" { + return error + .share + .ok_or_else(|| AppError::Cloud("published note metadata is missing".to_string())); + } + if status == StatusCode::PAYMENT_REQUIRED { + return Err(AppError::CloudSubscriptionRequired(error.message)); + } + if status == StatusCode::UNAUTHORIZED { + return Err(AppError::CloudLoginRequired(error.message)); + } + Err(AppError::Cloud(error.message)) +} + +pub fn status( + _app: &tauri::AppHandle, + root: &Path, + rel: &str, + content: &str, +) -> AppResult { + let share = cloud_metadata::get(root, rel)?.and_then(|entry| entry.share); + let is_outdated = share + .as_ref() + .is_some_and(|published| published.content_hash != content_hash(content)); + Ok(PublishedNoteStatus { + account: None, + share, + is_outdated, + free_share_limit: 1, + }) +} + +pub async fn publish( + app: &tauri::AppHandle, + root: &Path, + rel: &str, + title: &str, + content: &str, +) -> AppResult { + let entry = cloud_metadata::entry(root, rel)?; + let response = client()? + .post(format!("{API_BASE}/v1/shares")) + .bearer_auth(access_token(app)?) + .header("idempotency-key", request_key()) + .json(&PublishRequest { + entry_id: &entry.entry_id, + title, + markdown: content, + }) + .send() + .await + .map_err(|error| AppError::Network(error.to_string()))?; + let share = parse_response(response).await?; + cloud_metadata::set_share(root, rel, share.clone())?; + Ok(share) +} + +pub async fn update( + app: &tauri::AppHandle, + root: &Path, + rel: &str, + title: &str, + content: &str, +) -> AppResult { + let entry = + cloud_metadata::get(root, rel)?.ok_or_else(|| AppError::NotFound(rel.to_string()))?; + let current = entry + .share + .ok_or_else(|| AppError::NotFound("published note".to_string()))?; + let response = client()? + .put(format!("{API_BASE}/v1/shares/{}", current.id)) + .bearer_auth(access_token(app)?) + .header("idempotency-key", request_key()) + .json(&PublishRequest { + entry_id: &entry.entry_id, + title, + markdown: content, + }) + .send() + .await + .map_err(|error| AppError::Network(error.to_string()))?; + let share = parse_response(response).await?; + cloud_metadata::set_share(root, rel, share.clone())?; + Ok(share) +} + +pub async fn revoke(app: &tauri::AppHandle, root: &Path, rel: &str) -> AppResult<()> { + let Some(entry) = cloud_metadata::get(root, rel)? else { + return Ok(()); + }; + let Some(share) = entry.share else { + return Ok(()); + }; + let response = client()? + .delete(format!("{API_BASE}/v1/shares/{}", share.id)) + .bearer_auth(access_token(app)?) + .send() + .await + .map_err(|error| AppError::Network(error.to_string()))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let message = serde_json::from_str::(&body) + .map(|envelope| envelope.error.message) + .unwrap_or_else(|_| format!("publishing service returned {status}")); + return Err(AppError::Cloud(message)); + } + cloud_metadata::clear_share(root, rel) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hashes_content_like_the_cloud_api() { + assert_eq!( + content_hash("Markd"), + "5f760a58961babe4c488f61b3457e73fc9d9b78f5727b04ae80a8e470580eb6f" + ); + } +} diff --git a/src-tauri/src/cloud_metadata.rs b/src-tauri/src/cloud_metadata.rs new file mode 100644 index 0000000..2954b27 --- /dev/null +++ b/src-tauri/src/cloud_metadata.rs @@ -0,0 +1,196 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::vault::{resolve_rel, DATA_DIR}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PublishedShare { + pub id: String, + pub entry_id: String, + pub slug: String, + pub url: String, + pub title: String, + pub content_hash: String, + pub published_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudEntry { + pub entry_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub share: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct CloudMetadata { + #[serde(default)] + entries: BTreeMap, +} + +fn store_path(root: &Path) -> PathBuf { + root.join(DATA_DIR).join("cloud.json") +} + +fn read(root: &Path) -> AppResult { + let path = store_path(root); + if !path.exists() { + return Ok(CloudMetadata::default()); + } + Ok(serde_json::from_str(&fs::read_to_string(path)?)?) +} + +fn save(root: &Path, metadata: &CloudMetadata) -> AppResult<()> { + let path = store_path(root); + let temp = path.with_extension(format!("json.tmp-{}", Uuid::new_v4().simple())); + fs::write(&temp, serde_json::to_string_pretty(metadata)?)?; + fs::rename(&temp, &path).inspect_err(|_| { + let _ = fs::remove_file(&temp); + })?; + Ok(()) +} + +fn ensure_note(root: &Path, rel: &str) -> AppResult<()> { + let path = resolve_rel(root, rel)?; + if !path.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("md") { + return Err(AppError::NotFound(rel.to_string())); + } + Ok(()) +} + +pub fn entry(root: &Path, rel: &str) -> AppResult { + ensure_note(root, rel)?; + let mut metadata = read(root)?; + if let Some(entry) = metadata.entries.get(rel) { + return Ok(entry.clone()); + } + let entry = CloudEntry { + entry_id: format!("entry_{}", Uuid::new_v4().simple()), + share: None, + }; + metadata.entries.insert(rel.to_string(), entry.clone()); + save(root, &metadata)?; + Ok(entry) +} + +pub fn get(root: &Path, rel: &str) -> AppResult> { + Ok(read(root)?.entries.get(rel).cloned()) +} + +pub fn set_share(root: &Path, rel: &str, share: PublishedShare) -> AppResult<()> { + let mut metadata = read(root)?; + let cloud_entry = metadata + .entries + .get_mut(rel) + .ok_or_else(|| AppError::NotFound(rel.to_string()))?; + if cloud_entry.entry_id != share.entry_id { + return Err(AppError::InvalidInput( + "published note does not match its local entry".to_string(), + )); + } + cloud_entry.share = Some(share); + save(root, &metadata) +} + +pub fn clear_share(root: &Path, rel: &str) -> AppResult<()> { + let mut metadata = read(root)?; + let Some(entry) = metadata.entries.get_mut(rel) else { + return Ok(()); + }; + entry.share = None; + save(root, &metadata) +} + +pub fn has_published_under(root: &Path, rel: &str) -> AppResult { + Ok(read(root)?.entries.iter().any(|(entry_rel, entry)| { + entry.share.is_some() && (entry_rel == rel || entry_rel.starts_with(&format!("{rel}/"))) + })) +} + +/// Preserve cloud identity after a note or folder rename/move. +pub fn remap(root: &Path, from: &str, to: &str) -> AppResult<()> { + let metadata = read(root)?; + let mut changed = false; + let mut entries = BTreeMap::new(); + for (rel, entry) in metadata.entries { + let next = if rel == from { + changed = true; + to.to_string() + } else if rel.starts_with(&format!("{from}/")) { + changed = true; + format!("{to}{}", &rel[from.len()..]) + } else { + rel + }; + entries.insert(next, entry); + } + if changed { + save(root, &CloudMetadata { entries })?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::{ensure_layout, notes_root}; + use tempfile::tempdir; + + fn setup() -> tempfile::TempDir { + let dir = tempdir().unwrap(); + ensure_layout(dir.path()).unwrap(); + fs::create_dir_all(notes_root(dir.path()).join("projects")).unwrap(); + fs::write(notes_root(dir.path()).join("projects/Plan.md"), "# Plan").unwrap(); + dir + } + + #[test] + fn creates_and_reuses_stable_entry_id() { + let dir = setup(); + let first = entry(dir.path(), "projects/Plan.md").unwrap(); + let second = entry(dir.path(), "projects/Plan.md").unwrap(); + assert_eq!(first.entry_id, second.entry_id); + assert!(first.entry_id.starts_with("entry_")); + } + + #[test] + fn remaps_folder_descendants() { + let dir = setup(); + let before = entry(dir.path(), "projects/Plan.md").unwrap(); + remap(dir.path(), "projects", "work").unwrap(); + let after = get(dir.path(), "work/Plan.md").unwrap().unwrap(); + assert_eq!(before.entry_id, after.entry_id); + assert!(get(dir.path(), "projects/Plan.md").unwrap().is_none()); + } + + #[test] + fn detects_published_entries_before_deletion() { + let dir = setup(); + let cloud_entry = entry(dir.path(), "projects/Plan.md").unwrap(); + set_share( + dir.path(), + "projects/Plan.md", + PublishedShare { + id: "share_123".into(), + entry_id: cloud_entry.entry_id, + slug: "public-slug".into(), + url: "https://usemarkd.app/s/public-slug".into(), + title: "Plan".into(), + content_hash: "abc".into(), + published_at: 1, + updated_at: 1, + }, + ) + .unwrap(); + assert!(has_published_under(dir.path(), "projects").unwrap()); + assert!(has_published_under(dir.path(), "projects/Plan.md").unwrap()); + assert!(!has_published_under(dir.path(), "other").unwrap()); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3f6c163..a598177 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -12,7 +12,9 @@ use crate::error::{AppError, AppResult}; use crate::search::SearchHit; use crate::todos::{self, Todo}; use crate::vault::{self, TreeNode}; -use crate::{agent_docs, assets, daily_notes, link_meta, notes, pins, quick_capture, search}; +use crate::{ + agent_docs, assets, cloud, daily_notes, link_meta, notes, pins, quick_capture, search, +}; #[derive(Default)] pub struct AppState { @@ -227,6 +229,49 @@ pub fn backlinks_for(state: State<'_, AppState>, rel: String) -> AppResult, + rel: String, + content: String, +) -> AppResult { + cloud::status(&app, &state.root()?, &rel, &content) +} + +#[tauri::command] +pub async fn publish_note( + app: AppHandle, + state: State<'_, AppState>, + rel: String, + title: String, + content: String, +) -> AppResult { + cloud::publish(&app, &state.root()?, &rel, &title, &content).await +} + +#[tauri::command] +pub async fn update_published_note( + app: AppHandle, + state: State<'_, AppState>, + rel: String, + title: String, + content: String, +) -> AppResult { + cloud::update(&app, &state.root()?, &rel, &title, &content).await +} + +#[tauri::command] +pub async fn revoke_published_note( + app: AppHandle, + state: State<'_, AppState>, + rel: String, +) -> AppResult<()> { + cloud::revoke(&app, &state.root()?, &rel).await +} + // ---- pins ---- #[tauri::command] diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 0ca2453..2b43c03 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -17,6 +17,12 @@ pub enum AppError { #[error("network error: {0}")] Network(String), #[error("{0}")] + Cloud(String), + #[error("{0}")] + CloudLoginRequired(String), + #[error("{0}")] + CloudSubscriptionRequired(String), + #[error("{0}")] Other(String), } @@ -38,6 +44,9 @@ impl Serialize for AppError { AppError::InvalidInput(_) => "invalid_input", AppError::Io(_) => "io", AppError::Network(_) => "network", + AppError::Cloud(_) => "cloud", + AppError::CloudLoginRequired(_) => "cloud_login_required", + AppError::CloudSubscriptionRequired(_) => "cloud_subscription_required", AppError::Other(_) => "other", }; ErrorPayload { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3eb8dbe..9cbee35 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,8 @@ mod assets; mod backlink_links; mod backlinks; mod bookmarks; +mod cloud; +mod cloud_metadata; mod commands; mod config; mod daily_notes; @@ -57,6 +59,10 @@ pub fn run() { commands::delete_entry, commands::search_notes, commands::backlinks_for, + commands::published_note_status, + commands::publish_note, + commands::update_published_note, + commands::revoke_published_note, commands::pins_list, commands::pin_note, commands::unpin_note, diff --git a/src-tauri/src/notes.rs b/src-tauri/src/notes.rs index 0ce2c77..c8aaa68 100644 --- a/src-tauri/src/notes.rs +++ b/src-tauri/src/notes.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use crate::error::{AppError, AppResult}; use crate::util::sanitize_name; use crate::vault::{notes_root, rel_of, resolve_rel}; -use crate::{backlinks, pins}; +use crate::{backlinks, cloud_metadata, pins}; const WELCOME: &str = r#"# Welcome to Markd @@ -126,6 +126,7 @@ pub fn rename_entry(root: &Path, rel: &str, new_name: &str) -> AppResult let next = rel_of(root, &target)?; backlinks::rewrite_links(root, rel, &next)?; pins::remap(root, rel, &next)?; + cloud_metadata::remap(root, rel, &next)?; Ok(next) } @@ -167,6 +168,7 @@ pub fn move_entry(root: &Path, rel: &str, target_dir_rel: &str) -> AppResult AppResult<()> { if !path.exists() { return Err(AppError::NotFound(rel.to_string())); } + if cloud_metadata::has_published_under(root, rel)? { + return Err(AppError::InvalidInput( + "stop publishing this note before deleting it".to_string(), + )); + } trash::delete(&path).map_err(|e| AppError::Other(format!("trash: {e}")))?; pins::remove_under(root, rel)?; Ok(()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1ed0bdb..5d329a7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -47,7 +47,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; font-src 'self'; img-src 'self' asset: http://asset.localhost data: https:; style-src 'self' 'unsafe-inline'", + "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost https://api.usemarkd.app; font-src 'self'; img-src 'self' asset: http://asset.localhost data: https:; style-src 'self' 'unsafe-inline'", "assetProtocol": { "enable": true, "scope": [] diff --git a/src/components/editor/NoteEditor.tsx b/src/components/editor/NoteEditor.tsx index 38a9d97..094e03a 100644 --- a/src/components/editor/NoteEditor.tsx +++ b/src/components/editor/NoteEditor.tsx @@ -30,6 +30,7 @@ import { createExtensions } from "./extensions"; import { insertImageFile } from "./insertImage"; import { NoteLinkPicker, type LinkPickerState } from "./NoteLinkPicker"; import { NoteProperties } from "./NoteProperties"; +import { PublishNoteModal } from "./PublishNoteModal"; import { SelectionMenu } from "./SelectionMenu"; import { SlashMenu, type SlashMenuState } from "./SlashMenu"; @@ -73,6 +74,10 @@ export const NoteEditor = memo(function NoteEditor({ const [propertyAddRequest, setPropertyAddRequest] = useState(0); const [rawText, setRawText] = useState(""); const [loadNonce, setLoadNonce] = useState(0); + const [publishSnapshot, setPublishSnapshot] = useState<{ + rel: string; + markdown: string; + } | null>(null); const relRef = useRef(rel); // Frontmatter of the loaded note, kept out of the editor and re-attached on @@ -584,6 +589,12 @@ export const NoteEditor = memo(function NoteEditor({ } else if (action === "export") { const path = await ipc.exportNote(relRef.current, markdown); if (path) toast("Note exported", { description: path }); + } else if (action === "publish") { + debouncedPersist.cancel(); + if (pending.current !== null) { + await persist(pending.current, relRef.current); + } + setPublishSnapshot({ rel: relRef.current, markdown }); } else if (action === "delete") { debouncedPersist.cancel(); if (pending.current !== null) { @@ -709,6 +720,14 @@ export const NoteEditor = memo(function NoteEditor({ {words} {words === 1 ? "word" : "words"} )} + {active && publishSnapshot && ( + setPublishSnapshot(null)} + /> + )} ); }); diff --git a/src/components/editor/PublishNoteModal.tsx b/src/components/editor/PublishNoteModal.tsx new file mode 100644 index 0000000..1552817 --- /dev/null +++ b/src/components/editor/PublishNoteModal.tsx @@ -0,0 +1,317 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; +import { + Check, + Copy, + ExternalLink, + Globe2, + LogIn, + RefreshCw, + Trash2, + X, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import { IpcError, ipc } from "@/lib/ipc"; +import type { PublishedShare } from "@/lib/types"; +import { noteTitle } from "@/lib/utils"; + +type BusyAction = "publish" | "update" | "revoke" | null; + +export function PublishNoteModal({ + open, + onClose, + rel, + markdown, +}: { + open: boolean; + onClose: () => void; + rel: string; + markdown: string; +}) { + const [title, setTitle] = useState(noteTitle(rel)); + const [share, setShare] = useState(null); + const [account, setAccount] = useState<{ email: string; plan: "free" | "cloud" } | null>(null); + const [outdated, setOutdated] = useState(false); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [confirmRevoke, setConfirmRevoke] = useState(false); + const [error, setError] = useState<{ kind: string; message: string } | null>(null); + + useEffect(() => { + if (!open) return; + let disposed = false; + setLoading(true); + setAccount(null); + setError(null); + setConfirmRevoke(false); + void ipc + .publishedNoteStatus(rel, markdown) + .then((status) => { + if (disposed) return; + setShare(status.share); + setAccount(status.account); + setOutdated(status.isOutdated); + setTitle(status.share?.title ?? noteTitle(rel)); + }) + .catch((cause) => { + if (disposed) return; + setError({ + kind: cause instanceof IpcError ? cause.kind : "other", + message: cause instanceof Error ? cause.message : String(cause), + }); + }) + .finally(() => { + if (!disposed) setLoading(false); + }); + return () => { + disposed = true; + }; + }, [open, rel, markdown]); + + const run = async (action: Exclude) => { + setBusy(action); + setError(null); + try { + if (action === "revoke") { + await ipc.revokePublishedNote(rel); + setShare(null); + setOutdated(false); + setConfirmRevoke(false); + toast("Note unpublished"); + return; + } + const next = + action === "publish" + ? await ipc.publishNote(rel, title.trim(), markdown) + : await ipc.updatePublishedNote(rel, title.trim(), markdown); + setShare(next); + setTitle(next.title); + setOutdated(false); + toast(action === "publish" ? "Note published" : "Published note updated"); + } catch (cause) { + setError({ + kind: cause instanceof IpcError ? cause.kind : "other", + message: cause instanceof Error ? cause.message : String(cause), + }); + } finally { + setBusy(null); + } + }; + + const copyLink = async () => { + if (!share) return; + await navigator.clipboard.writeText(share.url); + toast("Public link copied"); + }; + + return ( + !busy && onClose()} + ariaLabel="Publish note on the web" + className="w-[480px]" + > +
+
+ +
+
+

+ Publish on the web +

+

+ Create an unlisted public snapshot +

+
+ +
+ +
+ {loading ? ( +
+
+
+
+
+ ) : !account ? ( +
+
+ +
+

+ Sign in to publish +

+

+ Public pages belong to your Markd account, so you can update or remove + them from any signed-in device. +

+

+ Free accounts can publish one active note. +

+ {error && ( +
+ {error.message} +
+ )} +
+ + +
+
+ ) : ( +
+ + + {share ? ( +
+
+ + + + Published + {outdated && ( + + Local changes + + )} +
+

+ {share.url} +

+
+ + +
+
+ ) : ( +
+ Publishing uploads a separate snapshot. Future edits remain private until + you update the published version. +

+ {account.plan === "cloud" + ? `Signed in as ${account.email}` + : `Free account · one active public note · ${account.email}`} +

+
+ )} + + {error && ( +
+ {error.message} + {error.kind === "cloud_subscription_required" && ( +

+ Account creation and Markd Cloud checkout are the next step. +

+ )} +
+ )} + +
+ {share ? ( + <> + {!confirmRevoke ? ( + + ) : ( +
+ Remove the public page? + + +
+ )} + + + ) : ( + <> + + + + )} +
+
+ )} +
+ + ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index ab59512..5ed7b50 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -4,6 +4,7 @@ import { Copy, Download, FilePlus, + Globe2, ListPlus, MoreVertical, PanelLeft, @@ -239,6 +240,15 @@ export function AppShell() { }} />
+ { + setNoteMenuOpen(false); + dispatchNoteAction("publish"); + }} + /> +
("search_notes", { query, limit }), backlinksFor: (rel: string) => call("backlinks_for", { rel }), + publishedNoteStatus: (rel: string, content: string) => + call("published_note_status", { rel, content }), + publishNote: (rel: string, title: string, content: string) => + call("publish_note", { rel, title, content }), + updatePublishedNote: (rel: string, title: string, content: string) => + call("update_published_note", { rel, title, content }), + revokePublishedNote: (rel: string) => + call("revoke_published_note", { rel }), pinsList: () => call("pins_list"), pinNote: (rel: string) => call("pin_note", { rel }), unpinNote: (rel: string) => call("unpin_note", { rel }), diff --git a/src/lib/types.ts b/src/lib/types.ts index 8ab78fe..123459e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -52,6 +52,24 @@ export interface BacklinkMention { occurrence: number; } +export interface PublishedShare { + id: string; + entryId: string; + slug: string; + url: string; + title: string; + contentHash: string; + publishedAt: number; + updatedAt: number; +} + +export interface PublishedNoteStatus { + account: { email: string; plan: "free" | "cloud" } | null; + share: PublishedShare | null; + isOutdated: boolean; + freeShareLimit: number; +} + export type View = | { type: "note"; rel: string } | { type: "todos" } From ade6ab763dd80995cc51c07a155cf8bd3116f187 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Fri, 17 Jul 2026 00:37:56 +0530 Subject: [PATCH 2/4] feat: add cloud account settings --- src-tauri/src/cloud.rs | 15 ++ src-tauri/src/commands.rs | 5 + src-tauri/src/lib.rs | 1 + src/components/settings/SettingsModal.tsx | 11 +- src/components/settings/SettingsPanels.tsx | 172 ++++++++++++++++++++- src/lib/ipc.ts | 2 + src/lib/types.ts | 12 +- 7 files changed, 214 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs index 875f962..efd6ee3 100644 --- a/src-tauri/src/cloud.rs +++ b/src-tauri/src/cloud.rs @@ -10,6 +10,7 @@ use crate::cloud_metadata::{self, PublishedShare}; use crate::error::{AppError, AppResult}; const API_BASE: &str = "https://api.usemarkd.app"; +const LOGIN_URL: &str = "https://usemarkd.app/login?source=desktop"; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -53,6 +54,20 @@ pub struct CloudAccount { pub plan: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudAccountStatus { + pub account: Option, + pub login_url: &'static str, +} + +pub fn account_status(_app: &tauri::AppHandle) -> CloudAccountStatus { + CloudAccountStatus { + account: None, + login_url: LOGIN_URL, + } +} + fn access_token(_app: &tauri::AppHandle) -> AppResult { Err(AppError::CloudLoginRequired( "Sign in to Markd before publishing a note.".to_string(), diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a598177..b501d75 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -231,6 +231,11 @@ pub fn backlinks_for(state: State<'_, AppState>, rel: String) -> AppResult cloud::CloudAccountStatus { + cloud::account_status(&app) +} + #[tauri::command] pub fn published_note_status( app: AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9cbee35..c33f597 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,6 +59,7 @@ pub fn run() { commands::delete_entry, commands::search_notes, commands::backlinks_for, + commands::cloud_account_status, commands::published_note_status, commands::publish_note, commands::update_published_note, diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index af473d8..be1f894 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -1,4 +1,5 @@ import { + Cloud, Keyboard, Palette, Settings, @@ -10,6 +11,7 @@ import { AnimatePresence, motion } from "motion/react"; import { useState } from "react"; import { AppearanceSettings, + CloudSettings, GeneralSettings, ShortcutSettings, } from "@/components/settings/SettingsPanels"; @@ -18,7 +20,7 @@ import { EASE_OUT } from "@/lib/ease"; import { cx } from "@/lib/utils"; import { useUi } from "@/stores/ui"; -type SettingsPage = "general" | "appearance" | "shortcuts"; +type SettingsPage = "general" | "cloud" | "appearance" | "shortcuts"; const PAGES: Array<{ id: SettingsPage; @@ -32,6 +34,12 @@ const PAGES: Array<{ description: "Vault and updates", icon: SlidersHorizontal, }, + { + id: "cloud", + label: "Markd Cloud", + description: "Account, publishing, and sync", + icon: Cloud, + }, { id: "appearance", label: "Appearance", @@ -48,6 +56,7 @@ const PAGES: Array<{ const PAGE_CONTENT: Record = { general: GeneralSettings, + cloud: CloudSettings, appearance: AppearanceSettings, shortcuts: ShortcutSettings, }; diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx index be65b78..57cc0ef 100644 --- a/src/components/settings/SettingsPanels.tsx +++ b/src/components/settings/SettingsPanels.tsx @@ -1,7 +1,21 @@ -import { FolderOpen, Monitor, Moon, RefreshCw, Sun } from "lucide-react"; -import type { Theme } from "@/lib/types"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { + ArrowUpRight, + Check, + Cloud, + FolderOpen, + Globe2, + LogIn, + Monitor, + Moon, + RefreshCw, + Sun, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import type { CloudAccountStatus, Theme } from "@/lib/types"; import { cx, isMac } from "@/lib/utils"; import { Button } from "@/components/ui/Button"; +import { ipc } from "@/lib/ipc"; import { useUpdater } from "@/stores/updater"; import { useVault } from "@/stores/vault"; @@ -31,6 +45,8 @@ const THEMES: Array<{ }, ]; +const CLOUD_LOGIN_URL = "https://usemarkd.app/login?source=desktop"; + function shortcuts(mac: boolean): Array<{ label: string; keys: string[] }> { const mod = mac ? "⌘" : "Ctrl"; const shift = mac ? "⇧" : "Shift"; @@ -131,6 +147,116 @@ export function GeneralSettings() { ); } +export function CloudSettings() { + const [status, setStatus] = useState(null); + + useEffect(() => { + let disposed = false; + void ipc + .cloudAccountStatus() + .then((next) => { + if (!disposed) setStatus(next); + }) + .catch(() => { + if (!disposed) setStatus({ account: null, loginUrl: CLOUD_LOGIN_URL }); + }); + return () => { + disposed = true; + }; + }, []); + + const account = status?.account ?? null; + const loginUrl = status?.loginUrl ?? CLOUD_LOGIN_URL; + const loading = status === null; + + return ( +
+ +
+
+
+ {account ? ( + + {account.email.slice(0, 1)} + + ) : ( + + )} +
+
+
+

+ {loading ? "Checking account…" : (account?.email ?? "Not signed in")} +

+ {account && ( + + {account.plan === "cloud" ? "Cloud" : "Free"} + + )} +
+

+ {loading + ? "Looking for a Markd account on this device." + : account + ? "Your publishing identity is connected to this device." + : "Your local notes stay available without an account."} +

+
+ +
+
+
+ + +
+ + +
+

+ + Publishing is being built first. Sync will follow. +

+
+
+ ); +} + export function AppearanceSettings() { const theme = useVault((state) => state.theme); const setTheme = useVault((state) => state.setTheme); @@ -239,6 +365,48 @@ function SettingsGroup({ ); } +function PlanCard({ + name, + price, + description, + features, + active, +}: { + name: string; + price: string; + description: string; + features: string[]; + active: boolean; +}) { + return ( +
+ {active && ( + + Current + + )} +

{name}

+

+ {price} +

+

{description}

+
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ ); +} + function Kbd({ children }: { children: React.ReactNode }) { return ( diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 43256ca..b93cc94 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { BacklinkMention, + CloudAccountStatus, Bookmark, PublishedNoteStatus, PublishedShare, @@ -63,6 +64,7 @@ export const ipc = { call("search_notes", { query, limit }), backlinksFor: (rel: string) => call("backlinks_for", { rel }), + cloudAccountStatus: () => call("cloud_account_status"), publishedNoteStatus: (rel: string, content: string) => call("published_note_status", { rel, content }), publishNote: (rel: string, title: string, content: string) => diff --git a/src/lib/types.ts b/src/lib/types.ts index 123459e..5cd0e2e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -63,8 +63,18 @@ export interface PublishedShare { updatedAt: number; } +export interface CloudAccount { + email: string; + plan: "free" | "cloud"; +} + +export interface CloudAccountStatus { + account: CloudAccount | null; + loginUrl: string; +} + export interface PublishedNoteStatus { - account: { email: string; plan: "free" | "cloud" } | null; + account: CloudAccount | null; share: PublishedShare | null; isOutdated: boolean; freeShareLimit: number; From 2597849c76cd7d17ed7677695e5f6ba6b2eb7602 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Fri, 17 Jul 2026 13:52:11 +0530 Subject: [PATCH 3/4] feat: add email OTP authentication --- .../cloud-api/migrations/0002_email_otp.sql | 21 ++ services/cloud-api/src/auth.ts | 21 +- services/cloud-api/src/crypto.ts | 35 +- services/cloud-api/src/email.ts | 35 ++ services/cloud-api/src/http.ts | 2 +- services/cloud-api/src/index.ts | 12 + services/cloud-api/src/otp.ts | 232 ++++++++++++ services/cloud-api/src/types.ts | 2 + services/cloud-api/test/publishing.test.ts | 41 ++- services/cloud-api/wrangler.jsonc | 15 +- site/app/login/page.tsx | 8 +- src-tauri/Cargo.lock | 343 ++++++++++++++---- src-tauri/Cargo.toml | 1 + src-tauri/src/cloud.rs | 183 +++++++++- src-tauri/src/commands.rs | 20 +- src-tauri/src/lib.rs | 3 + src/components/editor/PublishNoteModal.tsx | 9 +- src/components/settings/CloudAccountCard.tsx | 295 +++++++++++++++ src/components/settings/SettingsModal.tsx | 8 +- src/components/settings/SettingsPanels.tsx | 85 +---- src/lib/ipc.ts | 7 + src/lib/types.ts | 8 +- src/stores/ui.ts | 8 + 23 files changed, 1205 insertions(+), 189 deletions(-) create mode 100644 services/cloud-api/migrations/0002_email_otp.sql create mode 100644 services/cloud-api/src/email.ts create mode 100644 services/cloud-api/src/otp.ts create mode 100644 src/components/settings/CloudAccountCard.tsx diff --git a/services/cloud-api/migrations/0002_email_otp.sql b/services/cloud-api/migrations/0002_email_otp.sql new file mode 100644 index 0000000..ffa25a1 --- /dev/null +++ b/services/cloud-api/migrations/0002_email_otp.sql @@ -0,0 +1,21 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE otp_challenges ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + code_hash TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER NOT NULL, + consumed_at INTEGER, + created_at INTEGER NOT NULL +) STRICT; + +CREATE INDEX otp_challenges_email_created + ON otp_challenges(email, created_at DESC); + +CREATE INDEX otp_challenges_fingerprint_created + ON otp_challenges(request_fingerprint, created_at DESC); + +CREATE INDEX otp_challenges_expiry + ON otp_challenges(expires_at); diff --git a/services/cloud-api/src/auth.ts b/services/cloud-api/src/auth.ts index bc594d7..06d3409 100644 --- a/services/cloud-api/src/auth.ts +++ b/services/cloud-api/src/auth.ts @@ -10,13 +10,18 @@ interface SessionRow { export class AuthenticationError extends Error {} +export function bearerToken(request: Request): string { + const authorization = request.headers.get("authorization") ?? ""; + const match = /^Bearer ([A-Za-z0-9_-]{32,256})$/.exec(authorization); + if (!match) throw new AuthenticationError(); + return match[1]; +} + export async function authenticatedUser( request: Request, env: Env, ): Promise { - const authorization = request.headers.get("authorization") ?? ""; - const match = /^Bearer ([A-Za-z0-9_-]{32,256})$/.exec(authorization); - if (!match) throw new AuthenticationError(); + const token = bearerToken(request); const row = await env.DB.prepare( `SELECT users.id AS user_id, users.email, entitlements.plan @@ -26,13 +31,21 @@ export async function authenticatedUser( AND entitlements.status IN ('active', 'trialing') WHERE sessions.token_hash = ? AND sessions.expires_at > ?`, ) - .bind(await sha256(match[1]), Date.now()) + .bind(await sha256(token), Date.now()) .first(); if (!row) throw new AuthenticationError(); return { id: row.user_id, email: row.email, plan: row.plan ?? "free" }; } +export async function revokeSession(request: Request, env: Env): Promise { + const token = bearerToken(request); + await env.DB.prepare("DELETE FROM sessions WHERE token_hash = ?") + .bind(await sha256(token)) + .run(); + return new Response(null, { status: 204 }); +} + export function authenticationRequired(): Response { return error(401, "login_required", "Sign in to Markd before publishing a note."); } diff --git a/services/cloud-api/src/crypto.ts b/services/cloud-api/src/crypto.ts index 302d8f9..2c50529 100644 --- a/services/cloud-api/src/crypto.ts +++ b/services/cloud-api/src/crypto.ts @@ -7,6 +7,39 @@ export async function sha256(value: string): Promise { ).join(""); } +export async function hmacSha256(secret: string, value: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value)); + return Array.from(new Uint8Array(signature), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +function randomBase64Url(byteLength: number): string { + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +export function randomOtp(): string { + const value = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000; + return value.toString().padStart(6, "0"); +} + +export function randomToken(): string { + return randomBase64Url(32); +} + export function randomSlug(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)); let binary = ""; @@ -17,6 +50,6 @@ export function randomSlug(): string { .replace(/=+$/, ""); } -export function newId(prefix: "share"): string { +export function newId(prefix: "share" | "otp" | "session" | "user"): string { return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`; } diff --git a/services/cloud-api/src/email.ts b/services/cloud-api/src/email.ts new file mode 100644 index 0000000..705851d --- /dev/null +++ b/services/cloud-api/src/email.ts @@ -0,0 +1,35 @@ +const OTP_SENDER = "no-reply@usemarkd.app"; + +export async function sendOtpEmail( + env: { EMAIL: SendEmail }, + email: string, + code: string, +): Promise { + await env.EMAIL.send({ + to: email, + from: { email: OTP_SENDER, name: "Markd" }, + subject: "Your Markd sign-in code", + text: [ + "Sign in to Markd", + "", + `Your verification code is: ${code}`, + "", + "This code expires in 5 minutes and can only be used once.", + "If you did not request this code, you can ignore this email.", + ].join("\n"), + html: ` + + +
+
+

Markd

+

Sign in to Markd

+

Enter this code in the Markd desktop app:

+
${code}
+

The code expires in 5 minutes and can only be used once. If you did not request it, you can safely ignore this email.

+
+
+ +`, + }); +} diff --git a/services/cloud-api/src/http.ts b/services/cloud-api/src/http.ts index 323adb5..49fd26d 100644 --- a/services/cloud-api/src/http.ts +++ b/services/cloud-api/src/http.ts @@ -30,7 +30,7 @@ export async function readJson( ): Promise { const buffer = await request.arrayBuffer(); if (buffer.byteLength > maxBytes) { - throw new RequestBodyError(413, "payload_too_large", "The note is too large to publish."); + throw new RequestBodyError(413, "payload_too_large", "The request body is too large."); } try { return JSON.parse(new TextDecoder().decode(buffer)); diff --git a/services/cloud-api/src/index.ts b/services/cloud-api/src/index.ts index a5844b8..818c99c 100644 --- a/services/cloud-api/src/index.ts +++ b/services/cloud-api/src/index.ts @@ -12,7 +12,9 @@ import { authenticatedUser, AuthenticationError, authenticationRequired, + revokeSession, } from "./auth"; +import { OtpError, requestOtp, verifyOtp } from "./otp"; const SHARE_ID = /^\/v1\/shares\/(share_[a-f0-9]{32})$/; const PUBLIC_SLUG = /^\/v1\/public\/shares\/([a-zA-Z0-9_-]{20,24})$/; @@ -27,6 +29,15 @@ async function route( if (request.method === "GET" && url.pathname === "/health") { return json({ ok: true }); } + if (request.method === "POST" && url.pathname === "/v1/auth/otp/request") { + return requestOtp(request, env); + } + if (request.method === "POST" && url.pathname === "/v1/auth/otp/verify") { + return verifyOtp(request, env); + } + if (request.method === "DELETE" && url.pathname === "/v1/session") { + return revokeSession(request, env); + } if (request.method === "GET" && url.pathname === "/v1/me") { const user = await authenticatedUser(request, env); return json({ user: { id: user.id, email: user.email, plan: user.plan } }); @@ -62,6 +73,7 @@ export default { return await route(request, env, ctx); } catch (cause) { if (cause instanceof AuthenticationError) return authenticationRequired(); + if (cause instanceof OtpError) return error(cause.status, cause.code, cause.message); if (cause instanceof ValidationError) { return error(400, cause.code, cause.message); } diff --git a/services/cloud-api/src/otp.ts b/services/cloud-api/src/otp.ts new file mode 100644 index 0000000..1923d1f --- /dev/null +++ b/services/cloud-api/src/otp.ts @@ -0,0 +1,232 @@ +import { hmacSha256, newId, randomOtp, randomToken, sha256 } from "./crypto"; +import { sendOtpEmail } from "./email"; +import { json, readJson } from "./http"; +import type { AccountPlan, Env } from "./types"; + +const OTP_TTL_MS = 5 * 60 * 1_000; +const RESEND_COOLDOWN_MS = 60 * 1_000; +const RATE_WINDOW_MS = 60 * 60 * 1_000; +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1_000; +const MAX_EMAIL_REQUESTS_PER_HOUR = 5; +const MAX_FINGERPRINT_REQUESTS_PER_HOUR = 20; +const MAX_ATTEMPTS = 5; +const MAX_AUTH_BODY_BYTES = 16 * 1_024; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const CHALLENGE_PATTERN = /^otp_[a-f0-9]{32}$/; +const OTP_PATTERN = /^\d{6}$/; + +interface ChallengeRow { + id: string; + email: string; + code_hash: string; + attempts: number; + expires_at: number; + consumed_at: number | null; + created_at: number; +} + +interface CountRow { + count: number; +} + +interface UserRow { + id: string; + email: string; + plan: AccountPlan | null; +} + +export class OtpError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +export function normalizeEmail(value: unknown): string { + const email = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (!email || email.length > 254 || !EMAIL_PATTERN.test(email)) { + throw new OtpError(400, "invalid_email", "Enter a valid email address."); + } + return email; +} + +function requestFingerprint(request: Request): string { + return ( + request.headers.get("cf-connecting-ip") ?? + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + "unknown" + ); +} + +async function otpHash(env: Env, challengeId: string, code: string): Promise { + if (!env.OTP_PEPPER || env.OTP_PEPPER.length < 32) { + throw new Error("OTP_PEPPER must be configured as a Worker secret"); + } + return hmacSha256(env.OTP_PEPPER, `${challengeId}:${code}`); +} + +function challengeResponse(challenge: ChallengeRow, now: number): Response { + return json({ + challengeId: challenge.id, + email: challenge.email, + expiresIn: Math.max(0, Math.ceil((challenge.expires_at - now) / 1_000)), + resendAfter: Math.max( + 0, + Math.ceil((challenge.created_at + RESEND_COOLDOWN_MS - now) / 1_000), + ), + }); +} + +export async function requestOtp(request: Request, env: Env): Promise { + const body = (await readJson(request, MAX_AUTH_BODY_BYTES)) as Record; + const email = normalizeEmail(body?.email); + const now = Date.now(); + const fingerprint = await hmacSha256(env.OTP_PEPPER, requestFingerprint(request)); + + const latest = await env.DB.prepare( + `SELECT id, email, code_hash, attempts, expires_at, consumed_at, created_at + FROM otp_challenges + WHERE email = ? AND consumed_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + ) + .bind(email) + .first(); + if (latest && latest.created_at + RESEND_COOLDOWN_MS > now && latest.expires_at > now) { + return challengeResponse(latest, now); + } + + const windowStart = now - RATE_WINDOW_MS; + const [emailCount, fingerprintCount] = await Promise.all([ + env.DB.prepare( + "SELECT COUNT(*) AS count FROM otp_challenges WHERE email = ? AND created_at >= ?", + ) + .bind(email, windowStart) + .first(), + env.DB.prepare( + "SELECT COUNT(*) AS count FROM otp_challenges WHERE request_fingerprint = ? AND created_at >= ?", + ) + .bind(fingerprint, windowStart) + .first(), + ]); + if ( + (emailCount?.count ?? 0) >= MAX_EMAIL_REQUESTS_PER_HOUR || + (fingerprintCount?.count ?? 0) >= MAX_FINGERPRINT_REQUESTS_PER_HOUR + ) { + throw new OtpError( + 429, + "otp_rate_limited", + "Too many sign-in codes were requested. Try again later.", + ); + } + + const id = newId("otp"); + const code = randomOtp(); + const challenge: ChallengeRow = { + id, + email, + code_hash: await otpHash(env, id, code), + attempts: 0, + expires_at: now + OTP_TTL_MS, + consumed_at: null, + created_at: now, + }; + await env.DB.prepare( + `INSERT INTO otp_challenges ( + id, email, code_hash, request_fingerprint, attempts, expires_at, created_at + ) VALUES (?, ?, ?, ?, 0, ?, ?)`, + ) + .bind(id, email, challenge.code_hash, fingerprint, challenge.expires_at, now) + .run(); + + try { + await sendOtpEmail(env, email, code); + } catch (cause) { + await env.DB.prepare("DELETE FROM otp_challenges WHERE id = ?").bind(id).run(); + throw cause; + } + return challengeResponse(challenge, now); +} + +function invalidOtp(): OtpError { + return new OtpError( + 400, + "invalid_otp", + "The code is incorrect or has expired. Request a new code and try again.", + ); +} + +export async function verifyOtp(request: Request, env: Env): Promise { + const body = (await readJson(request, MAX_AUTH_BODY_BYTES)) as Record; + const challengeId = typeof body?.challengeId === "string" ? body.challengeId : ""; + const code = typeof body?.code === "string" ? body.code.trim() : ""; + if (!CHALLENGE_PATTERN.test(challengeId) || !OTP_PATTERN.test(code)) throw invalidOtp(); + + const now = Date.now(); + const challenge = await env.DB.prepare( + `SELECT id, email, code_hash, attempts, expires_at, consumed_at, created_at + FROM otp_challenges WHERE id = ?`, + ) + .bind(challengeId) + .first(); + if ( + !challenge || + challenge.consumed_at !== null || + challenge.expires_at <= now || + challenge.attempts >= MAX_ATTEMPTS + ) { + throw invalidOtp(); + } + + const candidateHash = await otpHash(env, challengeId, code); + if (candidateHash !== challenge.code_hash) { + await env.DB.prepare( + "UPDATE otp_challenges SET attempts = attempts + 1 WHERE id = ? AND consumed_at IS NULL", + ) + .bind(challengeId) + .run(); + throw invalidOtp(); + } + + await env.DB.prepare( + "INSERT OR IGNORE INTO users (id, email, created_at) VALUES (?, ?, ?)", + ) + .bind(newId("user"), challenge.email, now) + .run(); + const user = await env.DB.prepare( + `SELECT users.id, users.email, entitlements.plan + FROM users + LEFT JOIN entitlements ON entitlements.user_id = users.id + AND entitlements.status IN ('active', 'trialing') + WHERE users.email = ?`, + ) + .bind(challenge.email) + .first(); + if (!user) throw new Error("OTP verification could not create an account"); + + const accessToken = randomToken(); + const sessionId = newId("session"); + const expiresAt = now + SESSION_TTL_MS; + const consumed = await env.DB.prepare( + `UPDATE otp_challenges SET consumed_at = ? + WHERE id = ? AND consumed_at IS NULL AND expires_at > ? AND attempts < ?`, + ) + .bind(now, challengeId, now, MAX_ATTEMPTS) + .run(); + if (consumed.meta.changes !== 1) throw invalidOtp(); + await env.DB.prepare( + `INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .bind(sessionId, user.id, await sha256(accessToken), expiresAt, now) + .run(); + + return json({ + accessToken, + expiresAt, + user: { id: user.id, email: user.email, plan: user.plan ?? "free" }, + }); +} diff --git a/services/cloud-api/src/types.ts b/services/cloud-api/src/types.ts index d5f9246..8dc5327 100644 --- a/services/cloud-api/src/types.ts +++ b/services/cloud-api/src/types.ts @@ -1,7 +1,9 @@ export interface Env { DB: D1Database; PUBLISHED_NOTES: R2Bucket; + EMAIL: SendEmail; PUBLIC_SITE_ORIGIN: string; + OTP_PEPPER: string; } export interface PublishInput { diff --git a/services/cloud-api/test/publishing.test.ts b/services/cloud-api/test/publishing.test.ts index 614c878..dd279f2 100644 --- a/services/cloud-api/test/publishing.test.ts +++ b/services/cloud-api/test/publishing.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { randomSlug, sha256 } from "../src/crypto"; +import { hmacSha256, randomOtp, randomSlug, randomToken, sha256 } from "../src/crypto"; +import { sendOtpEmail } from "../src/email"; +import { normalizeEmail, OtpError } from "../src/otp"; import { idempotencyKey, publishInput, @@ -53,3 +55,40 @@ describe("publishing identifiers", () => { ); }); }); + +describe("email OTP authentication", () => { + test("normalizes email addresses and rejects malformed input", () => { + expect(normalizeEmail(" Person@Example.COM ")).toBe("person@example.com"); + expect(() => normalizeEmail("not-an-email")).toThrow(OtpError); + }); + + test("generates six-digit codes and opaque session tokens", () => { + expect(randomOtp()).toMatch(/^\d{6}$/); + expect(randomToken()).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + test("uses keyed hashes for OTP challenges", async () => { + const first = await hmacSha256("a".repeat(32), "otp_test:123456"); + const second = await hmacSha256("b".repeat(32), "otp_test:123456"); + expect(first).not.toBe(second); + expect(first).toHaveLength(64); + }); + + test("sends login codes from the configured Markd sender", async () => { + const messages: EmailMessageBuilder[] = []; + const EMAIL = { + async send(next: EmailMessageBuilder) { + messages.push(next); + return { messageId: "test" } as EmailSendResult; + }, + } as SendEmail; + + await sendOtpEmail({ EMAIL }, "person@example.com", "123456"); + const message = messages[0]; + expect(message.from).toEqual({ email: "no-reply@usemarkd.app", name: "Markd" }); + expect(message.to).toBe("person@example.com"); + expect(message.subject).toBe("Your Markd sign-in code"); + expect(message.text).toContain("123456"); + expect(message.text).toContain("expires in 5 minutes"); + }); +}); diff --git a/services/cloud-api/wrangler.jsonc b/services/cloud-api/wrangler.jsonc index 605deb5..f296d12 100644 --- a/services/cloud-api/wrangler.jsonc +++ b/services/cloud-api/wrangler.jsonc @@ -3,23 +3,34 @@ "name": "markd-cloud-api", "main": "src/index.ts", "compatibility_date": "2026-07-16", - "workers_dev": true, + "workers_dev": false, "observability": { "enabled": true }, "vars": { "PUBLIC_SITE_ORIGIN": "https://usemarkd.app" }, + "secrets": { + "required": ["OTP_PEPPER"] + }, + "send_email": [ + { + "name": "EMAIL", + "allowed_sender_addresses": ["no-reply@usemarkd.app"] + } + ], "d1_databases": [ { "binding": "DB", "database_name": "markd-cloud", + "database_id": "015f6402-fda5-43c2-8cbd-b86711368448", "migrations_dir": "migrations" } ], "r2_buckets": [ { - "binding": "PUBLISHED_NOTES" + "binding": "PUBLISHED_NOTES", + "bucket_name": "markd-published-notes" } ] } diff --git a/site/app/login/page.tsx b/site/app/login/page.tsx index ac4361b..10653a1 100644 --- a/site/app/login/page.tsx +++ b/site/app/login/page.tsx @@ -11,12 +11,12 @@ export default function LoginPage() { Sign in to publish

- Account sign-in is being connected to the desktop app. Publishing stays - disabled until a verified Markd session is available. + Open Markd Settings, choose Markd Cloud, and enter your email. We’ll send + a six-digit code to finish signing in securely.

- Free accounts will include one active public note. Markd Cloud accounts - will include unlimited publishing and cross-device sync. + Free accounts include one active public note. Markd Cloud accounts include + unlimited publishing and cross-device sync.
{ markdown: &'a str, } +#[derive(Debug, Serialize)] +struct OtpRequest<'a> { + email: &'a str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct OtpVerifyRequest<'a> { + challenge_id: &'a str, + code: &'a str, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OtpChallenge { + pub challenge_id: String, + pub email: String, + pub expires_in: u64, + pub resend_after: u64, +} + #[derive(Debug, Deserialize)] struct ShareEnvelope { share: PublishedShare, @@ -58,20 +81,82 @@ pub struct CloudAccount { #[serde(rename_all = "camelCase")] pub struct CloudAccountStatus { pub account: Option, - pub login_url: &'static str, } -pub fn account_status(_app: &tauri::AppHandle) -> CloudAccountStatus { - CloudAccountStatus { - account: None, - login_url: LOGIN_URL, +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct StoredSession { + access_token: String, + expires_at: u64, + account: CloudAccount, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionResponse { + access_token: String, + expires_at: u64, + user: CloudAccount, +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn keyring_entry() -> AppResult { + Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) + .map_err(|error| AppError::Cloud(format!("could not open secure account storage: {error}"))) +} + +fn load_session() -> AppResult> { + let entry = keyring_entry()?; + let value = match entry.get_password() { + Ok(value) => value, + Err(keyring::Error::NoEntry) => return Ok(None), + Err(error) => { + return Err(AppError::Cloud(format!( + "could not read the saved Markd account: {error}" + ))) + } + }; + let session: StoredSession = serde_json::from_str(&value)?; + if session.expires_at <= now_millis() { + let _ = entry.delete_credential(); + return Ok(None); + } + Ok(Some(session)) +} + +fn save_session(session: &StoredSession) -> AppResult<()> { + keyring_entry()? + .set_password(&serde_json::to_string(session)?) + .map_err(|error| AppError::Cloud(format!("could not save the Markd account: {error}"))) +} + +fn clear_session() -> AppResult<()> { + match keyring_entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(AppError::Cloud(format!( + "could not remove the saved Markd account: {error}" + ))), } } +pub fn account_status(_app: &tauri::AppHandle) -> AppResult { + Ok(CloudAccountStatus { + account: load_session()?.map(|session| session.account), + }) +} + fn access_token(_app: &tauri::AppHandle) -> AppResult { - Err(AppError::CloudLoginRequired( - "Sign in to Markd before publishing a note.".to_string(), - )) + load_session()? + .map(|session| session.access_token) + .ok_or_else(|| { + AppError::CloudLoginRequired("Sign in to Markd before publishing a note.".to_string()) + }) } fn client() -> AppResult { @@ -90,6 +175,82 @@ fn request_key() -> String { format!("publish_{}", Uuid::new_v4().simple()) } +async fn cloud_error(response: reqwest::Response) -> AppError { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let error = serde_json::from_str::(&body) + .map(|envelope| envelope.error) + .unwrap_or(CloudError { + code: "cloud_request_failed".to_string(), + message: format!("Markd Cloud returned {status}"), + share: None, + }); + if status == StatusCode::UNAUTHORIZED { + AppError::CloudLoginRequired(error.message) + } else if status == StatusCode::PAYMENT_REQUIRED { + AppError::CloudSubscriptionRequired(error.message) + } else { + AppError::Cloud(error.message) + } +} + +pub async fn request_otp(email: &str) -> AppResult { + let response = client()? + .post(format!("{API_BASE}/v1/auth/otp/request")) + .json(&OtpRequest { email }) + .send() + .await + .map_err(|error| AppError::Network(error.to_string()))?; + if !response.status().is_success() { + return Err(cloud_error(response).await); + } + response + .json::() + .await + .map_err(|error| AppError::Cloud(format!("invalid OTP response: {error}"))) +} + +pub async fn verify_otp(challenge_id: &str, code: &str) -> AppResult { + let response = client()? + .post(format!("{API_BASE}/v1/auth/otp/verify")) + .json(&OtpVerifyRequest { challenge_id, code }) + .send() + .await + .map_err(|error| AppError::Network(error.to_string()))?; + if !response.status().is_success() { + return Err(cloud_error(response).await); + } + let response = response + .json::() + .await + .map_err(|error| AppError::Cloud(format!("invalid session response: {error}")))?; + let session = StoredSession { + access_token: response.access_token, + expires_at: response.expires_at, + account: response.user, + }; + save_session(&session)?; + Ok(session.account) +} + +pub async fn sign_out(app: &tauri::AppHandle) -> AppResult<()> { + let token = access_token(app).ok(); + clear_session()?; + if let Some(token) = token { + tauri::async_runtime::spawn(async move { + let Ok(client) = client() else { + return; + }; + let _ = client + .delete(format!("{API_BASE}/v1/session")) + .bearer_auth(token) + .send() + .await; + }); + } + Ok(()) +} + async fn parse_response(response: reqwest::Response) -> AppResult { let status = response.status(); let body = response @@ -134,7 +295,7 @@ pub fn status( .as_ref() .is_some_and(|published| published.content_hash != content_hash(content)); Ok(PublishedNoteStatus { - account: None, + account: load_session()?.map(|session| session.account), share, is_outdated, free_share_limit: 1, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b501d75..ed894c2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -232,10 +232,28 @@ pub fn backlinks_for(state: State<'_, AppState>, rel: String) -> AppResult cloud::CloudAccountStatus { +pub fn cloud_account_status(app: AppHandle) -> AppResult { cloud::account_status(&app) } +#[tauri::command] +pub async fn cloud_request_otp(email: String) -> AppResult { + cloud::request_otp(&email).await +} + +#[tauri::command] +pub async fn cloud_verify_otp( + challenge_id: String, + code: String, +) -> AppResult { + cloud::verify_otp(&challenge_id, &code).await +} + +#[tauri::command] +pub async fn cloud_sign_out(app: AppHandle) -> AppResult<()> { + cloud::sign_out(&app).await +} + #[tauri::command] pub fn published_note_status( app: AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c33f597..3c62b1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -60,6 +60,9 @@ pub fn run() { commands::search_notes, commands::backlinks_for, commands::cloud_account_status, + commands::cloud_request_otp, + commands::cloud_verify_otp, + commands::cloud_sign_out, commands::published_note_status, commands::publish_note, commands::update_published_note, diff --git a/src/components/editor/PublishNoteModal.tsx b/src/components/editor/PublishNoteModal.tsx index 1552817..c6971ce 100644 --- a/src/components/editor/PublishNoteModal.tsx +++ b/src/components/editor/PublishNoteModal.tsx @@ -16,6 +16,7 @@ import { Modal } from "@/components/ui/Modal"; import { IpcError, ipc } from "@/lib/ipc"; import type { PublishedShare } from "@/lib/types"; import { noteTitle } from "@/lib/utils"; +import { useUi } from "@/stores/ui"; type BusyAction = "publish" | "update" | "revoke" | null; @@ -38,6 +39,7 @@ export function PublishNoteModal({ const [busy, setBusy] = useState(null); const [confirmRevoke, setConfirmRevoke] = useState(false); const [error, setError] = useState<{ kind: string; message: string } | null>(null); + const openSettings = useUi((state) => state.openSettings); useEffect(() => { if (!open) return; @@ -170,10 +172,13 @@ export function PublishNoteModal({
diff --git a/src/components/settings/CloudAccountCard.tsx b/src/components/settings/CloudAccountCard.tsx new file mode 100644 index 0000000..f9efd0a --- /dev/null +++ b/src/components/settings/CloudAccountCard.tsx @@ -0,0 +1,295 @@ +import { Check, Cloud, LogIn, LogOut, Mail, RotateCcw } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/Button"; +import { IpcError, ipc } from "@/lib/ipc"; +import type { CloudAccount, OtpChallenge } from "@/lib/types"; + +type Step = "account" | "email" | "code"; + +export function CloudAccountCard({ + onAccountChange, +}: { + onAccountChange: (account: CloudAccount | null) => void; +}) { + const [account, setAccount] = useState(null); + const [step, setStep] = useState("account"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [challenge, setChallenge] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [resendIn, setResendIn] = useState(0); + + const updateAccount = (next: CloudAccount | null) => { + setAccount(next); + onAccountChange(next); + }; + + useEffect(() => { + let disposed = false; + void ipc + .cloudAccountStatus() + .then((status) => { + if (!disposed) updateAccount(status.account); + }) + .catch((cause) => { + if (!disposed) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }) + .finally(() => { + if (!disposed) setLoading(false); + }); + return () => { + disposed = true; + }; + }, []); + + useEffect(() => { + if (resendIn <= 0) return; + const timer = window.setInterval(() => { + setResendIn((value) => Math.max(0, value - 1)); + }, 1_000); + return () => window.clearInterval(timer); + }, [resendIn]); + + const message = (cause: unknown) => + cause instanceof IpcError + ? cause.message + : cause instanceof Error + ? cause.message + : String(cause); + + const requestCode = async () => { + setBusy(true); + setError(null); + try { + const next = await ipc.cloudRequestOtp(email.trim()); + setChallenge(next); + setEmail(next.email); + setCode(""); + setResendIn(next.resendAfter); + setStep("code"); + } catch (cause) { + setError(message(cause)); + } finally { + setBusy(false); + } + }; + + const verifyCode = async () => { + if (!challenge) return; + setBusy(true); + setError(null); + try { + const next = await ipc.cloudVerifyOtp(challenge.challengeId, code); + updateAccount(next); + setStep("account"); + setChallenge(null); + setCode(""); + } catch (cause) { + setError(message(cause)); + } finally { + setBusy(false); + } + }; + + const signOut = async () => { + setBusy(true); + setError(null); + try { + await ipc.cloudSignOut(); + updateAccount(null); + setEmail(""); + } catch (cause) { + setError(message(cause)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ {step === "account" && ( +
+
+ {account ? ( + + {account.email.slice(0, 1)} + + ) : ( + + )} +
+
+
+

+ {loading ? "Checking account…" : (account?.email ?? "Not signed in")} +

+ {account && ( + + {account.plan === "cloud" ? "Cloud" : "Free"} + + )} +
+

+ {account + ? "Publishing is connected to this Markd account." + : "Your local notes stay available without an account."} +

+
+ {account ? ( + + ) : ( + + )} +
+ )} + + {step === "email" && ( +
{ + event.preventDefault(); + void requestCode(); + }} + > +
+ + + +
+

Sign in with email

+

+ We’ll send a six-digit code. No password needed. +

+
+
+ +
+ + +
+
+ )} + + {step === "code" && challenge && ( +
{ + event.preventDefault(); + void verifyCode(); + }} + > +
+ + + +
+

Check your email

+

+ Code sent to {challenge.email} +

+
+
+ +
+ + + +
+
+ )} + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index be1f894..d618acf 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -8,7 +8,6 @@ import { type LucideIcon, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; -import { useState } from "react"; import { AppearanceSettings, CloudSettings, @@ -18,9 +17,7 @@ import { import { Modal } from "@/components/ui/Modal"; import { EASE_OUT } from "@/lib/ease"; import { cx } from "@/lib/utils"; -import { useUi } from "@/stores/ui"; - -type SettingsPage = "general" | "cloud" | "appearance" | "shortcuts"; +import { useUi, type SettingsPage } from "@/stores/ui"; const PAGES: Array<{ id: SettingsPage; @@ -64,7 +61,8 @@ const PAGE_CONTENT: Record = { export function SettingsModal() { const open = useUi((state) => state.settingsOpen); const setOpen = useUi((state) => state.setSettingsOpen); - const [page, setPage] = useState("general"); + const page = useUi((state) => state.settingsPage); + const setPage = useUi((state) => state.setSettingsPage); const current = PAGES.find((item) => item.id === page) ?? PAGES[0]; const PageContent = PAGE_CONTENT[page]; diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx index 57cc0ef..8718916 100644 --- a/src/components/settings/SettingsPanels.tsx +++ b/src/components/settings/SettingsPanels.tsx @@ -1,21 +1,17 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; import { - ArrowUpRight, Check, - Cloud, FolderOpen, Globe2, - LogIn, Monitor, Moon, RefreshCw, Sun, } from "lucide-react"; -import { useEffect, useState } from "react"; -import type { CloudAccountStatus, Theme } from "@/lib/types"; +import { useState } from "react"; +import type { CloudAccount, Theme } from "@/lib/types"; import { cx, isMac } from "@/lib/utils"; import { Button } from "@/components/ui/Button"; -import { ipc } from "@/lib/ipc"; +import { CloudAccountCard } from "@/components/settings/CloudAccountCard"; import { useUpdater } from "@/stores/updater"; import { useVault } from "@/stores/vault"; @@ -45,8 +41,6 @@ const THEMES: Array<{ }, ]; -const CLOUD_LOGIN_URL = "https://usemarkd.app/login?source=desktop"; - function shortcuts(mac: boolean): Array<{ label: string; keys: string[] }> { const mod = mac ? "⌘" : "Ctrl"; const shift = mac ? "⇧" : "Shift"; @@ -148,26 +142,7 @@ export function GeneralSettings() { } export function CloudSettings() { - const [status, setStatus] = useState(null); - - useEffect(() => { - let disposed = false; - void ipc - .cloudAccountStatus() - .then((next) => { - if (!disposed) setStatus(next); - }) - .catch(() => { - if (!disposed) setStatus({ account: null, loginUrl: CLOUD_LOGIN_URL }); - }); - return () => { - disposed = true; - }; - }, []); - - const account = status?.account ?? null; - const loginUrl = status?.loginUrl ?? CLOUD_LOGIN_URL; - const loading = status === null; + const [account, setAccount] = useState(null); return (
@@ -175,57 +150,7 @@ export function CloudSettings() { title="Account" description="Sign in once to manage public pages and future synced devices." > -
-
-
- {account ? ( - - {account.email.slice(0, 1)} - - ) : ( - - )} -
-
-
-

- {loading ? "Checking account…" : (account?.email ?? "Not signed in")} -

- {account && ( - - {account.plan === "cloud" ? "Cloud" : "Free"} - - )} -
-

- {loading - ? "Looking for a Markd account on this device." - : account - ? "Your publishing identity is connected to this device." - : "Your local notes stay available without an account."} -

-
- -
-
+ call("backlinks_for", { rel }), cloudAccountStatus: () => call("cloud_account_status"), + cloudRequestOtp: (email: string) => + call("cloud_request_otp", { email }), + cloudVerifyOtp: (challengeId: string, code: string) => + call("cloud_verify_otp", { challengeId, code }), + cloudSignOut: () => call("cloud_sign_out"), publishedNoteStatus: (rel: string, content: string) => call("published_note_status", { rel, content }), publishNote: (rel: string, title: string, content: string) => diff --git a/src/lib/types.ts b/src/lib/types.ts index 5cd0e2e..ce07dbf 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -70,7 +70,13 @@ export interface CloudAccount { export interface CloudAccountStatus { account: CloudAccount | null; - loginUrl: string; +} + +export interface OtpChallenge { + challengeId: string; + email: string; + expiresIn: number; + resendAfter: number; } export interface PublishedNoteStatus { diff --git a/src/stores/ui.ts b/src/stores/ui.ts index 5b6f696..b4c341e 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -1,16 +1,20 @@ import { create } from "zustand"; type SaveState = "idle" | "saving" | "error"; +export type SettingsPage = "general" | "cloud" | "appearance" | "shortcuts"; interface UiState { paletteOpen: boolean; settingsOpen: boolean; + settingsPage: SettingsPage; sidebarHidden: boolean; backlinksHidden: boolean; markdownSource: boolean; saveState: SaveState; setPaletteOpen: (open: boolean) => void; setSettingsOpen: (open: boolean) => void; + openSettings: (page?: SettingsPage) => void; + setSettingsPage: (page: SettingsPage) => void; toggleSidebar: () => void; toggleBacklinks: () => void; toggleMarkdownSource: () => void; @@ -20,12 +24,16 @@ interface UiState { export const useUi = create((set, get) => ({ paletteOpen: false, settingsOpen: false, + settingsPage: "general", sidebarHidden: false, backlinksHidden: true, markdownSource: false, saveState: "idle", setPaletteOpen: (paletteOpen) => set({ paletteOpen }), setSettingsOpen: (settingsOpen) => set({ settingsOpen }), + openSettings: (settingsPage = "general") => + set({ settingsOpen: true, settingsPage }), + setSettingsPage: (settingsPage) => set({ settingsPage }), toggleSidebar: () => set({ sidebarHidden: !get().sidebarHidden }), toggleBacklinks: () => set({ backlinksHidden: !get().backlinksHidden }), toggleMarkdownSource: () => From 48fda46eb57ffcf4d2c75c6c6469fd267f399af1 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Fri, 17 Jul 2026 13:54:52 +0530 Subject: [PATCH 4/4] fix: align Cloudflare worker name --- services/cloud-api/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/cloud-api/wrangler.jsonc b/services/cloud-api/wrangler.jsonc index f296d12..5237bc6 100644 --- a/services/cloud-api/wrangler.jsonc +++ b/services/cloud-api/wrangler.jsonc @@ -1,6 +1,6 @@ { "$schema": "./node_modules/wrangler/config-schema.json", - "name": "markd-cloud-api", + "name": "markd-api", "main": "src/index.ts", "compatibility_date": "2026-07-16", "workers_dev": false,