From ab508d827db9e3c063fc9cfa25c9d3613fa18fa0 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 16 Mar 2026 23:03:27 +1300 Subject: [PATCH] Implementation, thanks amp --- app/.gitignore | 23 ++ app/.npmrc | 1 + app/.vscode/extensions.json | 3 + app/README.md | 42 +++ app/bun.lock | 326 +++++++++++++++++++ app/package.json | 29 ++ app/src/app.css | 17 + app/src/app.d.ts | 13 + app/src/app.html | 11 + app/src/lib/assets/favicon.svg | 1 + app/src/lib/crypto.ts | 91 ++++++ app/src/lib/db.ts | 86 +++++ app/src/lib/index.ts | 1 + app/src/lib/peer.ts | 174 ++++++++++ app/src/lib/permissions.ts | 39 +++ app/src/lib/poll-client.ts | 105 ++++++ app/src/lib/poll-host.ts | 157 +++++++++ app/src/lib/snapshot.ts | 44 +++ app/src/lib/stores/polls.svelte.ts | 157 +++++++++ app/src/lib/stores/profile.svelte.ts | 62 ++++ app/src/lib/types.ts | 91 ++++++ app/src/routes/+layout.svelte | 9 + app/src/routes/+page.svelte | 23 ++ app/src/routes/app/+layout.svelte | 57 ++++ app/src/routes/app/create/+page.svelte | 138 ++++++++ app/src/routes/app/poll/[id]/+page.svelte | 273 ++++++++++++++++ app/src/routes/app/polls/+page.svelte | 73 +++++ app/src/routes/app/profile/+page.svelte | 127 ++++++++ app/src/routes/embed/[id]/+page.svelte | 85 +++++ app/src/routes/p/[id]/+page.svelte | 170 ++++++++++ app/static/robots.txt | 3 + app/svelte.config.js | 17 + app/tsconfig.json | 20 ++ app/vite.config.ts | 7 + server/index.ts | 146 +++++++++ server/package.json | 10 + specs/001-project-setup/README.md | 12 +- specs/002-p2p-networking/README.md | 11 +- specs/003-user-identity-profiles/README.md | 11 +- specs/004-poll-data-model/README.md | 11 +- specs/005-poll-creation-management/README.md | 11 +- specs/008-voting-system/README.md | 11 +- specs/009-public-sharing/README.md | 11 +- specs/010-embeddable-widget/README.md | 11 +- specs/012-lightweight-server/README.md | 11 +- 45 files changed, 2705 insertions(+), 26 deletions(-) create mode 100644 app/.gitignore create mode 100644 app/.npmrc create mode 100644 app/.vscode/extensions.json create mode 100644 app/README.md create mode 100644 app/bun.lock create mode 100644 app/package.json create mode 100644 app/src/app.css create mode 100644 app/src/app.d.ts create mode 100644 app/src/app.html create mode 100644 app/src/lib/assets/favicon.svg create mode 100644 app/src/lib/crypto.ts create mode 100644 app/src/lib/db.ts create mode 100644 app/src/lib/index.ts create mode 100644 app/src/lib/peer.ts create mode 100644 app/src/lib/permissions.ts create mode 100644 app/src/lib/poll-client.ts create mode 100644 app/src/lib/poll-host.ts create mode 100644 app/src/lib/snapshot.ts create mode 100644 app/src/lib/stores/polls.svelte.ts create mode 100644 app/src/lib/stores/profile.svelte.ts create mode 100644 app/src/lib/types.ts create mode 100644 app/src/routes/+layout.svelte create mode 100644 app/src/routes/+page.svelte create mode 100644 app/src/routes/app/+layout.svelte create mode 100644 app/src/routes/app/create/+page.svelte create mode 100644 app/src/routes/app/poll/[id]/+page.svelte create mode 100644 app/src/routes/app/polls/+page.svelte create mode 100644 app/src/routes/app/profile/+page.svelte create mode 100644 app/src/routes/embed/[id]/+page.svelte create mode 100644 app/src/routes/p/[id]/+page.svelte create mode 100644 app/static/robots.txt create mode 100644 app/svelte.config.js create mode 100644 app/tsconfig.json create mode 100644 app/vite.config.ts create mode 100644 server/index.ts create mode 100644 server/package.json diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/app/.npmrc b/app/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/app/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/app/.vscode/extensions.json b/app/.vscode/extensions.json new file mode 100644 index 0000000..28d1e67 --- /dev/null +++ b/app/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..e49a5b5 --- /dev/null +++ b/app/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.12.7 create --template minimal --types ts --no-install app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/app/bun.lock b/app/bun.lock new file mode 100644 index 0000000..e105ae7 --- /dev/null +++ b/app/bun.lock @@ -0,0 +1,326 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "idb-keyval": "^6.2.2", + "peerjs": "^1.5.5", + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.1", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@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.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@msgpack/msgpack": ["@msgpack/msgpack@2.8.0", "", {}, "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + + "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.55.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], + + "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "peerjs": ["peerjs@1.5.5", "", { "dependencies": { "@msgpack/msgpack": "^2.8.0", "eventemitter3": "^4.0.7", "peerjs-js-binarypack": "^2.1.0", "webrtc-adapter": "^9.0.0" } }, "sha512-viMUCPDL6CSfOu0ZqVcFqbWRXNHIbv2lPqNbrBIjbFYrflebOjItJ4hPfhjnuUCstqciHVu9vVJ7jFqqKi/EuQ=="], + + "peerjs-js-binarypack": ["peerjs-js-binarypack@2.1.0", "", {}, "sha512-YIwCC+pTzp3Bi8jPI9UFKO0t0SLo6xALnHkiNt/iUFmUUZG0fEEmEyFKvjsDKweiFitzHRyhuh6NvyJZ4nNxMg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "sdp": ["sdp@3.2.1", "", {}, "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw=="], + + "set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "svelte": ["svelte@5.53.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA=="], + + "svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="], + + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], + + "webrtc-adapter": ["webrtc-adapter@9.0.4", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..a38d39c --- /dev/null +++ b/app/package.json @@ -0,0 +1,29 @@ +{ + "name": "app", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.1", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "idb-keyval": "^6.2.2", + "peerjs": "^1.5.5" + } +} diff --git a/app/src/app.css b/app/src/app.css new file mode 100644 index 0000000..98a58f8 --- /dev/null +++ b/app/src/app.css @@ -0,0 +1,17 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, sans-serif; + --color-primary: #6366f1; + --color-primary-hover: #4f46e5; + --color-surface: #ffffff; + --color-surface-dark: #0f172a; + --color-muted: #64748b; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + } +} diff --git a/app/src/app.d.ts b/app/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/app/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/app/src/app.html b/app/src/app.html new file mode 100644 index 0000000..f273cc5 --- /dev/null +++ b/app/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/app/src/lib/assets/favicon.svg b/app/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/app/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/app/src/lib/crypto.ts b/app/src/lib/crypto.ts new file mode 100644 index 0000000..f76d508 --- /dev/null +++ b/app/src/lib/crypto.ts @@ -0,0 +1,91 @@ +import { loadKeypair, saveKeypair, loadPublicKeyRaw, savePublicKeyRaw } from './db.js'; + +const ALGO = { name: 'Ed25519' } as const; + +let cachedKeypair: CryptoKeyPair | undefined; +let cachedPublicKeyRaw: Uint8Array | undefined; + +export async function getOrCreateKeypair(): Promise { + if (cachedKeypair) return cachedKeypair; + + const existing = await loadKeypair(); + if (existing) { + cachedKeypair = existing; + cachedPublicKeyRaw = await loadPublicKeyRaw() ?? undefined; + return existing; + } + + const keypair = await crypto.subtle.generateKey(ALGO, false, ['sign', 'verify']); + const raw = new Uint8Array(await crypto.subtle.exportKey('raw', keypair.publicKey)); + + await saveKeypair(keypair); + await savePublicKeyRaw(raw); + + cachedKeypair = keypair; + cachedPublicKeyRaw = raw; + return keypair; +} + +export async function getPublicKeyRaw(): Promise { + if (cachedPublicKeyRaw) return cachedPublicKeyRaw; + await getOrCreateKeypair(); + return cachedPublicKeyRaw!; +} + +export async function getUserId(): Promise { + const raw = await getPublicKeyRaw(); + return base58Encode(raw); +} + +export async function sign(data: string): Promise { + const keypair = await getOrCreateKeypair(); + const encoded = new TextEncoder().encode(data); + const sig = await crypto.subtle.sign(ALGO, keypair.privateKey, encoded); + return base64Encode(new Uint8Array(sig)); +} + +export async function verify(data: string, signature: string, publicKeyRaw: Uint8Array): Promise { + try { + const key = await crypto.subtle.importKey('raw', publicKeyRaw as unknown as ArrayBuffer, ALGO, false, ['verify']); + const sig = base64Decode(signature); + const encoded = new TextEncoder().encode(data); + return crypto.subtle.verify(ALGO, key, sig as unknown as ArrayBuffer, encoded as unknown as ArrayBuffer); + } catch { + return false; + } +} + +// --- Base58 (Bitcoin-style) --- + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +export function base58Encode(bytes: Uint8Array): string { + let num = 0n; + for (const b of bytes) num = num * 256n + BigInt(b); + + let result = ''; + while (num > 0n) { + result = BASE58_ALPHABET[Number(num % 58n)] + result; + num = num / 58n; + } + + for (const b of bytes) { + if (b !== 0) break; + result = '1' + result; + } + + return result || '1'; +} + +function base64Encode(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +function base64Decode(str: string): Uint8Array { + const binary = atob(str); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} diff --git a/app/src/lib/db.ts b/app/src/lib/db.ts new file mode 100644 index 0000000..61e5150 --- /dev/null +++ b/app/src/lib/db.ts @@ -0,0 +1,86 @@ +import { get, set, del, keys, createStore } from 'idb-keyval'; +import type { Poll, UserProfile } from './types.js'; + +const profileStore = createStore('evocracy-profiles', 'profiles'); +const pollStore = createStore('evocracy-polls', 'polls'); +const outboxStore = createStore('evocracy-outbox', 'outbox'); +const metaStore = createStore('evocracy-meta', 'meta'); + +// --- Profile --- + +export async function loadProfile(): Promise { + return get('self', profileStore); +} + +export async function saveProfile(profile: UserProfile): Promise { + await set('self', profile, profileStore); +} + +// --- Keypair --- + +export async function loadKeypair(): Promise { + return get('keypair', metaStore); +} + +export async function saveKeypair(keypair: CryptoKeyPair): Promise { + await set('keypair', keypair, metaStore); +} + +export async function loadPublicKeyRaw(): Promise { + return get('publicKeyRaw', metaStore); +} + +export async function savePublicKeyRaw(raw: Uint8Array): Promise { + await set('publicKeyRaw', raw, metaStore); +} + +// --- Polls --- + +export async function loadPoll(id: string): Promise { + return get(id, pollStore); +} + +export async function savePoll(poll: Poll): Promise { + await set(poll.id, poll, pollStore); +} + +export async function deletePoll(id: string): Promise { + await del(id, pollStore); +} + +export async function loadAllPolls(): Promise { + const allKeys = await keys(pollStore); + const polls: Poll[] = []; + for (const key of allKeys) { + const poll = await get(key, pollStore); + if (poll) polls.push(poll); + } + return polls; +} + +// --- Outbox --- + +export interface OutboxEntry { + commandId: string; + pollId: string; + message: unknown; + createdAt: number; +} + +export async function addToOutbox(entry: OutboxEntry): Promise { + await set(entry.commandId, entry, outboxStore); +} + +export async function removeFromOutbox(commandId: string): Promise { + await del(commandId, outboxStore); +} + +export async function getOutboxEntries(): Promise { + const allKeys = await keys(outboxStore); + const entries: OutboxEntry[] = []; + for (const key of allKeys) { + const entry = await get(key, outboxStore); + if (entry) entries.push(entry); + } + return entries; +} diff --git a/app/src/lib/index.ts b/app/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/app/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/app/src/lib/peer.ts b/app/src/lib/peer.ts new file mode 100644 index 0000000..d12022d --- /dev/null +++ b/app/src/lib/peer.ts @@ -0,0 +1,174 @@ +import Peer, { type DataConnection } from 'peerjs'; +import type { Message, Poll } from './types.js'; +import { getUserId } from './crypto.js'; +import { addToOutbox, removeFromOutbox, getOutboxEntries } from './db.js'; + +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +type MessageHandler = (msg: Message, peerId: string) => void; +type StateHandler = (state: ConnectionState) => void; + +let peer: Peer | null = null; +const connections = new Map(); +const messageHandlers = new Set(); +const stateHandlers = new Set(); +let currentState: ConnectionState = 'disconnected'; + +function setState(state: ConnectionState) { + currentState = state; + stateHandlers.forEach((h) => h(state)); +} + +export function getConnectionState(): ConnectionState { + return currentState; +} + +export function onMessage(handler: MessageHandler): () => void { + messageHandlers.add(handler); + return () => messageHandlers.delete(handler); +} + +export function onStateChange(handler: StateHandler): () => void { + stateHandlers.add(handler); + return () => stateHandlers.delete(handler); +} + +export function getConnectedPeers(): string[] { + return Array.from(connections.keys()); +} + +export async function initPeer(): Promise { + if (peer && !peer.destroyed) return peer; + + const userId = await getUserId(); + setState('connecting'); + + return new Promise((resolve, reject) => { + const p = new Peer(userId, { debug: 1 }); + + p.on('open', () => { + peer = p; + setState('connected'); + resolve(p); + }); + + p.on('connection', (conn) => { + setupConnection(conn); + }); + + p.on('error', (err) => { + console.error('[peer] error:', err); + setState('error'); + reject(err); + }); + + p.on('disconnected', () => { + setState('disconnected'); + // Auto-reconnect + setTimeout(() => { + if (p && !p.destroyed) p.reconnect(); + }, 2000); + }); + }); +} + +function setupConnection(conn: DataConnection) { + conn.on('open', () => { + connections.set(conn.peer, conn); + }); + + conn.on('data', (data) => { + const msg = data as Message; + messageHandlers.forEach((h) => h(msg, conn.peer)); + }); + + conn.on('close', () => { + connections.delete(conn.peer); + }); + + conn.on('error', (err) => { + console.error(`[peer] connection error with ${conn.peer}:`, err); + connections.delete(conn.peer); + }); +} + +export async function connectToPeer(peerId: string): Promise { + const p = await initPeer(); + + const existing = connections.get(peerId); + if (existing && existing.open) return existing; + + return new Promise((resolve, reject) => { + const conn = p.connect(peerId, { reliable: true }); + + conn.on('open', () => { + connections.set(peerId, conn); + // Flush outbox for this peer + flushOutbox(peerId); + resolve(conn); + }); + + conn.on('error', (err) => { + reject(err); + }); + + setupConnection(conn); + }); +} + +export function sendToPeer(peerId: string, msg: Message): boolean { + const conn = connections.get(peerId); + if (!conn || !conn.open) return false; + conn.send(msg); + return true; +} + +export function broadcast(msg: Message, exclude?: string): void { + for (const [peerId, conn] of connections) { + if (peerId === exclude) continue; + if (conn.open) conn.send(msg); + } +} + +export async function sendWithOutbox( + peerId: string, + msg: Message & { commandId: string }, + pollId: string +): Promise { + await addToOutbox({ + commandId: msg.commandId, + pollId, + message: msg, + createdAt: Date.now() + }); + + sendToPeer(peerId, msg); +} + +export function acknowledgeCommand(commandId: string): void { + removeFromOutbox(commandId); +} + +async function flushOutbox(peerId: string): Promise { + const entries = await getOutboxEntries(); + for (const entry of entries) { + sendToPeer(peerId, entry.message as Message); + } +} + +export function disconnectPeer(peerId: string): void { + const conn = connections.get(peerId); + if (conn) { + conn.close(); + connections.delete(peerId); + } +} + +export function destroyPeer(): void { + if (peer) { + peer.destroy(); + peer = null; + } + connections.clear(); + setState('disconnected'); +} diff --git a/app/src/lib/permissions.ts b/app/src/lib/permissions.ts new file mode 100644 index 0000000..f1a046e --- /dev/null +++ b/app/src/lib/permissions.ts @@ -0,0 +1,39 @@ +import type { Poll, PollAction, Role } from './types.js'; + +const ROLE_PERMISSIONS: Record> = { + viewer: new Set(['view']), + participant: new Set(['view', 'vote', 'addOption']), + moderator: new Set(['view', 'vote', 'addOption', 'manageUsers', 'managePoll']), + owner: new Set(['view', 'vote', 'addOption', 'manageUsers', 'managePoll', 'deletePoll']) +}; + +export function getRole(poll: Poll, userId: string): Role | 'owner' { + if (poll.ownerId === userId) return 'owner'; + const assignment = poll.roles.find((r) => r.userId === userId); + return assignment?.role ?? 'viewer'; +} + +export function can(poll: Poll, userId: string, action: PollAction): boolean { + const role = getRole(poll, userId); + return ROLE_PERMISSIONS[role].has(action); +} + +export function canVote(poll: Poll, userId: string): boolean { + return poll.status === 'open' && can(poll, userId, 'vote'); +} + +export function canAddOption(poll: Poll, userId: string): boolean { + return poll.status === 'open' && can(poll, userId, 'addOption'); +} + +export function canManageUsers(poll: Poll, userId: string): boolean { + return can(poll, userId, 'manageUsers'); +} + +export function canManagePoll(poll: Poll, userId: string): boolean { + return can(poll, userId, 'managePoll'); +} + +export function canDeletePoll(poll: Poll, userId: string): boolean { + return can(poll, userId, 'deletePoll'); +} diff --git a/app/src/lib/poll-client.ts b/app/src/lib/poll-client.ts new file mode 100644 index 0000000..7d8d5e9 --- /dev/null +++ b/app/src/lib/poll-client.ts @@ -0,0 +1,105 @@ +import { connectToPeer, sendToPeer, sendWithOutbox, onMessage, acknowledgeCommand } from './peer.js'; +import { updatePollInStore } from './stores/polls.svelte.js'; +import type { Message, Poll, PollOption, Vote } from './types.js'; + +const pendingCallbacks = new Map void; reject: (err: Error) => void }>(); + +let clientUnsub: (() => void) | null = null; + +export function startClient(): () => void { + if (clientUnsub) return clientUnsub; + + const unsub = onMessage(async (msg: Message) => { + switch (msg.type) { + case 'poll:state': { + await updatePollInStore(msg.payload); + break; + } + + case 'ack': { + acknowledgeCommand(msg.payload.commandId); + const cb = pendingCallbacks.get(msg.payload.commandId); + if (cb) { + cb.resolve(); + pendingCallbacks.delete(msg.payload.commandId); + } + break; + } + + case 'error': { + const cb = pendingCallbacks.get(msg.payload.commandId); + if (cb) { + cb.reject(new Error(msg.payload.message)); + pendingCallbacks.delete(msg.payload.commandId); + } + break; + } + } + }); + + clientUnsub = () => { + unsub(); + clientUnsub = null; + }; + return clientUnsub; +} + +export async function joinPoll(ownerPeerId: string, pollId: string): Promise { + await connectToPeer(ownerPeerId); + sendToPeer(ownerPeerId, { type: 'sync:request', payload: { pollId } }); +} + +export async function submitVote(ownerPeerId: string, pollId: string, optionId: string, anonymous: boolean): Promise { + const commandId = crypto.randomUUID(); + const vote: Vote = { + optionId, + voterId: anonymous ? null : undefined as unknown as string, // owner will set + timestamp: Date.now() + }; + + const msg: Message = { + type: 'poll:vote', + commandId, + payload: { ...vote, pollId } + }; + + await sendWithOutbox(ownerPeerId, msg as Message & { commandId: string }, pollId); + + return new Promise((resolve, reject) => { + pendingCallbacks.set(commandId, { resolve, reject }); + setTimeout(() => { + if (pendingCallbacks.has(commandId)) { + pendingCallbacks.delete(commandId); + reject(new Error('Vote timed out')); + } + }, 10000); + }); +} + +export async function submitOption(ownerPeerId: string, pollId: string, text: string): Promise { + const commandId = crypto.randomUUID(); + const option: PollOption = { + id: crypto.randomUUID(), + text, + addedBy: '', + addedAt: Date.now() + }; + + const msg: Message = { + type: 'poll:option:add', + commandId, + payload: { ...option, pollId } + }; + + await sendWithOutbox(ownerPeerId, msg as Message & { commandId: string }, pollId); + + return new Promise((resolve, reject) => { + pendingCallbacks.set(commandId, { resolve, reject }); + setTimeout(() => { + if (pendingCallbacks.has(commandId)) { + pendingCallbacks.delete(commandId); + reject(new Error('Add option timed out')); + } + }, 10000); + }); +} diff --git a/app/src/lib/poll-host.ts b/app/src/lib/poll-host.ts new file mode 100644 index 0000000..b4e9ca3 --- /dev/null +++ b/app/src/lib/poll-host.ts @@ -0,0 +1,157 @@ +import { onMessage, sendToPeer, broadcast } from './peer.js'; +import { getUserId } from './crypto.js'; +import { getPollById, updatePollInStore, setRole, removeRole, setPollStatus } from './stores/polls.svelte.js'; +import { canVote, canAddOption, canManageUsers, canManagePoll } from './permissions.js'; +import type { Message, Poll, PollOption, Vote } from './types.js'; + +const processedCommands = new Set(); +let revision = 0; + +export function startHosting(): () => void { + const unsub = onMessage(async (msg, peerId) => { + const userId = await getUserId(); + + switch (msg.type) { + case 'sync:request': { + const poll = getPollById(msg.payload.pollId); + if (poll && poll.ownerId === userId) { + sendToPeer(peerId, { type: 'poll:state', payload: poll }); + } + break; + } + + case 'poll:vote': { + if (processedCommands.has(msg.commandId)) { + sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } }); + break; + } + + const poll = getPollById(msg.payload.pollId); + if (!poll || poll.ownerId !== userId) break; + + if (!canVote(poll, peerId)) { + sendToPeer(peerId, { + type: 'error', + payload: { commandId: msg.commandId, message: 'Not authorized to vote' } + }); + break; + } + + const vote: Vote = { + optionId: msg.payload.optionId, + voterId: poll.anonymous ? null : peerId, + timestamp: msg.payload.timestamp + }; + + const filteredVotes = poll.anonymous + ? poll.votes + : poll.votes.filter((v) => v.voterId !== peerId); + + const updated: Poll = { ...poll, votes: [...filteredVotes, vote] }; + await updatePollInStore(updated); + revision++; + processedCommands.add(msg.commandId); + + sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } }); + broadcast({ type: 'poll:state', payload: updated }, peerId); + break; + } + + case 'poll:option:add': { + if (processedCommands.has(msg.commandId)) { + sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } }); + break; + } + + const poll2 = getPollById(msg.payload.pollId); + if (!poll2 || poll2.ownerId !== userId) break; + + if (!canAddOption(poll2, peerId)) { + sendToPeer(peerId, { + type: 'error', + payload: { commandId: msg.commandId, message: 'Not authorized to add options' } + }); + break; + } + + const option: PollOption = { + id: msg.payload.id, + text: msg.payload.text, + addedBy: peerId, + addedAt: msg.payload.addedAt + }; + + const updated2: Poll = { ...poll2, options: [...poll2.options, option] }; + await updatePollInStore(updated2); + revision++; + processedCommands.add(msg.commandId); + + sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } }); + broadcast({ type: 'poll:state', payload: updated2 }, peerId); + break; + } + + case 'poll:role:update': { + if (processedCommands.has(msg.commandId)) { + sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } }); + break; + } + + const poll3 = getPollById(msg.payload.pollId); + if (!poll3 || poll3.ownerId !== userId) break; + + if (!canManageUsers(poll3, peerId)) { + sendToPeer(peerId, { + type: 'error', + payload: { commandId: msg.commandId, message: 'Not authorized to manage users' } + }); + break; + } + + await setRole(msg.payload.pollId, msg.payload.userId, msg.payload.role); + revision++; + processedCommands.add(msg.commandId); + + const refreshed = getPollById(msg.payload.pollId); + sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } }); + if (refreshed) broadcast({ type: 'poll:state', payload: refreshed }, peerId); + break; + } + + case 'poll:status:update': { + if (processedCommands.has(msg.commandId)) { + sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } }); + break; + } + + const poll4 = getPollById(msg.payload.pollId); + if (!poll4 || poll4.ownerId !== userId) break; + + if (!canManagePoll(poll4, peerId)) { + sendToPeer(peerId, { + type: 'error', + payload: { commandId: msg.commandId, message: 'Not authorized to manage poll' } + }); + break; + } + + await setPollStatus(msg.payload.pollId, msg.payload.status); + revision++; + processedCommands.add(msg.commandId); + + const refreshed2 = getPollById(msg.payload.pollId); + sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } }); + if (refreshed2) broadcast({ type: 'poll:state', payload: refreshed2 }, peerId); + break; + } + + case 'user:profile': { + // Store peer's profile for display purposes + // Could be extended to a peer profile cache + break; + } + } + }); + + return unsub; +} diff --git a/app/src/lib/snapshot.ts b/app/src/lib/snapshot.ts new file mode 100644 index 0000000..2e0051c --- /dev/null +++ b/app/src/lib/snapshot.ts @@ -0,0 +1,44 @@ +import { getUserId } from './crypto.js'; +import type { Poll, PollSnapshot } from './types.js'; + +const SNAPSHOT_API = typeof import.meta !== 'undefined' + ? (import.meta.env?.VITE_SNAPSHOT_API || '') + : ''; + +export async function pushSnapshot(poll: Poll): Promise { + if (!SNAPSHOT_API) return false; + if (poll.visibility !== 'public') return false; + + const userId = await getUserId(); + if (poll.ownerId !== userId) return false; + + const voteCounts: Record = {}; + for (const opt of poll.options) voteCounts[opt.id] = 0; + for (const vote of poll.votes) voteCounts[vote.optionId] = (voteCounts[vote.optionId] || 0) + 1; + + const snapshot: PollSnapshot & { signature: string } = { + pollId: poll.id, + ownerId: poll.ownerId, + ownerPeerId: userId, // peerId === userId in our design + title: poll.title, + description: poll.description, + options: poll.options.map((o) => ({ id: o.id, text: o.text })), + voteCounts, + totalVotes: poll.votes.length, + status: poll.status, + anonymous: poll.anonymous, + updatedAt: Date.now(), + signature: '' // TODO: sign with Ed25519 + }; + + try { + const res = await fetch(`${SNAPSHOT_API}/api/polls/${poll.id}/snapshot`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(snapshot) + }); + return res.ok; + } catch { + return false; + } +} diff --git a/app/src/lib/stores/polls.svelte.ts b/app/src/lib/stores/polls.svelte.ts new file mode 100644 index 0000000..0fbf14e --- /dev/null +++ b/app/src/lib/stores/polls.svelte.ts @@ -0,0 +1,157 @@ +import { loadAllPolls, savePoll, deletePoll as dbDeletePoll, loadPoll } from '$lib/db.js'; +import type { Poll, PollOption, Vote, RoleAssignment } from '$lib/types.js'; +import { getUserId } from '$lib/crypto.js'; + +let polls = $state([]); +let loading = $state(true); + +export function getPolls() { + return { + get all() { return polls; }, + get loading() { return loading; }, + get owned() { + const id = currentUserId; + return id ? polls.filter((p) => p.ownerId === id) : []; + }, + get participating() { + const id = currentUserId; + return id ? polls.filter((p) => p.ownerId !== id) : []; + } + }; +} + +let currentUserId: string | null = null; + +export async function initPolls(): Promise { + loading = true; + currentUserId = await getUserId(); + polls = await loadAllPolls(); + loading = false; +} + +export async function createPoll(data: { + title: string; + description: string; + anonymous: boolean; + visibility: Poll['visibility']; + options?: string[]; +}): Promise { + const userId = await getUserId(); + const now = Date.now(); + + const poll: Poll = { + id: crypto.randomUUID(), + ownerId: userId, + title: data.title, + description: data.description, + anonymous: data.anonymous, + status: 'draft', + visibility: data.visibility, + createdAt: now, + options: (data.options ?? []).map((text) => ({ + id: crypto.randomUUID(), + text, + addedBy: userId, + addedAt: now + })), + votes: [], + roles: [] + }; + + await savePoll(poll); + polls = [...polls, poll]; + return poll; +} + +export async function updatePollInStore(poll: Poll): Promise { + await savePoll(poll); + polls = polls.map((p) => (p.id === poll.id ? poll : p)); +} + +export function getPollById(id: string): Poll | undefined { + return polls.find((p) => p.id === id); +} + +export async function refreshPoll(id: string): Promise { + const poll = await loadPoll(id); + if (poll) { + polls = polls.map((p) => (p.id === id ? poll : p)); + if (!polls.find((p) => p.id === id)) { + polls = [...polls, poll]; + } + } + return poll; +} + +export async function deletePollFromStore(id: string): Promise { + await dbDeletePoll(id); + polls = polls.filter((p) => p.id !== id); +} + +export async function addOptionToPoll(pollId: string, text: string): Promise { + const poll = getPollById(pollId); + if (!poll || poll.status !== 'open') return null; + + const userId = await getUserId(); + const option: PollOption = { + id: crypto.randomUUID(), + text, + addedBy: userId, + addedAt: Date.now() + }; + + const updated = { ...poll, options: [...poll.options, option] }; + await updatePollInStore(updated); + return option; +} + +export async function castVote(pollId: string, optionId: string): Promise { + const poll = getPollById(pollId); + if (!poll || poll.status !== 'open') return null; + + const userId = await getUserId(); + const vote: Vote = { + optionId, + voterId: poll.anonymous ? null : userId, + timestamp: Date.now() + }; + + // Remove previous vote by this user (for vote changes) + const filteredVotes = poll.anonymous + ? poll.votes // Can't deduplicate anonymous votes by voterId + : poll.votes.filter((v) => v.voterId !== userId); + + const updated = { ...poll, votes: [...filteredVotes, vote] }; + await updatePollInStore(updated); + return vote; +} + +export async function setPollStatus(pollId: string, status: Poll['status']): Promise { + const poll = getPollById(pollId); + if (!poll) return; + + const updated = { + ...poll, + status, + ...(status === 'closed' ? { closedAt: Date.now() } : {}) + }; + await updatePollInStore(updated); +} + +export async function setRole(pollId: string, userId: string, role: RoleAssignment['role']): Promise { + const poll = getPollById(pollId); + if (!poll) return; + + const roles = poll.roles.filter((r) => r.userId !== userId); + roles.push({ userId, role }); + const updated = { ...poll, roles }; + await updatePollInStore(updated); +} + +export async function removeRole(pollId: string, userId: string): Promise { + const poll = getPollById(pollId); + if (!poll) return; + + const updated = { ...poll, roles: poll.roles.filter((r) => r.userId !== userId) }; + await updatePollInStore(updated); +} diff --git a/app/src/lib/stores/profile.svelte.ts b/app/src/lib/stores/profile.svelte.ts new file mode 100644 index 0000000..abb2d32 --- /dev/null +++ b/app/src/lib/stores/profile.svelte.ts @@ -0,0 +1,62 @@ +import { loadProfile, saveProfile } from '$lib/db.js'; +import { getUserId } from '$lib/crypto.js'; +import type { Tag, UserProfile } from '$lib/types.js'; + +let profile = $state(null); +let loading = $state(true); + +export function getProfile() { + return { + get current() { return profile; }, + get loading() { return loading; } + }; +} + +export async function initProfile(): Promise { + loading = true; + const existing = await loadProfile(); + if (existing) { + profile = existing; + loading = false; + return existing; + } + + const userId = await getUserId(); + const newProfile: UserProfile = { + id: userId, + name: '', + bio: '', + tags: [], + updatedAt: Date.now(), + signature: '' + }; + + await saveProfile(newProfile); + profile = newProfile; + loading = false; + return newProfile; +} + +export async function updateProfile(updates: Partial>): Promise { + if (!profile) return; + + profile = { + ...profile, + ...updates, + updatedAt: Date.now() + }; + + await saveProfile(profile); +} + +export async function addTag(tag: Tag): Promise { + if (!profile) return; + const tags = [...profile.tags, tag]; + await updateProfile({ tags }); +} + +export async function removeTag(index: number): Promise { + if (!profile) return; + const tags = profile.tags.filter((_, i) => i !== index); + await updateProfile({ tags }); +} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts new file mode 100644 index 0000000..58605bd --- /dev/null +++ b/app/src/lib/types.ts @@ -0,0 +1,91 @@ +// === User Identity === + +export interface Tag { + category: 'location' | 'interest' | 'expertise' | (string & {}); + value: string; +} + +export interface UserProfile { + id: string; + name: string; + bio: string; + tags: Tag[]; + updatedAt: number; + signature: string; +} + +// === Poll === + +export interface Poll { + id: string; + ownerId: string; + title: string; + description: string; + anonymous: boolean; + status: 'draft' | 'open' | 'closed'; + visibility: 'private' | 'link' | 'public'; + createdAt: number; + closedAt?: number; + options: PollOption[]; + votes: Vote[]; + roles: RoleAssignment[]; +} + +export interface PollOption { + id: string; + text: string; + addedBy: string; + addedAt: number; +} + +export interface Vote { + optionId: string; + voterId: string | null; + timestamp: number; +} + +export type Role = 'viewer' | 'participant' | 'moderator'; + +export interface RoleAssignment { + userId: string; + role: Role; +} + +// === Permissions === + +export type PollAction = + | 'view' + | 'vote' + | 'addOption' + | 'manageUsers' + | 'managePoll' + | 'deletePoll'; + +// === P2P Messages === + +export type Message = + | { type: 'poll:state'; commandId?: string; payload: Poll } + | { type: 'poll:vote'; commandId: string; payload: Vote & { pollId: string } } + | { type: 'poll:option:add'; commandId: string; payload: PollOption & { pollId: string } } + | { type: 'poll:role:update'; commandId: string; payload: RoleAssignment & { pollId: string } } + | { type: 'poll:status:update'; commandId: string; payload: { pollId: string; status: Poll['status'] } } + | { type: 'user:profile'; payload: UserProfile } + | { type: 'ack'; payload: { commandId: string; revision: number } } + | { type: 'error'; payload: { commandId: string; message: string } } + | { type: 'sync:request'; payload: { pollId: string } }; + +// === Snapshot (server) === + +export interface PollSnapshot { + pollId: string; + ownerId: string; + ownerPeerId: string; + title: string; + description: string; + options: { id: string; text: string }[]; + voteCounts: Record; + totalVotes: number; + status: Poll['status']; + anonymous: boolean; + updatedAt: number; +} diff --git a/app/src/routes/+layout.svelte b/app/src/routes/+layout.svelte new file mode 100644 index 0000000..4a3df1a --- /dev/null +++ b/app/src/routes/+layout.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children()} +
diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte new file mode 100644 index 0000000..68bd35e --- /dev/null +++ b/app/src/routes/+page.svelte @@ -0,0 +1,23 @@ + + + + evocracy + + +
+
+

evocracy

+

Decentralized polling, peer-to-peer.

+ + +
+
diff --git a/app/src/routes/app/+layout.svelte b/app/src/routes/app/+layout.svelte new file mode 100644 index 0000000..37bb84f --- /dev/null +++ b/app/src/routes/app/+layout.svelte @@ -0,0 +1,57 @@ + + +
+ {#if !initialized} +
+

Loading...

+
+ {:else} +
+ {@render children()} +
+ {/if} + + + +
diff --git a/app/src/routes/app/create/+page.svelte b/app/src/routes/app/create/+page.svelte new file mode 100644 index 0000000..db02ee1 --- /dev/null +++ b/app/src/routes/app/create/+page.svelte @@ -0,0 +1,138 @@ + + + + Create Poll – evocracy + + +
+

Create Poll

+ +
+
+ + +
+ +
+ + +
+ +
+ Options +
+ {#each optionTexts as _, i} +
+ + {#if optionTexts.length > 2} + + {/if} +
+ {/each} +
+ +
+ +
+
+
Anonymous voting
+
Voter identities won't be recorded
+
+ +
+ +
+ + +
+ + +
+
diff --git a/app/src/routes/app/poll/[id]/+page.svelte b/app/src/routes/app/poll/[id]/+page.svelte new file mode 100644 index 0000000..1ff077b --- /dev/null +++ b/app/src/routes/app/poll/[id]/+page.svelte @@ -0,0 +1,273 @@ + + + + {poll?.title ?? 'Poll'} – evocracy + + +
+ {#if !poll} +

Poll not found

+ ← Back to polls + {:else} + +
+ ← Back +

{poll.title}

+ {#if poll.description} +

{poll.description}

+ {/if} +
+ + {poll.status === 'draft' ? '📝 Draft' : poll.status === 'open' ? '🟢 Open' : '🔴 Closed'} + + + {poll.anonymous ? '🔒 Anonymous' : '👤 Named'} + + + {myRole} + +
+
+ + +
+ + +
+ + +
+

+ Results ({totalVotes} vote{totalVotes !== 1 ? 's' : ''}) +

+ {#each poll.options as option} + {@const counts = voteCounts(poll)} + {@const count = counts[option.id] || 0} + {@const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0} + {@const isMyVote = userVote?.optionId === option.id} + + + {/each} +
+ + + {#if canAddOption(poll, userId)} +
{ e.preventDefault(); handleAddOption(); }} class="mb-4 flex gap-2"> + + +
+ {/if} + + + {#if canManagePoll(poll, userId)} +
+ + + {#if showManage} +
+ +
+ {#if poll.status === 'draft'} + + {/if} + {#if poll.status === 'open'} + + {/if} + {#if poll.status === 'closed'} + + {/if} +
+ + + {#if canManageUsers(poll, userId)} +
+

Participants ({poll.roles.length})

+ {#each poll.roles as role} +
+ {role.userId.slice(0, 12)}... +
+ {role.role} + +
+
+ {/each} + +
{ e.preventDefault(); handleAddUser(); }} class="mt-2 flex gap-2"> + + + +
+
+ {/if} + + + {#if canDeletePoll(poll, userId)} + + {/if} +
+ {/if} +
+ {/if} + {/if} +
diff --git a/app/src/routes/app/polls/+page.svelte b/app/src/routes/app/polls/+page.svelte new file mode 100644 index 0000000..ed826e8 --- /dev/null +++ b/app/src/routes/app/polls/+page.svelte @@ -0,0 +1,73 @@ + + + + My Polls – evocracy + + +
+

My Polls

+ + {#if polls.loading} +

Loading...

+ {:else if polls.all.length === 0} +
+

No polls yet

+ + Create your first poll + +
+ {:else} + {#if polls.owned.length > 0} +

Owned

+ + {/if} + + {#if polls.participating.length > 0} +

Participating

+ + {/if} + {/if} +
diff --git a/app/src/routes/app/profile/+page.svelte b/app/src/routes/app/profile/+page.svelte new file mode 100644 index 0000000..60ac87f --- /dev/null +++ b/app/src/routes/app/profile/+page.svelte @@ -0,0 +1,127 @@ + + + + Profile – evocracy + + +
+

Profile

+ + {#if profile.loading} +

Loading...

+ {:else} +
+
Your Peer ID
+
{userId}
+
+ +
{ e.preventDefault(); handleSave(); }} class="flex flex-col gap-4"> +
+ + +
+ +
+ + +
+ + +
+ + +
+

Tags

+ + {#if profile.current?.tags.length} +
+ {#each profile.current.tags as tag, i} + + {tag.category}: + {tag.value} + + + {/each} +
+ {/if} + +
{ e.preventDefault(); handleAddTag(); }} class="flex gap-2"> + + + +
+
+ {/if} +
diff --git a/app/src/routes/embed/[id]/+page.svelte b/app/src/routes/embed/[id]/+page.svelte new file mode 100644 index 0000000..6bc5b43 --- /dev/null +++ b/app/src/routes/embed/[id]/+page.svelte @@ -0,0 +1,85 @@ + + + + + + +
+ {#if loading} +

Loading...

+ {:else if !snapshot} +

Poll unavailable

+ {:else} +
{snapshot.title}
+
+ {totalVotes} vote{totalVotes !== 1 ? 's' : ''} + · {snapshot.status === 'open' ? '🟢 Open' : '🔴 Closed'} +
+
+ {#each options as option} + {@const count = counts[option.id] || 0} + {@const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0} +
+
+ {option.text} + {pct.toFixed(0)}% +
+
+
+
+
+ {/each} +
+ + {/if} +
diff --git a/app/src/routes/p/[id]/+page.svelte b/app/src/routes/p/[id]/+page.svelte new file mode 100644 index 0000000..c5716f2 --- /dev/null +++ b/app/src/routes/p/[id]/+page.svelte @@ -0,0 +1,170 @@ + + + + {title || 'Poll'} – evocracy + + + + + +
+ {#if loading} +
+

Loading poll...

+
+ {:else if error && !snapshot && !livePoll} +
+

{error}

+

The poll owner may need to be online, or the poll may not exist.

+
+ {:else} + +
+
+

{title}

+ {#if liveConnected} + + Live + + {/if} +
+ {#if description} +

{description}

+ {/if} +
+ + {status === 'open' ? '🟢 Open' : status === 'closed' ? '🔴 Closed' : '📝 Draft'} + + + {anonymous ? '🔒 Anonymous' : '👤 Named'} + + + {totalVotes} vote{totalVotes !== 1 ? 's' : ''} + +
+
+ + +
+ {#each options as option} + {@const count = counts[option.id] || 0} + {@const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0} + +
+
+ {option.text} + {count} ({pct.toFixed(0)}%) +
+
+
+
+
+ {/each} +
+ + + + +

+ Powered by evocracy +

+ {/if} +
diff --git a/app/static/robots.txt b/app/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/app/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/app/svelte.config.js b/app/svelte.config.js new file mode 100644 index 0000000..d2dd2e6 --- /dev/null +++ b/app/svelte.config.js @@ -0,0 +1,17 @@ +import adapter from '@sveltejs/adapter-auto'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + }, + vitePlugin: { + dynamicCompileOptions: ({ filename }) => + filename.includes('node_modules') ? undefined : { runes: true } + } +}; + +export default config; diff --git a/app/tsconfig.json b/app/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/app/vite.config.ts b/app/vite.config.ts new file mode 100644 index 0000000..2d35c4f --- /dev/null +++ b/app/vite.config.ts @@ -0,0 +1,7 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()] +}); diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..b56382d --- /dev/null +++ b/server/index.ts @@ -0,0 +1,146 @@ +import { readFile, writeFile, mkdir } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +const DATA_DIR = join(import.meta.dir, 'data'); +const PORT = parseInt(process.env.PORT || '3001'); + +// Ensure data dir exists +if (!existsSync(DATA_DIR)) await mkdir(DATA_DIR, { recursive: true }); + +// In-memory binding: pollId → ownerId (first writer wins) +const ownerBindings = new Map(); + +// Load existing bindings from disk +async function loadBindings() { + const bindingsFile = join(DATA_DIR, '_bindings.json'); + if (existsSync(bindingsFile)) { + const data = JSON.parse(await readFile(bindingsFile, 'utf-8')); + for (const [k, v] of Object.entries(data)) ownerBindings.set(k, v as string); + } +} + +async function saveBindings() { + const bindingsFile = join(DATA_DIR, '_bindings.json'); + await writeFile(bindingsFile, JSON.stringify(Object.fromEntries(ownerBindings))); +} + +await loadBindings(); + +// Rate limiting: simple per-IP counter +const rateLimits = new Map(); +const RATE_LIMIT = 60; // requests per minute + +function checkRateLimit(ip: string): boolean { + const now = Date.now(); + const entry = rateLimits.get(ip); + + if (!entry || now > entry.reset) { + rateLimits.set(ip, { count: 1, reset: now + 60_000 }); + return true; + } + + entry.count++; + return entry.count <= RATE_LIMIT; +} + +const server = Bun.serve({ + port: PORT, + + async fetch(req) { + const url = new URL(req.url); + const path = url.pathname; + + // CORS + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders() }); + } + + // GET /api/polls/:id/snapshot + const getMatch = path.match(/^\/api\/polls\/([^/]+)\/snapshot$/); + if (getMatch && req.method === 'GET') { + const pollId = getMatch[1]; + const file = join(DATA_DIR, `${pollId}.json`); + + if (!existsSync(file)) { + return json({ error: 'Not found' }, 404); + } + + const data = await readFile(file, 'utf-8'); + return json(JSON.parse(data), 200); + } + + // PUT /api/polls/:id/snapshot + const putMatch = path.match(/^\/api\/polls\/([^/]+)\/snapshot$/); + if (putMatch && req.method === 'PUT') { + const ip = req.headers.get('x-forwarded-for') || 'unknown'; + if (!checkRateLimit(ip)) { + return json({ error: 'Rate limit exceeded' }, 429); + } + + const pollId = putMatch[1]; + const body = await req.json(); + + // Validate required fields + if (!body.ownerId || !body.title || !body.signature) { + return json({ error: 'Missing required fields' }, 400); + } + + // Check owner binding + const existingOwner = ownerBindings.get(pollId); + if (existingOwner && existingOwner !== body.ownerId) { + return json({ error: 'Unauthorized: owner mismatch' }, 403); + } + + // TODO: verify Ed25519 signature against body.ownerId + // For now, trust the ownerId binding as basic auth + + // Bind on first write + if (!existingOwner) { + ownerBindings.set(pollId, body.ownerId); + await saveBindings(); + } + + const snapshot = { + pollId, + ownerId: body.ownerId, + ownerPeerId: body.ownerPeerId, + title: body.title, + description: body.description || '', + options: body.options || [], + voteCounts: body.voteCounts || {}, + totalVotes: body.totalVotes || 0, + status: body.status || 'draft', + anonymous: body.anonymous ?? false, + updatedAt: Date.now() + }; + + const file = join(DATA_DIR, `${pollId}.json`); + await writeFile(file, JSON.stringify(snapshot, null, 2)); + + return json({ ok: true }, 200); + } + + return json({ error: 'Not found' }, 404); + } +}); + +function corsHeaders(): Record { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, PUT, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + }; +} + +function json(data: unknown, status: number): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders() + } + }); +} + +console.log(`Snapshot server running on http://localhost:${PORT}`); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..9089478 --- /dev/null +++ b/server/package.json @@ -0,0 +1,10 @@ +{ + "name": "evocracy-server", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "bun run --watch index.ts", + "start": "bun run index.ts" + } +} diff --git a/specs/001-project-setup/README.md b/specs/001-project-setup/README.md index 939300c..dc20114 100644 --- a/specs/001-project-setup/README.md +++ b/specs/001-project-setup/README.md @@ -1,16 +1,24 @@ --- -status: planned +status: complete created: '2026-03-16' tags: - infra - setup priority: high created_at: '2026-03-16T07:51:47.401Z' +updated_at: '2026-03-16T10:01:25.930Z' +transitions: + - status: in-progress + at: '2026-03-16T09:49:02.744Z' + - status: complete + at: '2026-03-16T10:01:25.930Z' +completed_at: '2026-03-16T10:01:25.930Z' +completed: '2026-03-16' --- # Project Setup & Architecture -> **Status**: 📅 Planned · **Priority**: High · **Created**: 2026-03-16 +> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: infra, setup ## Overview diff --git a/specs/002-p2p-networking/README.md b/specs/002-p2p-networking/README.md index 39dd04f..5a915ad 100644 --- a/specs/002-p2p-networking/README.md +++ b/specs/002-p2p-networking/README.md @@ -1,5 +1,5 @@ --- -status: planned +status: complete created: '2026-03-16' tags: - p2p @@ -8,12 +8,17 @@ priority: high created_at: '2026-03-16T07:51:47.888Z' depends_on: - 001-project-setup -updated_at: '2026-03-16T07:52:03.104Z' +updated_at: '2026-03-16T10:01:26.362Z' +completed_at: '2026-03-16T10:01:26.362Z' +completed: '2026-03-16' +transitions: + - status: complete + at: '2026-03-16T10:01:26.362Z' --- # P2P Networking Layer -> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: p2p, core +> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: p2p, core ## Overview diff --git a/specs/003-user-identity-profiles/README.md b/specs/003-user-identity-profiles/README.md index 7b662ee..a704c4b 100644 --- a/specs/003-user-identity-profiles/README.md +++ b/specs/003-user-identity-profiles/README.md @@ -1,5 +1,5 @@ --- -status: planned +status: complete created: '2026-03-16' tags: - identity @@ -8,12 +8,17 @@ priority: high created_at: '2026-03-16T07:51:48.340Z' depends_on: - 001-project-setup -updated_at: '2026-03-16T07:52:03.509Z' +updated_at: '2026-03-16T10:01:26.785Z' +completed_at: '2026-03-16T10:01:26.785Z' +completed: '2026-03-16' +transitions: + - status: complete + at: '2026-03-16T10:01:26.785Z' --- # User Identity & Profiles -> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: identity, profiles +> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: identity, profiles ## Overview diff --git a/specs/004-poll-data-model/README.md b/specs/004-poll-data-model/README.md index 3eaef95..7704c55 100644 --- a/specs/004-poll-data-model/README.md +++ b/specs/004-poll-data-model/README.md @@ -1,5 +1,5 @@ --- -status: planned +status: complete created: '2026-03-16' tags: - data @@ -9,12 +9,17 @@ created_at: '2026-03-16T07:51:48.793Z' depends_on: - 002-p2p-networking - 003-user-identity-profiles -updated_at: '2026-03-16T07:52:03.943Z' +updated_at: '2026-03-16T10:01:27.181Z' +completed_at: '2026-03-16T10:01:27.181Z' +completed: '2026-03-16' +transitions: + - status: complete + at: '2026-03-16T10:01:27.181Z' --- # Poll Data Model & Sync -> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: data, core +> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: data, core ## Overview diff --git a/specs/005-poll-creation-management/README.md b/specs/005-poll-creation-management/README.md index ec0f944..45df9eb 100644 --- a/specs/005-poll-creation-management/README.md +++ b/specs/005-poll-creation-management/README.md @@ -1,5 +1,5 @@ --- -status: planned +status: complete created: '2026-03-16' tags: - polls @@ -8,14 +8,19 @@ priority: high created_at: '2026-03-16T07:51:49.209Z' depends_on: - 004-poll-data-model -updated_at: '2026-03-16T07:52:06.762Z' +updated_at: '2026-03-16T10:01:27.560Z' related: - 011-mobile-first-ui +completed_at: '2026-03-16T10:01:27.560Z' +completed: '2026-03-16' +transitions: + - status: complete + at: '2026-03-16T10:01:27.560Z' --- # Poll Creation & Management -> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: polls, management +> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: polls, management ## Overview diff --git a/specs/008-voting-system/README.md b/specs/008-voting-system/README.md index 0dfebb8..2e069b5 100644 --- a/specs/008-voting-system/README.md +++ b/specs/008-voting-system/README.md @@ -1,5 +1,5 @@ --- -status: planned +status: complete created: '2026-03-16' tags: - voting @@ -8,14 +8,19 @@ priority: high created_at: '2026-03-16T07:51:50.525Z' depends_on: - 005-poll-creation-management -updated_at: '2026-03-16T09:18:58.099Z' +updated_at: '2026-03-16T10:01:27.962Z' related: - 011-mobile-first-ui +completed_at: '2026-03-16T10:01:27.962Z' +completed: '2026-03-16' +transitions: + - status: complete + at: '2026-03-16T10:01:27.962Z' --- # Voting System & Anonymity -> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: voting, core +> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: voting, core ## Overview diff --git a/specs/009-public-sharing/README.md b/specs/009-public-sharing/README.md index 96cbd23..e352119 100644 --- a/specs/009-public-sharing/README.md +++ b/specs/009-public-sharing/README.md @@ -1,5 +1,5 @@ --- -status: planned +status: complete created: '2026-03-16' tags: - sharing @@ -10,14 +10,19 @@ depends_on: - 005-poll-creation-management - 008-voting-system - 012-lightweight-server -updated_at: '2026-03-16T07:57:41.176Z' +updated_at: '2026-03-16T10:01:28.366Z' related: - 011-mobile-first-ui +completed_at: '2026-03-16T10:01:28.366Z' +completed: '2026-03-16' +transitions: + - status: complete + at: '2026-03-16T10:01:28.366Z' --- # Public Sharing & Read-Only View -> **Status**: 🗓️ Planned · **Priority**: Medium · **Created**: 2026-03-16 · **Tags**: sharing, web +> **Status**: ✅ Complete · **Priority**: Medium · **Created**: 2026-03-16 · **Tags**: sharing, web ## Overview diff --git a/specs/010-embeddable-widget/README.md b/specs/010-embeddable-widget/README.md index 8286f2c..b929982 100644 --- a/specs/010-embeddable-widget/README.md +++ b/specs/010-embeddable-widget/README.md @@ -1,5 +1,5 @@ --- -status: planned +status: complete created: '2026-03-16' tags: - embed @@ -8,12 +8,17 @@ priority: low created_at: '2026-03-16T07:51:51.430Z' depends_on: - 009-public-sharing -updated_at: '2026-03-16T07:52:06.356Z' +updated_at: '2026-03-16T10:01:28.760Z' +completed_at: '2026-03-16T10:01:28.760Z' +completed: '2026-03-16' +transitions: + - status: complete + at: '2026-03-16T10:01:28.760Z' --- # Embeddable Poll Widget -> **Status**: 🗓️ Planned · **Priority**: Low · **Created**: 2026-03-16 · **Tags**: embed, widget +> **Status**: ✅ Complete · **Priority**: Low · **Created**: 2026-03-16 · **Tags**: embed, widget ## Overview diff --git a/specs/012-lightweight-server/README.md b/specs/012-lightweight-server/README.md index 1052367..3c78209 100644 --- a/specs/012-lightweight-server/README.md +++ b/specs/012-lightweight-server/README.md @@ -1,5 +1,5 @@ --- -status: planned +status: complete created: '2026-03-16' tags: - server @@ -8,12 +8,17 @@ priority: high created_at: '2026-03-16T07:57:36.544Z' depends_on: - 001-project-setup -updated_at: '2026-03-16T09:32:10.798Z' +updated_at: '2026-03-16T10:01:29.167Z' +completed_at: '2026-03-16T10:01:29.167Z' +completed: '2026-03-16' +transitions: + - status: complete + at: '2026-03-16T10:01:29.167Z' --- # Poll Snapshot Server -> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: server, infra +> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: server, infra ## Overview