Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.

Compare changes

Choose any two refs to compare.

+8
.dockerignore
··· 9 9 *.log 10 10 .vscode 11 11 .idea 12 + server 13 + .prettierrc 14 + testDeploy 15 + .tangled 16 + .crush 17 + .claude 18 + server 19 + hosting-service
+1
.gitignore
··· 16 16 17 17 # production 18 18 /build 19 + /result 19 20 20 21 # misc 21 22 .DS_Store
+22
.tangled/workflows/test.yml
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: main 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - git 10 + github:NixOS/nixpkgs/nixpkgs-unstable: 11 + - bun 12 + 13 + steps: 14 + - name: install dependencies 15 + command: | 16 + export PATH="$HOME/.nix-profile/bin:$PATH" 17 + bun install 18 + 19 + - name: run all tests 20 + command: | 21 + export PATH="$HOME/.nix-profile/bin:$PATH" 22 + bun test
+10 -6
Dockerfile
··· 15 15 COPY public ./public 16 16 17 17 # Build the application (if needed) 18 - # RUN bun run build 18 + RUN bun build \ 19 + --compile \ 20 + --minify \ 21 + --outfile server \ 22 + src/index.ts 23 + 24 + FROM scratch AS runtime 25 + WORKDIR /app 26 + COPY --from=base /app/server /app/server 19 27 20 28 # Set environment variables (can be overridden at runtime) 21 29 ENV PORT=3000 ··· 24 32 # Expose the application port 25 33 EXPOSE 3000 26 34 27 - # Health check 28 - HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 29 - CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" 30 - 31 35 # Start the application 32 - CMD ["bun", "src/index.ts"] 36 + CMD ["./server"]
+7 -12
README.md
··· 1 - # Elysia with Bun runtime 1 + # Wisp.place 2 + A static site hosting service built on the AT Protocol. [https://wisp.place](https://wisp.place) 2 3 3 - ## Getting Started 4 - To get started with this template, simply paste this command into your terminal: 5 - ```bash 6 - bun create elysia ./elysia-example 7 - ``` 4 + /src is the main backend 5 + 6 + /hosting-service is the microservice that serves on-disk caches of sites pulled from the firehose and pdses 8 7 9 - ## Development 10 - To start the development server run: 11 - ```bash 12 - bun run dev 13 - ``` 8 + /cli is the wisp-cli, a way to upload sites directly to the pds 14 9 15 - Open http://localhost:3000/ with your browser to see the result. 10 + full readme soon
-41
api.md
··· 1 - /** 2 - * AUTHENTICATION ROUTES 3 - * 4 - * Handles OAuth authentication flow for Bluesky/ATProto accounts 5 - * All routes are on the editor.wisp.place subdomain 6 - * 7 - * Routes: 8 - * POST /api/auth/signin - Initiate OAuth sign-in flow 9 - * GET /api/auth/callback - OAuth callback handler (redirect from PDS) 10 - * GET /api/auth/status - Check current authentication status 11 - * POST /api/auth/logout - Sign out and clear session 12 - */ 13 - 14 - /** 15 - * CUSTOM DOMAIN ROUTES 16 - * 17 - * Handles custom domain (BYOD - Bring Your Own Domain) management 18 - * Users can claim custom domains with DNS verification (TXT + CNAME) 19 - * and map them to their sites 20 - * 21 - * Routes: 22 - * GET /api/check-domain - Fast verification check for routing (public) 23 - * GET /api/custom-domains - List user's custom domains 24 - * POST /api/custom-domains/check - Check domain availability and DNS config 25 - * POST /api/custom-domains/claim - Claim a custom domain 26 - * PUT /api/custom-domains/:id/site - Update site mapping 27 - * DELETE /api/custom-domains/:id - Remove a custom domain 28 - * POST /api/custom-domains/:id/verify - Manually trigger verification 29 - */ 30 - 31 - /** 32 - * WISP SITE MANAGEMENT ROUTES 33 - * 34 - * API endpoints for managing user's Wisp sites stored in ATProto repos 35 - * Handles reading site metadata, fetching content, updating sites, and uploads 36 - * All routes are on the editor.wisp.place subdomain 37 - * 38 - * Routes: 39 - * GET /wisp/sites - List all sites for authenticated user 40 - * POST /wisp/upload-files - Upload and deploy files as a site 41 - */
+179
bun.lock
··· 26 26 "lucide-react": "^0.546.0", 27 27 "react": "^19.2.0", 28 28 "react-dom": "^19.2.0", 29 + "react-shiki": "^0.9.0", 29 30 "tailwind-merge": "^3.3.1", 30 31 "tailwindcss": "4", 31 32 "tw-animate-css": "^1.4.0", ··· 279 280 280 281 "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], 281 282 283 + "@shikijs/core": ["@shikijs/core@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="], 284 + 285 + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="], 286 + 287 + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="], 288 + 289 + "@shikijs/langs": ["@shikijs/langs@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="], 290 + 291 + "@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="], 292 + 293 + "@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="], 294 + 295 + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], 296 + 282 297 "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], 283 298 284 299 "@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="], ··· 291 306 292 307 "@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="], 293 308 309 + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], 310 + 311 + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 312 + 313 + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], 314 + 315 + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], 316 + 317 + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], 318 + 319 + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], 320 + 294 321 "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], 295 322 296 323 "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], ··· 298 325 "@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="], 299 326 300 327 "@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="], 328 + 329 + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], 330 + 331 + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], 301 332 302 333 "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], 303 334 ··· 347 378 348 379 "cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="], 349 380 381 + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], 382 + 350 383 "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 351 384 385 + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], 386 + 387 + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], 388 + 389 + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], 390 + 391 + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], 392 + 352 393 "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], 353 394 354 395 "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], ··· 362 403 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 363 404 364 405 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 406 + 407 + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], 365 408 366 409 "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], 367 410 ··· 378 421 "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 379 422 380 423 "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 424 + 425 + "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], 381 426 382 427 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], 383 428 429 + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], 430 + 384 431 "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], 385 432 386 433 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 387 434 388 435 "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], 436 + 437 + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], 389 438 390 439 "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], 391 440 ··· 406 455 "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 407 456 408 457 "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 458 + 459 + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], 409 460 410 461 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], 411 462 ··· 453 504 454 505 "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 455 506 507 + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], 508 + 509 + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], 510 + 511 + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], 512 + 513 + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], 514 + 456 515 "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], 457 516 458 517 "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], ··· 463 522 464 523 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 465 524 525 + "inline-style-parser": ["inline-style-parser@0.2.6", "", {}, "sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg=="], 526 + 466 527 "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], 467 528 468 529 "iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="], 469 530 470 531 "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], 471 532 533 + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 534 + 535 + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 536 + 472 537 "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], 473 538 539 + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 540 + 474 541 "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 475 542 543 + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 544 + 476 545 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 477 546 478 547 "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], ··· 480 549 "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], 481 550 482 551 "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], 552 + 553 + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], 483 554 484 555 "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], 485 556 ··· 487 558 488 559 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 489 560 561 + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], 562 + 563 + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], 564 + 565 + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], 566 + 567 + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], 568 + 569 + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], 570 + 571 + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], 572 + 573 + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], 574 + 575 + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], 576 + 490 577 "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], 491 578 492 579 "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], 493 580 494 581 "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], 495 582 583 + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], 584 + 585 + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], 586 + 587 + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], 588 + 589 + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], 590 + 591 + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], 592 + 593 + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], 594 + 595 + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], 596 + 597 + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], 598 + 599 + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], 600 + 601 + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], 602 + 603 + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], 604 + 605 + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], 606 + 607 + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], 608 + 609 + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], 610 + 611 + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], 612 + 613 + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], 614 + 615 + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], 616 + 617 + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], 618 + 619 + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], 620 + 621 + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], 622 + 623 + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], 624 + 496 625 "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], 497 626 498 627 "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], ··· 517 646 518 647 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], 519 648 649 + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], 650 + 651 + "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="], 652 + 520 653 "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], 654 + 655 + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], 521 656 522 657 "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], 523 658 ··· 541 676 542 677 "process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="], 543 678 679 + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], 680 + 544 681 "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], 545 682 546 683 "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], ··· 563 700 564 701 "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], 565 702 703 + "react-shiki": ["react-shiki@0.9.0", "", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^3.11.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5t+vHGglJioG3LU6uTKFaiOC+KNW7haL8e22ZHSP7m174ZD/X2KgCVJcxvcUOM3FiqjPQD09AyS9/+RcOh3PmA=="], 704 + 566 705 "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], 567 706 568 707 "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], 569 708 570 709 "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], 571 710 711 + "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], 712 + 713 + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], 714 + 715 + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], 716 + 572 717 "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], 573 718 574 719 "require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], ··· 589 734 590 735 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], 591 736 737 + "shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="], 738 + 592 739 "shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="], 593 740 594 741 "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], ··· 600 747 "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], 601 748 602 749 "sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg=="], 750 + 751 + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], 603 752 604 753 "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], 605 754 ··· 609 758 610 759 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 611 760 761 + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], 762 + 612 763 "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 613 764 614 765 "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], 615 766 767 + "style-to-js": ["style-to-js@1.1.19", "", { "dependencies": { "style-to-object": "1.0.12" } }, "sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ=="], 768 + 769 + "style-to-object": ["style-to-object@1.0.12", "", { "dependencies": { "inline-style-parser": "0.2.6" } }, "sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw=="], 770 + 616 771 "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 617 772 618 773 "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], ··· 630 785 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], 631 786 632 787 "token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], 788 + 789 + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], 633 790 634 791 "ts-morph": ["ts-morph@24.0.0", "", { "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" } }, "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw=="], 635 792 ··· 651 808 652 809 "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], 653 810 811 + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], 812 + 813 + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], 814 + 815 + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], 816 + 817 + "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], 818 + 819 + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], 820 + 654 821 "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], 655 822 656 823 "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], ··· 661 828 662 829 "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], 663 830 831 + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], 832 + 833 + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], 834 + 664 835 "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 665 836 666 837 "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], ··· 677 848 678 849 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 679 850 851 + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], 852 + 680 853 "@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 681 854 682 855 "express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], 683 856 684 857 "iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], 685 858 859 + "micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 860 + 861 + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], 862 + 686 863 "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], 687 864 688 865 "require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], ··· 692 869 "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 693 870 694 871 "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 872 + 873 + "micromark/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 695 874 696 875 "require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 697 876 }
+420 -25
claude.md
··· 1 - Wisp.place - Decentralized Static Site Hosting 1 + # Wisp.place - Codebase Overview 2 + 3 + **Project URL**: https://wisp.place 4 + 5 + A decentralized static site hosting service built on the AT Protocol (Bluesky). Users can host static websites directly in their AT Protocol accounts, keeping full control and ownership while benefiting from fast CDN distribution. 6 + 7 + --- 8 + 9 + ## ๐Ÿ—๏ธ Architecture Overview 10 + 11 + ### Multi-Part System 12 + 1. **Main Backend** (`/src`) - OAuth, site management, custom domains 13 + 2. **Hosting Service** (`/hosting-service`) - Microservice that serves cached sites 14 + 3. **CLI Tool** (`/cli`) - Rust CLI for direct site uploads to PDS 15 + 4. **Frontend** (`/public`) - React UI for onboarding, editor, admin 16 + 17 + ### Tech Stack 18 + - **Backend**: Elysia (Bun) + TypeScript + PostgreSQL 19 + - **Frontend**: React 19 + Tailwind CSS 4 + Radix UI 20 + - **CLI**: Rust with Jacquard (AT Protocol library) 21 + - **Database**: PostgreSQL for session/domain/site caching 22 + - **AT Protocol**: OAuth 2.0 + custom lexicons for storage 23 + 24 + --- 25 + 26 + ## ๐Ÿ“‚ Directory Structure 27 + 28 + ### `/src` - Main Backend Server 29 + **Purpose**: Core server handling OAuth, site management, custom domains, admin features 30 + 31 + **Key Routes**: 32 + - `/api/auth/*` - OAuth signin/callback/logout/status 33 + - `/api/domain/*` - Custom domain management (BYOD) 34 + - `/wisp/*` - Site upload and management 35 + - `/api/user/*` - User info and site listing 36 + - `/api/admin/*` - Admin console (logs, metrics, DNS verification) 37 + 38 + **Key Files**: 39 + - `index.ts` - Express-like Elysia app setup with middleware (CORS, CSP, security headers) 40 + - `lib/oauth-client.ts` - OAuth client setup with session/state persistence 41 + - `lib/db.ts` - PostgreSQL schema and queries for all tables 42 + - `lib/wisp-auth.ts` - Cookie-based authentication middleware 43 + - `lib/wisp-utils.ts` - File compression (gzip), manifest creation, blob handling 44 + - `lib/sync-sites.ts` - Syncs user's place.wisp.fs records from PDS to database cache 45 + - `lib/dns-verify.ts` - DNS verification for custom domains (TXT + CNAME) 46 + - `lib/dns-verification-worker.ts` - Background worker that checks domain verification every 10 minutes 47 + - `lib/admin-auth.ts` - Simple username/password admin authentication 48 + - `lib/observability.ts` - Logging, error tracking, metrics collection 49 + - `routes/auth.ts` - OAuth flow handlers 50 + - `routes/wisp.ts` - File upload and site creation (/wisp/upload-files) 51 + - `routes/domain.ts` - Domain claiming/verification API 52 + - `routes/user.ts` - User status/info/sites listing 53 + - `routes/site.ts` - Site metadata and file retrieval 54 + - `routes/admin.ts` - Admin dashboard API (logs, system health, manual DNS trigger) 55 + 56 + ### `/lexicons` & `src/lexicons/` 57 + **Purpose**: AT Protocol Lexicon definitions for custom data types 58 + 59 + **Key File**: `fs.json` - Defines `place.wisp.fs` record format 60 + - **structure**: Virtual filesystem manifest with tree structure 61 + - **site**: string identifier 62 + - **root**: directory object containing entries 63 + - **file**: blob reference + metadata (encoding, mimeType, base64 flag) 64 + - **directory**: array of entries (recursive) 65 + - **entry**: name + node (file or directory) 66 + 67 + **Important**: Files are gzip-compressed and base64-encoded before upload to bypass PDS content sniffing 68 + 69 + ### `/hosting-service` 70 + **Purpose**: Lightweight microservice that serves cached sites from disk 71 + 72 + **Architecture**: 73 + - Routes by domain lookup in PostgreSQL 74 + - Caches site content locally on first access or firehose event 75 + - Listens to AT Protocol firehose for new site records 76 + - Automatically downloads and caches files from PDS 77 + - SSRF-protected fetch (timeout, size limits, private IP blocking) 78 + 79 + **Routes**: 80 + 1. Custom domains (`/*`) โ†’ lookup custom_domains table 81 + 2. Wisp subdomains (`/*.wisp.place/*`) โ†’ lookup domains table 82 + 3. DNS hash routing (`/hash.dns.wisp.place/*`) โ†’ lookup custom_domains by hash 83 + 4. Direct serving (`/s.wisp.place/:identifier/:site/*`) โ†’ fetch from PDS if not cached 84 + 85 + **HTML Path Rewriting**: Absolute paths in HTML (`/style.css`) automatically rewritten to relative (`/:identifier/:site/style.css`) 86 + 87 + ### `/cli` 88 + **Purpose**: Rust CLI tool for direct site uploads using app password or OAuth 89 + 90 + **Flow**: 91 + 1. Authenticate with handle + app password or OAuth 92 + 2. Walk directory tree, compress files 93 + 3. Upload blobs to PDS via agent 94 + 4. Create place.wisp.fs record with manifest 95 + 5. Store site in database cache 96 + 97 + **Auth Methods**: 98 + - `--password` flag for app password auth 99 + - OAuth loopback server for browser-based auth 100 + - Supports both (password preferred if provided) 101 + 102 + --- 103 + 104 + ## ๐Ÿ” Key Concepts 105 + 106 + ### Custom Domains (BYOD - Bring Your Own Domain) 107 + **Process**: 108 + 1. User claims custom domain via API 109 + 2. System generates hash (SHA256(domain + secret)) 110 + 3. User adds DNS records: 111 + - TXT at `_wisp.example.com` = their DID 112 + - CNAME at `example.com` = `{hash}.dns.wisp.place` 113 + 4. Background worker checks verification every 10 minutes 114 + 5. Once verified, custom domain routes to their hosted sites 115 + 116 + **Tables**: `custom_domains` (id, domain, did, rkey, verified, last_verified_at) 117 + 118 + ### Wisp Subdomains 119 + **Process**: 120 + 1. Handle claimed on first signup (e.g., alice โ†’ alice.wisp.place) 121 + 2. Stored in `domains` table mapping domain โ†’ DID 122 + 3. Served by hosting service 123 + 124 + ### Site Storage 125 + **Locations**: 126 + - **Authoritative**: PDS (AT Protocol repo) as `place.wisp.fs` record 127 + - **Cache**: PostgreSQL `sites` table (rkey, did, site_name, created_at) 128 + - **File Cache**: Hosting service caches downloaded files on disk 129 + 130 + **Limits**: 131 + - MAX_SITE_SIZE: 300MB total 132 + - MAX_FILE_SIZE: 100MB per file 133 + - MAX_FILE_COUNT: 2000 files 134 + 135 + ### File Compression Strategy 136 + **Why**: Bypass PDS content sniffing issues (was treating HTML as images) 137 + 138 + **Process**: 139 + 1. All files gzip-compressed (level 9) 140 + 2. Compressed content base64-encoded 141 + 3. Uploaded as `application/octet-stream` MIME type 142 + 4. Blob metadata stores original MIME type + encoding flag 143 + 5. Hosting service decompresses on serve 144 + 145 + --- 146 + 147 + ## ๐Ÿ”„ Data Flow 148 + 149 + ### User Registration โ†’ Site Upload 150 + ``` 151 + 1. OAuth signin โ†’ state/session stored in DB 152 + 2. Cookie set with DID 153 + 3. Sync sites from PDS to cache DB 154 + 4. If no sites/domain โ†’ redirect to onboarding 155 + 5. User creates site โ†’ POST /wisp/upload-files 156 + 6. Files compressed, uploaded as blobs 157 + 7. place.wisp.fs record created 158 + 8. Site cached in DB 159 + 9. Hosting service notified via firehose 160 + ``` 161 + 162 + ### Custom Domain Setup 163 + ``` 164 + 1. User claims domain (DB check + allocation) 165 + 2. System generates hash 166 + 3. User adds DNS records (_wisp.domain TXT + CNAME) 167 + 4. Background worker verifies every 10 min 168 + 5. Hosting service routes based on verification status 169 + ``` 170 + 171 + ### Site Access 172 + ``` 173 + Hosting Service: 174 + 1. Request arrives at custom domain or *.wisp.place 175 + 2. Domain lookup in PostgreSQL 176 + 3. Check cache for site files 177 + 4. If not cached: 178 + - Fetch from PDS using DID + rkey 179 + - Decompress files 180 + - Save to disk cache 181 + 5. Serve files (with HTML path rewriting) 182 + ``` 183 + 184 + --- 185 + 186 + ## ๐Ÿ› ๏ธ Important Implementation Details 187 + 188 + ### OAuth Implementation 189 + - **State & Session Storage**: PostgreSQL (with expiration) 190 + - **Key Rotation**: Periodic rotation + expiration cleanup (hourly) 191 + - **OAuth Flow**: Redirects to PDS, returns to /api/auth/callback 192 + - **Session Timeout**: 30 days 193 + - **State Timeout**: 1 hour 194 + 195 + ### Security Headers 196 + - X-Frame-Options: DENY 197 + - X-Content-Type-Options: nosniff 198 + - Strict-Transport-Security: max-age=31536000 199 + - Content-Security-Policy (configured for Elysia + React) 200 + - X-XSS-Protection: 1; mode=block 201 + - Referrer-Policy: strict-origin-when-cross-origin 202 + 203 + ### Admin Authentication 204 + - Simple username/password (hashed with bcrypt) 205 + - Session-based cookie auth (24hr expiration) 206 + - Separate `admin_session` cookie 207 + - Initial setup prompted on startup 208 + 209 + ### Observability 210 + - **Logging**: Structured logging with service tags + event types 211 + - **Error Tracking**: Captures error context (message, stack, etc.) 212 + - **Metrics**: Request counts, latencies, error rates 213 + - **Log Levels**: debug, info, warn, error 214 + - **Collection**: Centralized log collector with in-memory buffer 215 + 216 + --- 217 + 218 + ## ๐Ÿ“ Database Schema 219 + 220 + ### oauth_states 221 + - key (primary key) 222 + - data (JSON) 223 + - created_at, expires_at (timestamps) 2 224 3 - Architecture Overview 225 + ### oauth_sessions 226 + - sub (primary key - subject/DID) 227 + - data (JSON with OAuth session) 228 + - updated_at, expires_at 4 229 5 - Wisp.Place a two-service application that provides static site hosting on the AT 6 - Protocol. Wisp aims to be a CDN for static sites where the content is ultimately owned by the user at their repo. The microservice is responsbile for injesting firehose events and serving a on-disk cache of the latest site files. 230 + ### oauth_keys 231 + - kid (primary key - key ID) 232 + - jwk (JSON Web Key) 233 + - created_at 234 + 235 + ### domains 236 + - domain (primary key - e.g., alice.wisp.place) 237 + - did (unique - user's DID) 238 + - rkey (optional - record key) 239 + - created_at 240 + 241 + ### custom_domains 242 + - id (primary key - UUID) 243 + - domain (unique - e.g., example.com) 244 + - did (user's DID) 245 + - rkey (optional) 246 + - verified (boolean) 247 + - last_verified_at (timestamp) 248 + - created_at 249 + 250 + ### sites 251 + - id, did, rkey, site_name 252 + - created_at, updated_at 253 + - Indexes on (did), (did, rkey), (rkey) 254 + 255 + ### admin_users 256 + - username (primary key) 257 + - password_hash (bcrypt) 258 + - created_at 259 + 260 + --- 261 + 262 + ## ๐Ÿš€ Key Workflows 263 + 264 + ### Sign In Flow 265 + 1. POST /api/auth/signin with handle 266 + 2. System generates state token 267 + 3. Redirects to PDS OAuth endpoint 268 + 4. PDS redirects back to /api/auth/callback?code=X&state=Y 269 + 5. Validate state (CSRF protection) 270 + 6. Exchange code for session 271 + 7. Store session in DB, set DID cookie 272 + 8. Sync sites from PDS 273 + 9. Redirect to /editor or /onboarding 274 + 275 + ### File Upload Flow 276 + 1. POST /wisp/upload-files with siteName + files 277 + 2. Validate site name (rkey format rules) 278 + 3. For each file: 279 + - Check size limits 280 + - Read as ArrayBuffer 281 + - Gzip compress 282 + - Base64 encode 283 + 4. Upload all blobs in parallel via agent.com.atproto.repo.uploadBlob() 284 + 5. Create manifest with all blob refs 285 + 6. putRecord() for place.wisp.fs with manifest 286 + 7. Upsert to sites table 287 + 8. Return URI + CID 7 288 8 - Service 1: Main App (Port 8000, Bun runtime, elysia.js) 9 - - User-facing editor and API 10 - - OAuth authentication (AT Protocol) 11 - - File upload processing (gzip + base64 encoding) 12 - - Domain management (subdomains + custom domains) 13 - - DNS verification worker 14 - - React frontend 289 + ### Domain Verification Flow 290 + 1. POST /api/custom-domains/claim 291 + 2. Generate hash = SHA256(domain + secret) 292 + 3. Store in custom_domains with verified=false 293 + 4. Return hash for user to configure DNS 294 + 5. Background worker periodically: 295 + - Query custom_domains where verified=false 296 + - Verify TXT record at _wisp.domain 297 + - Verify CNAME points to hash.dns.wisp.place 298 + - Update verified flag + last_verified_at 299 + 6. Hosting service routes when verified=true 15 300 16 - Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js) 17 - - AT Protocol Firehose listener for real-time updates 18 - - Serves hosted websites from local cache 19 - - Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain) 20 - - Distributed locking for multi-instance coordination 301 + --- 21 302 22 - Tech Stack 303 + ## ๐ŸŽจ Frontend Structure 23 304 24 - - Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK 25 - - Frontend: React 19, Tailwind CSS v4, Shadcn UI 305 + ### `/public` 306 + - **index.tsx** - Landing page with sign-in form 307 + - **editor/editor.tsx** - Site editor/management UI 308 + - **admin/admin.tsx** - Admin dashboard 309 + - **components/ui/** - Reusable components (Button, Card, Dialog, etc.) 310 + - **styles/global.css** - Tailwind + custom styles 26 311 27 - Key Features 312 + ### Page Flow 313 + 1. `/` - Landing page (sign in / get started) 314 + 2. `/editor` - Main app (requires auth) 315 + 3. `/admin` - Admin console (requires admin auth) 316 + 4. `/onboarding` - First-time user setup 28 317 29 - - AT Protocol Integration: Sites stored as place.wisp.fs records in user repos 30 - - File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS 31 - - Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification 32 - - Real-time Sync: Firehose worker listens for site updates and caches files locally 33 - - Atomic Updates: Safe cache swapping without downtime 318 + --- 319 + 320 + ## ๐Ÿ” Notable Implementation Patterns 321 + 322 + ### File Handling 323 + - Files stored as base64-encoded gzip in PDS blobs 324 + - Metadata preserves original MIME type 325 + - Hosting service decompresses on serve 326 + - Workaround for PDS image pipeline issues with HTML 327 + 328 + ### Error Handling 329 + - Comprehensive logging with context 330 + - Graceful degradation (e.g., site sync failure doesn't break auth) 331 + - Structured error responses with details 332 + 333 + ### Performance 334 + - Site sync: Batch fetch up to 100 records per request 335 + - Blob upload: Parallel promises for all files 336 + - DNS verification: Batched background worker (10 min intervals) 337 + - Caching: Two-tier (DB + disk in hosting service) 338 + 339 + ### Validation 340 + - Lexicon validation on manifest creation 341 + - Record type checking 342 + - Domain format validation 343 + - Site name format validation (AT Protocol rkey rules) 344 + - File size limits enforced before upload 345 + 346 + --- 347 + 348 + ## ๐Ÿ› Known Quirks & Workarounds 349 + 350 + 1. **PDS Content Sniffing**: Files must be uploaded as `application/octet-stream` (even HTML) and base64-encoded to prevent PDS from misinterpreting content 351 + 352 + 2. **Max URL Query Size**: DNS verification worker queries in batch; may need pagination for users with many custom domains 353 + 354 + 3. **File Count Limits**: Max 500 entries per directory (Lexicon constraint); large sites split across multiple directories 355 + 356 + 4. **Blob Size Limits**: Individual blobs limited to 100MB by Lexicon; large files handled differently if needed 357 + 358 + 5. **HTML Path Rewriting**: Only in hosting service for `/s.wisp.place/:identifier/:site/*` routes; custom domains handled differently 359 + 360 + --- 361 + 362 + ## ๐Ÿ“‹ Environment Variables 363 + 364 + - `DOMAIN` - Base domain with protocol (default: `https://wisp.place`) 365 + - `CLIENT_NAME` - OAuth client name (default: `PDS-View`) 366 + - `DATABASE_URL` - PostgreSQL connection (default: `postgres://postgres:postgres@localhost:5432/wisp`) 367 + - `NODE_ENV` - production/development 368 + - `HOSTING_PORT` - Hosting service port (default: 3001) 369 + - `BASE_DOMAIN` - Domain for URLs (default: wisp.place) 370 + 371 + --- 372 + 373 + ## ๐Ÿง‘โ€๐Ÿ’ป Development Notes 374 + 375 + ### Adding New Features 376 + 1. **New routes**: Add to `/src/routes/*.ts`, import in index.ts 377 + 2. **DB changes**: Add migration in db.ts 378 + 3. **New lexicons**: Update `/lexicons/*.json`, regenerate types 379 + 4. **Admin features**: Add to /api/admin endpoints 380 + 381 + ### Testing 382 + - Run with `bun test` 383 + - CSRF tests in lib/csrf.test.ts 384 + - Utility tests in lib/wisp-utils.test.ts 385 + 386 + ### Debugging 387 + - Check logs via `/api/admin/logs` (requires admin auth) 388 + - DNS verification manual trigger: POST /api/admin/verify-dns 389 + - Health check: GET /api/health (includes DNS verifier status) 390 + 391 + --- 392 + 393 + ## ๐Ÿš€ Deployment Considerations 394 + 395 + 1. **Secrets**: Admin password, OAuth keys, database credentials 396 + 2. **HTTPS**: Required (HSTS header enforces it) 397 + 3. **CDN**: Custom domains require DNS configuration 398 + 4. **Scaling**: 399 + - Main server: Horizontal scaling with session DB 400 + - Hosting service: Independent scaling, disk cache per instance 401 + 5. **Backups**: PostgreSQL database critical; firehose provides recovery 402 + 403 + --- 404 + 405 + ## ๐Ÿ“š Related Technologies 406 + 407 + - **AT Protocol**: Decentralized identity, OAuth 2.0 408 + - **Jacquard**: Rust library for AT Protocol interactions 409 + - **Elysia**: Bun web framework (similar to Express/Hono) 410 + - **Lexicon**: AT Protocol's schema definition language 411 + - **Firehose**: Real-time event stream of repo changes 412 + - **PDS**: Personal Data Server (where users' data stored) 413 + 414 + --- 415 + 416 + ## ๐ŸŽฏ Project Goals 417 + 418 + โœ… Decentralized site hosting (data owned by users) 419 + โœ… Custom domain support with DNS verification 420 + โœ… Fast CDN distribution via hosting service 421 + โœ… Developer tools (CLI + API) 422 + โœ… Admin dashboard for monitoring 423 + โœ… Zero user data retention (sites in PDS, sessions in DB only) 424 + 425 + --- 426 + 427 + **Last Updated**: November 2025 428 + **Status**: Active development
+24
cli/.gitignore
··· 1 + .DS_STORE 2 + jacquard/ 3 + binaries/ 4 + # Generated by Cargo 5 + # will have compiled files and executables 6 + debug 7 + target 8 + 9 + # These are backup files generated by rustfmt 10 + **/*.rs.bk 11 + 12 + # MSVC Windows builds of rustc generate these, which store debugging information 13 + *.pdb 14 + 15 + # Generated by cargo mutants 16 + # Contains mutation testing data 17 + **/mutants.out*/ 18 + 19 + # RustRover 20 + # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 21 + # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 22 + # and can be added to the global gitignore or merged into this file. For a more nuclear 23 + # option (not recommended) you can uncomment the following to ignore the entire idea folder. 24 + #.idea/
+9 -151
cli/Cargo.lock
··· 922 922 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 923 923 924 924 [[package]] 925 - name = "foreign-types" 926 - version = "0.3.2" 927 - source = "registry+https://github.com/rust-lang/crates.io-index" 928 - checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 929 - dependencies = [ 930 - "foreign-types-shared", 931 - ] 932 - 933 - [[package]] 934 - name = "foreign-types-shared" 935 - version = "0.1.1" 936 - source = "registry+https://github.com/rust-lang/crates.io-index" 937 - checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 938 - 939 - [[package]] 940 925 name = "form_urlencoded" 941 926 version = "1.2.2" 942 927 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1340 1325 ] 1341 1326 1342 1327 [[package]] 1343 - name = "hyper-tls" 1344 - version = "0.6.0" 1345 - source = "registry+https://github.com/rust-lang/crates.io-index" 1346 - checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" 1347 - dependencies = [ 1348 - "bytes", 1349 - "http-body-util", 1350 - "hyper", 1351 - "hyper-util", 1352 - "native-tls", 1353 - "tokio", 1354 - "tokio-native-tls", 1355 - "tower-service", 1356 - ] 1357 - 1358 - [[package]] 1359 1328 name = "hyper-util" 1360 1329 version = "0.1.17" 1361 1330 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1614 1583 [[package]] 1615 1584 name = "jacquard" 1616 1585 version = "0.9.0" 1586 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1617 1587 dependencies = [ 1618 1588 "bytes", 1619 1589 "getrandom 0.2.16", ··· 1641 1611 [[package]] 1642 1612 name = "jacquard-api" 1643 1613 version = "0.9.0" 1614 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1644 1615 dependencies = [ 1645 1616 "bon", 1646 1617 "bytes", ··· 1658 1629 [[package]] 1659 1630 name = "jacquard-common" 1660 1631 version = "0.9.0" 1632 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1661 1633 dependencies = [ 1662 1634 "base64 0.22.1", 1663 1635 "bon", ··· 1694 1666 [[package]] 1695 1667 name = "jacquard-derive" 1696 1668 version = "0.9.0" 1669 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1697 1670 dependencies = [ 1698 1671 "heck 0.5.0", 1699 1672 "jacquard-lexicon", ··· 1704 1677 1705 1678 [[package]] 1706 1679 name = "jacquard-identity" 1707 - version = "0.9.0" 1680 + version = "0.9.1" 1681 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1708 1682 dependencies = [ 1709 1683 "bon", 1710 1684 "bytes", ··· 1729 1703 1730 1704 [[package]] 1731 1705 name = "jacquard-lexicon" 1732 - version = "0.9.0" 1706 + version = "0.9.1" 1707 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1733 1708 dependencies = [ 1734 1709 "cid", 1735 1710 "dashmap", ··· 1755 1730 [[package]] 1756 1731 name = "jacquard-oauth" 1757 1732 version = "0.9.0" 1733 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1758 1734 dependencies = [ 1759 1735 "base64 0.22.1", 1760 1736 "bytes", ··· 2132 2108 ] 2133 2109 2134 2110 [[package]] 2135 - name = "native-tls" 2136 - version = "0.2.14" 2137 - source = "registry+https://github.com/rust-lang/crates.io-index" 2138 - checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" 2139 - dependencies = [ 2140 - "libc", 2141 - "log", 2142 - "openssl", 2143 - "openssl-probe", 2144 - "openssl-sys", 2145 - "schannel", 2146 - "security-framework", 2147 - "security-framework-sys", 2148 - "tempfile", 2149 - ] 2150 - 2151 - [[package]] 2152 2111 name = "ndk-context" 2153 2112 version = "0.1.1" 2154 2113 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2288 2247 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 2289 2248 2290 2249 [[package]] 2291 - name = "openssl" 2292 - version = "0.10.74" 2293 - source = "registry+https://github.com/rust-lang/crates.io-index" 2294 - checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" 2295 - dependencies = [ 2296 - "bitflags", 2297 - "cfg-if", 2298 - "foreign-types", 2299 - "libc", 2300 - "once_cell", 2301 - "openssl-macros", 2302 - "openssl-sys", 2303 - ] 2304 - 2305 - [[package]] 2306 - name = "openssl-macros" 2307 - version = "0.1.1" 2308 - source = "registry+https://github.com/rust-lang/crates.io-index" 2309 - checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 2310 - dependencies = [ 2311 - "proc-macro2", 2312 - "quote", 2313 - "syn 2.0.108", 2314 - ] 2315 - 2316 - [[package]] 2317 - name = "openssl-probe" 2318 - version = "0.1.6" 2319 - source = "registry+https://github.com/rust-lang/crates.io-index" 2320 - checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 2321 - 2322 - [[package]] 2323 - name = "openssl-sys" 2324 - version = "0.9.110" 2325 - source = "registry+https://github.com/rust-lang/crates.io-index" 2326 - checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" 2327 - dependencies = [ 2328 - "cc", 2329 - "libc", 2330 - "pkg-config", 2331 - "vcpkg", 2332 - ] 2333 - 2334 - [[package]] 2335 2250 name = "option-ext" 2336 2251 version = "0.2.0" 2337 2252 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2497 2412 "der", 2498 2413 "spki", 2499 2414 ] 2500 - 2501 - [[package]] 2502 - name = "pkg-config" 2503 - version = "0.3.32" 2504 - source = "registry+https://github.com/rust-lang/crates.io-index" 2505 - checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 2506 2415 2507 2416 [[package]] 2508 2417 name = "potential_utf" ··· 2827 2736 "http-body-util", 2828 2737 "hyper", 2829 2738 "hyper-rustls", 2830 - "hyper-tls", 2831 2739 "hyper-util", 2832 2740 "js-sys", 2833 2741 "log", 2834 2742 "mime", 2835 - "native-tls", 2836 2743 "percent-encoding", 2837 2744 "pin-project-lite", 2838 2745 "quinn", ··· 2843 2750 "serde_urlencoded", 2844 2751 "sync_wrapper", 2845 2752 "tokio", 2846 - "tokio-native-tls", 2847 2753 "tokio-rustls", 2848 2754 "tokio-util", 2849 2755 "tower", ··· 3019 2925 ] 3020 2926 3021 2927 [[package]] 3022 - name = "schannel" 3023 - version = "0.1.28" 3024 - source = "registry+https://github.com/rust-lang/crates.io-index" 3025 - checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 3026 - dependencies = [ 3027 - "windows-sys 0.61.2", 3028 - ] 3029 - 3030 - [[package]] 3031 2928 name = "schemars" 3032 2929 version = "0.9.0" 3033 2930 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3069 2966 "pkcs8", 3070 2967 "subtle", 3071 2968 "zeroize", 3072 - ] 3073 - 3074 - [[package]] 3075 - name = "security-framework" 3076 - version = "2.11.1" 3077 - source = "registry+https://github.com/rust-lang/crates.io-index" 3078 - checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 3079 - dependencies = [ 3080 - "bitflags", 3081 - "core-foundation 0.9.4", 3082 - "core-foundation-sys", 3083 - "libc", 3084 - "security-framework-sys", 3085 - ] 3086 - 3087 - [[package]] 3088 - name = "security-framework-sys" 3089 - version = "2.15.0" 3090 - source = "registry+https://github.com/rust-lang/crates.io-index" 3091 - checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" 3092 - dependencies = [ 3093 - "core-foundation-sys", 3094 - "libc", 3095 2969 ] 3096 2970 3097 2971 [[package]] ··· 3698 3572 ] 3699 3573 3700 3574 [[package]] 3701 - name = "tokio-native-tls" 3702 - version = "0.3.1" 3703 - source = "registry+https://github.com/rust-lang/crates.io-index" 3704 - checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" 3705 - dependencies = [ 3706 - "native-tls", 3707 - "tokio", 3708 - ] 3709 - 3710 - [[package]] 3711 3575 name = "tokio-rustls" 3712 3576 version = "0.26.4" 3713 3577 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3927 3791 version = "0.2.2" 3928 3792 source = "registry+https://github.com/rust-lang/crates.io-index" 3929 3793 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 3930 - 3931 - [[package]] 3932 - name = "vcpkg" 3933 - version = "0.2.15" 3934 - source = "registry+https://github.com/rust-lang/crates.io-index" 3935 - checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 3936 3794 3937 3795 [[package]] 3938 3796 name = "version_check"
+9 -8
cli/Cargo.toml
··· 8 8 place_wisp = [] 9 9 10 10 [dependencies] 11 - jacquard = { path = "jacquard/crates/jacquard", features = ["loopback"] } 12 - jacquard-oauth = { path = "jacquard/crates/jacquard-oauth" } 13 - jacquard-api = { path = "jacquard/crates/jacquard-api" } 14 - jacquard-common = { path = "jacquard/crates/jacquard-common" } 15 - jacquard-identity = { path = "jacquard/crates/jacquard-identity", features = ["dns"] } 16 - jacquard-derive = { path = "jacquard/crates/jacquard-derive" } 17 - jacquard-lexicon = { path = "jacquard/crates/jacquard-lexicon" } 11 + jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] } 12 + jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 13 + jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 14 + jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 15 + jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] } 16 + jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 17 + jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 18 18 clap = { version = "4.5.51", features = ["derive"] } 19 19 tokio = { version = "1.48", features = ["full"] } 20 20 miette = { version = "7.6.0", features = ["fancy"] } 21 21 serde_json = "1.0.145" 22 22 serde = { version = "1.0", features = ["derive"] } 23 23 shellexpand = "3.1.1" 24 - reqwest = "0.12" 24 + #reqwest = "0.12" 25 + reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } 25 26 rustversion = "1.0" 26 27 flate2 = "1.0" 27 28 base64 = "0.22"
+23
cli/build-linux.sh
··· 1 + #!/usr/bin/env bash 2 + # Build Linux binaries (statically linked) 3 + set -e 4 + mkdir -p binaries 5 + 6 + # Build Linux binaries 7 + echo "Building Linux binaries..." 8 + 9 + echo "Building Linux ARM64 (static)..." 10 + nix-shell -p rustup --run ' 11 + rustup target add aarch64-unknown-linux-musl 12 + RUSTFLAGS="-C target-feature=+crt-static" cargo zigbuild --release --target aarch64-unknown-linux-musl 13 + ' 14 + cp target/aarch64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-aarch64-linux 15 + 16 + echo "Building Linux x86_64 (static)..." 17 + nix-shell -p rustup --run ' 18 + rustup target add x86_64-unknown-linux-musl 19 + RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target x86_64-unknown-linux-musl 20 + ' 21 + cp target/x86_64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-x86_64-linux 22 + 23 + echo "Done! Binaries in ./binaries/"
+15
cli/build-macos.sh
··· 1 + #!/bin/bash 2 + # Build Linux and macOS binaries 3 + 4 + set -e 5 + 6 + mkdir -p binaries 7 + rm -rf target 8 + 9 + # Build macOS binaries natively 10 + echo "Building macOS binaries..." 11 + rustup target add aarch64-apple-darwin 12 + 13 + echo "Building macOS arm64 binary." 14 + RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target aarch64-apple-darwin 15 + cp target/aarch64-apple-darwin/release/wisp-cli binaries/wisp-cli-macos-arm64
-14
cli/test_headers.rs
··· 1 - use http::Request; 2 - 3 - fn main() { 4 - let builder = Request::builder() 5 - .header(http::header::CONTENT_TYPE, "*/*") 6 - .header(http::header::CONTENT_TYPE, "application/octet-stream"); 7 - 8 - let req = builder.body(()).unwrap(); 9 - 10 - println!("Content-Type headers:"); 11 - for value in req.headers().get_all(http::header::CONTENT_TYPE) { 12 - println!(" {:?}", value); 13 - } 14 - }
+63
crates.nix
··· 1 + {...}: { 2 + perSystem = { 3 + pkgs, 4 + config, 5 + lib, 6 + inputs', 7 + ... 8 + }: { 9 + # declare projects 10 + nci.projects."wisp-place-cli" = { 11 + path = ./cli; 12 + export = false; 13 + }; 14 + nci.toolchains.mkBuild = _: 15 + with inputs'.fenix.packages; 16 + combine [ 17 + minimal.rustc 18 + minimal.cargo 19 + targets.x86_64-pc-windows-gnu.latest.rust-std 20 + targets.x86_64-unknown-linux-gnu.latest.rust-std 21 + targets.aarch64-apple-darwin.latest.rust-std 22 + ]; 23 + # configure crates 24 + nci.crates."wisp-cli" = { 25 + profiles = { 26 + dev.runTests = false; 27 + release.runTests = false; 28 + }; 29 + targets."x86_64-unknown-linux-gnu" = { 30 + default = true; 31 + }; 32 + targets."x86_64-pc-windows-gnu" = let 33 + targetPkgs = pkgs.pkgsCross.mingwW64; 34 + targetCC = targetPkgs.stdenv.cc; 35 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 36 + in rec { 37 + depsDrvConfig.mkDerivation = { 38 + nativeBuildInputs = [targetCC]; 39 + buildInputs = with targetPkgs; [windows.pthreads]; 40 + }; 41 + depsDrvConfig.env = rec { 42 + TARGET_CC = "${targetCC.targetPrefix}cc"; 43 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 44 + }; 45 + drvConfig = depsDrvConfig; 46 + }; 47 + targets."aarch64-apple-darwin" = let 48 + targetPkgs = pkgs.pkgsCross.aarch64-darwin; 49 + targetCC = targetPkgs.stdenv.cc; 50 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 51 + in rec { 52 + depsDrvConfig.mkDerivation = { 53 + nativeBuildInputs = [targetCC]; 54 + }; 55 + depsDrvConfig.env = rec { 56 + TARGET_CC = "${targetCC.targetPrefix}cc"; 57 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 58 + }; 59 + drvConfig = depsDrvConfig; 60 + }; 61 + }; 62 + }; 63 + }
+318
flake.lock
··· 1 + { 2 + "nodes": { 3 + "crane": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1758758545, 7 + "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", 8 + "owner": "ipetkov", 9 + "repo": "crane", 10 + "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", 11 + "type": "github" 12 + }, 13 + "original": { 14 + "owner": "ipetkov", 15 + "ref": "v0.21.1", 16 + "repo": "crane", 17 + "type": "github" 18 + } 19 + }, 20 + "dream2nix": { 21 + "inputs": { 22 + "nixpkgs": [ 23 + "nci", 24 + "nixpkgs" 25 + ], 26 + "purescript-overlay": "purescript-overlay", 27 + "pyproject-nix": "pyproject-nix" 28 + }, 29 + "locked": { 30 + "lastModified": 1754978539, 31 + "narHash": "sha256-nrDovydywSKRbWim9Ynmgj8SBm8LK3DI2WuhIqzOHYI=", 32 + "owner": "nix-community", 33 + "repo": "dream2nix", 34 + "rev": "fbec3263cb4895ac86ee9506cdc4e6919a1a2214", 35 + "type": "github" 36 + }, 37 + "original": { 38 + "owner": "nix-community", 39 + "repo": "dream2nix", 40 + "type": "github" 41 + } 42 + }, 43 + "fenix": { 44 + "inputs": { 45 + "nixpkgs": [ 46 + "nixpkgs" 47 + ], 48 + "rust-analyzer-src": "rust-analyzer-src" 49 + }, 50 + "locked": { 51 + "lastModified": 1762584108, 52 + "narHash": "sha256-wZUW7dlXMXaRdvNbaADqhF8gg9bAfFiMV+iyFQiDv+Y=", 53 + "owner": "nix-community", 54 + "repo": "fenix", 55 + "rev": "32f3ad3b6c690061173e1ac16708874975ec6056", 56 + "type": "github" 57 + }, 58 + "original": { 59 + "owner": "nix-community", 60 + "repo": "fenix", 61 + "type": "github" 62 + } 63 + }, 64 + "flake-compat": { 65 + "flake": false, 66 + "locked": { 67 + "lastModified": 1696426674, 68 + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 69 + "owner": "edolstra", 70 + "repo": "flake-compat", 71 + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 72 + "type": "github" 73 + }, 74 + "original": { 75 + "owner": "edolstra", 76 + "repo": "flake-compat", 77 + "type": "github" 78 + } 79 + }, 80 + "mk-naked-shell": { 81 + "flake": false, 82 + "locked": { 83 + "lastModified": 1681286841, 84 + "narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=", 85 + "owner": "90-008", 86 + "repo": "mk-naked-shell", 87 + "rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd", 88 + "type": "github" 89 + }, 90 + "original": { 91 + "owner": "90-008", 92 + "repo": "mk-naked-shell", 93 + "type": "github" 94 + } 95 + }, 96 + "nci": { 97 + "inputs": { 98 + "crane": "crane", 99 + "dream2nix": "dream2nix", 100 + "mk-naked-shell": "mk-naked-shell", 101 + "nixpkgs": [ 102 + "nixpkgs" 103 + ], 104 + "parts": "parts", 105 + "rust-overlay": "rust-overlay", 106 + "treefmt": "treefmt" 107 + }, 108 + "locked": { 109 + "lastModified": 1762582646, 110 + "narHash": "sha256-MMzE4xccG+8qbLhdaZoeFDUKWUOn3B4lhp5dZmgukmM=", 111 + "owner": "90-008", 112 + "repo": "nix-cargo-integration", 113 + "rev": "0993c449377049fa8868a664e8290ac6658e0b9a", 114 + "type": "github" 115 + }, 116 + "original": { 117 + "owner": "90-008", 118 + "repo": "nix-cargo-integration", 119 + "type": "github" 120 + } 121 + }, 122 + "nixpkgs": { 123 + "locked": { 124 + "lastModified": 1762361079, 125 + "narHash": "sha256-lz718rr1BDpZBYk7+G8cE6wee3PiBUpn8aomG/vLLiY=", 126 + "owner": "nixos", 127 + "repo": "nixpkgs", 128 + "rev": "ffcdcf99d65c61956d882df249a9be53e5902ea5", 129 + "type": "github" 130 + }, 131 + "original": { 132 + "owner": "nixos", 133 + "ref": "nixpkgs-unstable", 134 + "repo": "nixpkgs", 135 + "type": "github" 136 + } 137 + }, 138 + "parts": { 139 + "inputs": { 140 + "nixpkgs-lib": [ 141 + "nci", 142 + "nixpkgs" 143 + ] 144 + }, 145 + "locked": { 146 + "lastModified": 1762440070, 147 + "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", 148 + "owner": "hercules-ci", 149 + "repo": "flake-parts", 150 + "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", 151 + "type": "github" 152 + }, 153 + "original": { 154 + "owner": "hercules-ci", 155 + "repo": "flake-parts", 156 + "type": "github" 157 + } 158 + }, 159 + "parts_2": { 160 + "inputs": { 161 + "nixpkgs-lib": [ 162 + "nixpkgs" 163 + ] 164 + }, 165 + "locked": { 166 + "lastModified": 1762440070, 167 + "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", 168 + "owner": "hercules-ci", 169 + "repo": "flake-parts", 170 + "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", 171 + "type": "github" 172 + }, 173 + "original": { 174 + "owner": "hercules-ci", 175 + "repo": "flake-parts", 176 + "type": "github" 177 + } 178 + }, 179 + "purescript-overlay": { 180 + "inputs": { 181 + "flake-compat": "flake-compat", 182 + "nixpkgs": [ 183 + "nci", 184 + "dream2nix", 185 + "nixpkgs" 186 + ], 187 + "slimlock": "slimlock" 188 + }, 189 + "locked": { 190 + "lastModified": 1728546539, 191 + "narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=", 192 + "owner": "thomashoneyman", 193 + "repo": "purescript-overlay", 194 + "rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4", 195 + "type": "github" 196 + }, 197 + "original": { 198 + "owner": "thomashoneyman", 199 + "repo": "purescript-overlay", 200 + "type": "github" 201 + } 202 + }, 203 + "pyproject-nix": { 204 + "inputs": { 205 + "nixpkgs": [ 206 + "nci", 207 + "dream2nix", 208 + "nixpkgs" 209 + ] 210 + }, 211 + "locked": { 212 + "lastModified": 1752481895, 213 + "narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=", 214 + "owner": "pyproject-nix", 215 + "repo": "pyproject.nix", 216 + "rev": "16ee295c25107a94e59a7fc7f2e5322851781162", 217 + "type": "github" 218 + }, 219 + "original": { 220 + "owner": "pyproject-nix", 221 + "repo": "pyproject.nix", 222 + "type": "github" 223 + } 224 + }, 225 + "root": { 226 + "inputs": { 227 + "fenix": "fenix", 228 + "nci": "nci", 229 + "nixpkgs": "nixpkgs", 230 + "parts": "parts_2" 231 + } 232 + }, 233 + "rust-analyzer-src": { 234 + "flake": false, 235 + "locked": { 236 + "lastModified": 1762438844, 237 + "narHash": "sha256-ApIKJf6CcMsV2nYBXhGF95BmZMO/QXPhgfSnkA/rVUo=", 238 + "owner": "rust-lang", 239 + "repo": "rust-analyzer", 240 + "rev": "4bf516ee5a960c1e2eee9fedd9b1c9e976a19c86", 241 + "type": "github" 242 + }, 243 + "original": { 244 + "owner": "rust-lang", 245 + "ref": "nightly", 246 + "repo": "rust-analyzer", 247 + "type": "github" 248 + } 249 + }, 250 + "rust-overlay": { 251 + "inputs": { 252 + "nixpkgs": [ 253 + "nci", 254 + "nixpkgs" 255 + ] 256 + }, 257 + "locked": { 258 + "lastModified": 1762569282, 259 + "narHash": "sha256-vINZAJpXQTZd5cfh06Rcw7hesH7sGSvi+Tn+HUieJn8=", 260 + "owner": "oxalica", 261 + "repo": "rust-overlay", 262 + "rev": "a35a6144b976f70827c2fe2f5c89d16d8f9179d8", 263 + "type": "github" 264 + }, 265 + "original": { 266 + "owner": "oxalica", 267 + "repo": "rust-overlay", 268 + "type": "github" 269 + } 270 + }, 271 + "slimlock": { 272 + "inputs": { 273 + "nixpkgs": [ 274 + "nci", 275 + "dream2nix", 276 + "purescript-overlay", 277 + "nixpkgs" 278 + ] 279 + }, 280 + "locked": { 281 + "lastModified": 1688756706, 282 + "narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=", 283 + "owner": "thomashoneyman", 284 + "repo": "slimlock", 285 + "rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c", 286 + "type": "github" 287 + }, 288 + "original": { 289 + "owner": "thomashoneyman", 290 + "repo": "slimlock", 291 + "type": "github" 292 + } 293 + }, 294 + "treefmt": { 295 + "inputs": { 296 + "nixpkgs": [ 297 + "nci", 298 + "nixpkgs" 299 + ] 300 + }, 301 + "locked": { 302 + "lastModified": 1762410071, 303 + "narHash": "sha256-aF5fvoZeoXNPxT0bejFUBXeUjXfHLSL7g+mjR/p5TEg=", 304 + "owner": "numtide", 305 + "repo": "treefmt-nix", 306 + "rev": "97a30861b13c3731a84e09405414398fbf3e109f", 307 + "type": "github" 308 + }, 309 + "original": { 310 + "owner": "numtide", 311 + "repo": "treefmt-nix", 312 + "type": "github" 313 + } 314 + } 315 + }, 316 + "root": "root", 317 + "version": 7 318 + }
+36
flake.nix
··· 1 + { 2 + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 3 + inputs.nci.url = "github:90-008/nix-cargo-integration"; 4 + inputs.nci.inputs.nixpkgs.follows = "nixpkgs"; 5 + inputs.parts.url = "github:hercules-ci/flake-parts"; 6 + inputs.parts.inputs.nixpkgs-lib.follows = "nixpkgs"; 7 + inputs.fenix = { 8 + url = "github:nix-community/fenix"; 9 + inputs.nixpkgs.follows = "nixpkgs"; 10 + }; 11 + 12 + outputs = inputs @ { 13 + parts, 14 + nci, 15 + ... 16 + }: 17 + parts.lib.mkFlake {inherit inputs;} { 18 + systems = ["x86_64-linux" "aarch64-darwin"]; 19 + imports = [ 20 + nci.flakeModule 21 + ./crates.nix 22 + ]; 23 + perSystem = { 24 + pkgs, 25 + config, 26 + ... 27 + }: let 28 + crateOutputs = config.nci.outputs."wisp-cli"; 29 + in { 30 + devShells.default = crateOutputs.devShell; 31 + packages.default = crateOutputs.packages.release; 32 + packages.wisp-cli-windows = crateOutputs.allTargets."x86_64-pc-windows-gnu".packages.release; 33 + packages.wisp-cli-darwin = crateOutputs.allTargets."aarch64-apple-darwin".packages.release; 34 + }; 35 + }; 36 + }
+14 -192
hosting-service/bun.lock
··· 7 7 "@atproto/api": "^0.17.4", 8 8 "@atproto/identity": "^0.4.9", 9 9 "@atproto/lexicon": "^0.5.1", 10 - "@atproto/sync": "^0.1.35", 10 + "@atproto/sync": "^0.1.36", 11 11 "@atproto/xrpc": "^0.7.5", 12 - "@elysiajs/node": "^1.4.1", 13 - "@elysiajs/opentelemetry": "latest", 14 - "elysia": "latest", 12 + "@hono/node-server": "^1.19.6", 13 + "hono": "^4.10.4", 15 14 "mime-types": "^2.1.35", 16 15 "multiformats": "^13.4.1", 17 16 "postgres": "^3.4.5", 18 17 }, 19 18 "devDependencies": { 19 + "@types/bun": "^1.3.1", 20 20 "@types/mime-types": "^2.1.4", 21 21 "@types/node": "^22.10.5", 22 22 "tsx": "^4.19.2", ··· 38 38 39 39 "@atproto/repo": ["@atproto/repo@0.8.10", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/common-web": "^0.4.3", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@ipld/dag-cbor": "^7.0.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "varint": "^6.0.0", "zod": "^3.23.8" } }, ""], 40 40 41 - "@atproto/sync": ["@atproto/sync@0.1.35", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/identity": "^0.4.9", "@atproto/lexicon": "^0.5.1", "@atproto/repo": "^0.8.10", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.9.5", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, ""], 41 + "@atproto/sync": ["@atproto/sync@0.1.36", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/identity": "^0.4.9", "@atproto/lexicon": "^0.5.1", "@atproto/repo": "^0.8.10", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.9.5", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, "sha512-HyF835Bmn8ps9BuXkmGjRrbgfv4K3fJdfEvXimEhTCntqIxQg0ttmOYDg/WBBmIRfkCB5ab+wS1PCGN8trr+FQ=="], 42 42 43 43 "@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, ""], 44 44 ··· 46 46 47 47 "@atproto/xrpc-server": ["@atproto/xrpc-server@0.9.5", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@atproto/xrpc": "^0.7.5", "cbor-x": "^1.5.1", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "uint8arrays": "3.0.0", "ws": "^8.12.0", "zod": "^3.23.8" } }, ""], 48 48 49 - "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], 50 - 51 49 "@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, ""], 52 50 53 - "@elysiajs/node": ["@elysiajs/node@1.4.1", "", { "dependencies": { "crossws": "^0.4.1", "srvx": "^0.8.9" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-2wAALwHK3IYi1XJPnxfp1xJsvps5FqqcQqe+QXjYlGQvsmSG+vI5wNDIuvIlB+6p9NE/laLbqV0aFromf3X7yg=="], 54 - 55 - "@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="], 56 - 57 51 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="], 58 52 59 53 "@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="], ··· 106 100 107 101 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="], 108 102 109 - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="], 110 - 111 - "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], 103 + "@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="], 112 104 113 105 "@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, ""], 114 106 115 - "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], 116 - 117 107 "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, ""], 118 108 119 109 "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, ""], 120 110 121 - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], 122 - 123 - "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="], 124 - 125 - "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="], 126 - 127 - "@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="], 128 - 129 - "@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+3MDfa5YQPGM3WXxW9kqGD85Q7s9wlEMVNhXXG7tYFLnIeaseUt9YtCeFhEDFzfEktacdFpOtXmJuNW8cHbU5A=="], 130 - 131 - "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KfWw49htbGGp9s8N4KI8EQ9XuqKJ0VG+yVYVYFiCYSjEV32qpQ5qZ9UZBzOZ6xRb+E16SXOSCT3RkqBVSABZ+g=="], 132 - 133 - "@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-GmahpUU/55hxfH4TP77ChOfftADsCq/nuri73I/AVLe2s4NIglvTsaACkFVZAVmnXXyPS00Fk3x27WS3yO07zA=="], 134 - 135 - "@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew=="], 136 - 137 - "@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="], 138 - 139 - "@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-E+uPj0yyvz81U9pvLZp3oHtFrEzNSqKGVkIViTQY1rH3TOobeJPSpLnTVXACnCwkPR5XeTvPnK3pZ2Kni8AFMg=="], 140 - 141 - "@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw=="], 142 - 143 - "@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-hmeZrUkFl1YMsgukSuHCFPYeF9df0hHoKeHUthRKFCxiURs+GwF1VuabuHmBMZnjTbsuvNjOB+JSs37Csem/5Q=="], 144 - 145 - "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Goi//m/7ZHeUedxTGVmEzH19NgqJY+Bzr6zXo1Rni1+hwqaksEyJ44gdlEMREu6dzX1DlAaH/qSykSVzdrdafA=="], 146 - 147 - "@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-V9TDSD3PjK1OREw2iT9TUTzNYEVWJk4Nhodzhp9eiz4onDMYmPy3LaGbPv81yIR6dUb/hNp/SIhpiCHwFUq2Vg=="], 148 - 149 - "@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-icxaKZ+jZL/NHXX8Aru4HGsrdhK0MLcuRXkX5G5IRmCgoRLw+Br6I/nMVozX2xjGGwV7hw2g+4Slj8K7s4HbVg=="], 150 - 151 - "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg=="], 152 - 153 - "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="], 154 - 155 - "@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CK2S+bFgOZ66Bsu5hlDeOX6cvW5FVtVjFFbWuaJP0ELxJKBB6HlbLZQ2phqz/uLj1cWap5xJr/PsR3iGoB7Vqw=="], 156 - 157 - "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="], 158 - 159 - "@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-blx9S2EI49Ycuw6VZq+bkpaIoiJFhsDuvFGhBIoH3vJ5oYjJ2U0s3fAM5jYft99xVIAv6HqoPtlP9gpVA2IZtA=="], 160 - 161 - "@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Mbm/LSFyAtQKP0AQah4AfGgsD+vsZcyreZoQ5okFBk33hU7AquU4TltgyL9dvaO8/Zkoud8/0gEvwfOZ5d7EPA=="], 162 - 163 - "@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="], 164 - 165 - "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA=="], 166 - 167 - "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="], 168 - 169 - "@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.200.0", "@opentelemetry/exporter-logs-otlp-http": "0.200.0", "@opentelemetry/exporter-logs-otlp-proto": "0.200.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.200.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.200.0", "@opentelemetry/exporter-prometheus": "0.200.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.200.0", "@opentelemetry/exporter-trace-otlp-http": "0.200.0", "@opentelemetry/exporter-trace-otlp-proto": "0.200.0", "@opentelemetry/exporter-zipkin": "2.0.0", "@opentelemetry/instrumentation": "0.200.0", "@opentelemetry/propagator-b3": "2.0.0", "@opentelemetry/propagator-jaeger": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/sdk-trace-node": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A=="], 170 - 171 - "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw=="], 172 - 173 - "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="], 174 - 175 - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], 176 - 177 - "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], 178 - 179 - "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], 180 - 181 - "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], 182 - 183 - "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], 184 - 185 - "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], 186 - 187 - "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], 188 - 189 - "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], 190 - 191 - "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], 192 - 193 - "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], 194 - 195 - "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], 196 - 197 - "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], 198 - 199 - "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], 200 - 201 - "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], 111 + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], 202 112 203 113 "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], 204 114 205 115 "@types/node": ["@types/node@22.18.12", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog=="], 206 116 207 - "@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="], 117 + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], 208 118 209 119 "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, ""], 210 120 211 121 "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, ""], 212 122 213 - "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], 214 - 215 - "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], 216 - 217 - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 218 - 219 - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 220 - 221 123 "array-flatten": ["array-flatten@1.1.1", "", {}, ""], 222 124 223 125 "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, ""], ··· 229 131 "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, ""], 230 132 231 133 "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, ""], 134 + 135 + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], 232 136 233 137 "bytes": ["bytes@3.1.2", "", {}, ""], 234 138 ··· 242 146 243 147 "cborg": ["cborg@1.10.2", "", { "bin": "cli.js" }, ""], 244 148 245 - "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], 246 - 247 - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], 248 - 249 - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 250 - 251 - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 252 - 253 149 "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, ""], 254 150 255 151 "content-type": ["content-type@1.0.5", "", {}, ""], 256 152 257 - "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], 153 + "cookie": ["cookie@0.7.1", "", {}, ""], 258 154 259 155 "cookie-signature": ["cookie-signature@1.0.6", "", {}, ""], 260 156 261 - "crossws": ["crossws@0.4.1", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-E7WKBcHVhAVrY6JYD5kteNqVq1GSZxqGrdSiwXR9at+XHi43HJoCQKXcCczR5LBnBquFZPsB3o7HklulKoBU5w=="], 157 + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 262 158 263 159 "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, ""], 264 160 ··· 272 168 273 169 "ee-first": ["ee-first@1.1.1", "", {}, ""], 274 170 275 - "elysia": ["elysia@1.4.13", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "exact-mirror": ">= 0.0.9", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-6QaWQEm7QN1UCo1TPpEjaRJPHUmnM7R29y6LY224frDGk5PrpAnWmdHkoZxkcv+JRWp1j2ROr2IHbxHbG/jRjw=="], 276 - 277 - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 278 - 279 171 "encodeurl": ["encodeurl@2.0.0", "", {}, ""], 280 172 281 173 "es-define-property": ["es-define-property@1.0.1", "", {}, ""], ··· 286 178 287 179 "esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": "bin/esbuild" }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="], 288 180 289 - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 290 - 291 181 "escape-html": ["escape-html@1.0.3", "", {}, ""], 292 182 293 183 "etag": ["etag@1.8.1", "", {}, ""], ··· 297 187 "eventemitter3": ["eventemitter3@4.0.7", "", {}, ""], 298 188 299 189 "events": ["events@3.3.0", "", {}, ""], 300 - 301 - "exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" } }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="], 302 190 303 191 "express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, ""], 304 192 305 - "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], 306 - 307 193 "fast-redact": ["fast-redact@3.5.0", "", {}, ""], 308 - 309 - "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], 310 - 311 - "file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="], 312 194 313 195 "finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, ""], 314 196 ··· 320 202 321 203 "function-bind": ["function-bind@1.1.2", "", {}, ""], 322 204 323 - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], 324 - 325 205 "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, ""], 326 206 327 207 "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, ""], ··· 335 215 "has-symbols": ["has-symbols@1.1.0", "", {}, ""], 336 216 337 217 "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, ""], 218 + 219 + "hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="], 338 220 339 221 "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, ""], 340 222 ··· 342 224 343 225 "ieee754": ["ieee754@1.2.1", "", {}, ""], 344 226 345 - "import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="], 346 - 347 227 "inherits": ["inherits@2.0.4", "", {}, ""], 348 228 349 229 "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, ""], 350 230 351 - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], 352 - 353 - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 354 - 355 231 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, ""], 356 232 357 - "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], 358 - 359 - "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], 360 - 361 233 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, ""], 362 234 363 235 "media-typer": ["media-typer@0.3.0", "", {}, ""], 364 - 365 - "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], 366 236 367 237 "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, ""], 368 238 ··· 374 244 375 245 "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""], 376 246 377 - "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], 378 - 379 247 "ms": ["ms@2.0.0", "", {}, ""], 380 248 381 249 "multiformats": ["multiformats@13.4.1", "", {}, ""], ··· 389 257 "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, ""], 390 258 391 259 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, ""], 392 - 393 - "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], 394 260 395 261 "p-finally": ["p-finally@1.0.0", "", {}, ""], 396 262 ··· 400 266 401 267 "parseurl": ["parseurl@1.3.3", "", {}, ""], 402 268 403 - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 404 - 405 269 "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, ""], 406 270 407 271 "pino": ["pino@8.21.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^1.2.0", "pino-std-serializers": "^6.0.0", "process-warning": "^3.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^3.7.0", "thread-stream": "^2.6.0" }, "bin": "bin.js" }, ""], ··· 416 280 417 281 "process-warning": ["process-warning@3.0.0", "", {}, ""], 418 282 419 - "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], 420 - 421 283 "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, ""], 422 284 423 285 "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, ""], ··· 434 296 435 297 "real-require": ["real-require@0.2.0", "", {}, ""], 436 298 437 - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], 438 - 439 - "require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], 440 - 441 - "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], 442 - 443 299 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], 444 300 445 301 "safe-buffer": ["safe-buffer@5.2.1", "", {}, ""], ··· 454 310 455 311 "setprototypeof": ["setprototypeof@1.2.0", "", {}, ""], 456 312 457 - "shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="], 458 - 459 313 "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, ""], 460 314 461 315 "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, ""], ··· 468 322 469 323 "split2": ["split2@4.2.0", "", {}, ""], 470 324 471 - "srvx": ["srvx@0.8.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ=="], 472 - 473 325 "statuses": ["statuses@2.0.1", "", {}, ""], 474 - 475 - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 476 326 477 327 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, ""], 478 328 479 - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 480 - 481 - "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], 482 - 483 - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 484 - 485 329 "thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, ""], 486 330 487 331 "tlds": ["tlds@1.261.0", "", { "bin": "bin.js" }, ""], 488 332 489 333 "toidentifier": ["toidentifier@1.0.1", "", {}, ""], 490 334 491 - "token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], 492 - 493 335 "tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="], 494 336 495 337 "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, ""], 496 - 497 - "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], 498 338 499 339 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, ""], 500 340 ··· 508 348 509 349 "vary": ["vary@1.1.2", "", {}, ""], 510 350 511 - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 512 - 513 351 "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, ""], 514 352 515 - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], 516 - 517 - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], 518 - 519 - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], 520 - 521 353 "zod": ["zod@3.25.76", "", {}, ""], 522 354 523 355 "@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, ""], ··· 534 366 535 367 "@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, ""], 536 368 537 - "@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 538 - 539 - "express/cookie": ["cookie@0.7.1", "", {}, ""], 540 - 541 - "require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 542 - 543 369 "send/encodeurl": ["encodeurl@1.0.2", "", {}, ""], 544 370 545 371 "send/ms": ["ms@2.1.3", "", {}, ""], 546 372 547 373 "uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, ""], 548 - 549 - "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 550 - 551 - "require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 552 374 } 553 375 }
+8 -5
hosting-service/package.json
··· 3 3 "version": "1.0.0", 4 4 "type": "module", 5 5 "scripts": { 6 - "dev": "tsx watch src/index.ts", 7 - "start": "node --loader tsx src/index.ts" 6 + "dev": "tsx --env-file=.env watch src/index.ts", 7 + "build": "tsc", 8 + "start": "tsx --env-file=.env src/index.ts", 9 + "backfill": "tsx --env-file=.env src/index.ts --backfill" 8 10 }, 9 11 "dependencies": { 10 12 "@atproto/api": "^0.17.4", 11 13 "@atproto/identity": "^0.4.9", 12 14 "@atproto/lexicon": "^0.5.1", 13 - "@atproto/sync": "^0.1.35", 15 + "@atproto/sync": "^0.1.36", 14 16 "@atproto/xrpc": "^0.7.5", 15 - "@elysiajs/opentelemetry": "latest", 16 - "elysia": "latest", 17 + "@hono/node-server": "^1.19.6", 18 + "hono": "^4.10.4", 17 19 "mime-types": "^2.1.35", 18 20 "multiformats": "^13.4.1", 19 21 "postgres": "^3.4.5" 20 22 }, 21 23 "devDependencies": { 24 + "@types/bun": "^1.3.1", 22 25 "@types/mime-types": "^2.1.4", 23 26 "@types/node": "^22.10.5", 24 27 "tsx": "^4.19.2"
+33 -10
hosting-service/src/index.ts
··· 1 1 import app from './server'; 2 + import { serve } from '@hono/node-server'; 2 3 import { FirehoseWorker } from './lib/firehose'; 3 4 import { logger } from './lib/observability'; 4 5 import { mkdirSync, existsSync } from 'fs'; 6 + import { backfillCache } from './lib/backfill'; 5 7 6 8 const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001; 7 - const CACHE_DIR = './cache/sites'; 9 + const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; 10 + 11 + // Parse CLI arguments 12 + const args = process.argv.slice(2); 13 + const hasBackfillFlag = args.includes('--backfill'); 14 + const backfillOnStartup = hasBackfillFlag || process.env.BACKFILL_ON_STARTUP === 'true'; 8 15 9 16 // Ensure cache directory exists 10 17 if (!existsSync(CACHE_DIR)) { ··· 18 25 }); 19 26 20 27 firehose.start(); 28 + 29 + // Run backfill if requested 30 + if (backfillOnStartup) { 31 + console.log('๐Ÿ”„ Backfill requested, starting cache backfill...'); 32 + backfillCache({ 33 + skipExisting: true, 34 + concurrency: 3, 35 + }).then((stats) => { 36 + console.log('โœ… Cache backfill completed'); 37 + }).catch((err) => { 38 + console.error('โŒ Cache backfill error:', err); 39 + }); 40 + } 21 41 22 42 // Add health check endpoint 23 - app.get('/health', () => { 43 + app.get('/health', (c) => { 24 44 const firehoseHealth = firehose.getHealth(); 25 - return { 45 + return c.json({ 26 46 status: 'ok', 27 47 firehose: firehoseHealth, 28 - }; 48 + }); 49 + }); 50 + 51 + // Start HTTP server with Node.js adapter 52 + const server = serve({ 53 + fetch: app.fetch, 54 + port: PORT, 29 55 }); 30 56 31 - // Start HTTP server 32 - app.listen(PORT, () => { 33 - console.log(` 57 + console.log(` 34 58 Wisp Hosting Service 35 59 36 60 Server: http://localhost:${PORT} ··· 38 62 Cache: ${CACHE_DIR} 39 63 Firehose: Connected to Firehose 40 64 `); 41 - }); 42 65 43 66 // Graceful shutdown 44 67 process.on('SIGINT', async () => { 45 68 console.log('\n๐Ÿ›‘ Shutting down...'); 46 69 firehose.stop(); 47 - app.stop(); 70 + server.close(); 48 71 process.exit(0); 49 72 }); 50 73 51 74 process.on('SIGTERM', async () => { 52 75 console.log('\n๐Ÿ›‘ Shutting down...'); 53 76 firehose.stop(); 54 - app.stop(); 77 + server.close(); 55 78 process.exit(0); 56 79 });
+1 -1
hosting-service/src/lexicon/types/place/wisp/fs.ts
··· 2 2 * GENERATED CODE - DO NOT MODIFY 3 3 */ 4 4 import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 - import { CID } from 'multiformats/cid' 5 + import { CID } from 'multiformats' 6 6 import { validate as _validate } from '../../../lexicons' 7 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 8
+136
hosting-service/src/lib/backfill.ts
··· 1 + import { getAllSites } from './db'; 2 + import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils'; 3 + import { logger } from './observability'; 4 + 5 + export interface BackfillOptions { 6 + skipExisting?: boolean; // Skip sites already in cache 7 + concurrency?: number; // Number of sites to cache concurrently 8 + maxSites?: number; // Maximum number of sites to backfill (for testing) 9 + } 10 + 11 + export interface BackfillStats { 12 + total: number; 13 + cached: number; 14 + skipped: number; 15 + failed: number; 16 + duration: number; 17 + } 18 + 19 + /** 20 + * Backfill all sites from the database into the local cache 21 + */ 22 + export async function backfillCache(options: BackfillOptions = {}): Promise<BackfillStats> { 23 + const { 24 + skipExisting = true, 25 + concurrency = 3, 26 + maxSites, 27 + } = options; 28 + 29 + const startTime = Date.now(); 30 + const stats: BackfillStats = { 31 + total: 0, 32 + cached: 0, 33 + skipped: 0, 34 + failed: 0, 35 + duration: 0, 36 + }; 37 + 38 + logger.info('Starting cache backfill', { skipExisting, concurrency, maxSites }); 39 + console.log(` 40 + โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— 41 + โ•‘ CACHE BACKFILL STARTING โ•‘ 42 + โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 43 + `); 44 + 45 + try { 46 + // Get all sites from database 47 + let sites = await getAllSites(); 48 + stats.total = sites.length; 49 + 50 + logger.info(`Found ${sites.length} sites in database`); 51 + console.log(`๐Ÿ“Š Found ${sites.length} sites in database`); 52 + 53 + // Limit if specified 54 + if (maxSites && maxSites > 0) { 55 + sites = sites.slice(0, maxSites); 56 + console.log(`โš™๏ธ Limited to ${maxSites} sites for backfill`); 57 + } 58 + 59 + // Process sites in batches 60 + const batches: typeof sites[] = []; 61 + for (let i = 0; i < sites.length; i += concurrency) { 62 + batches.push(sites.slice(i, i + concurrency)); 63 + } 64 + 65 + let processed = 0; 66 + for (const batch of batches) { 67 + await Promise.all( 68 + batch.map(async (site) => { 69 + try { 70 + // Check if already cached 71 + if (skipExisting && isCached(site.did, site.rkey)) { 72 + stats.skipped++; 73 + processed++; 74 + logger.debug(`Skipping already cached site`, { did: site.did, rkey: site.rkey }); 75 + console.log(`โญ๏ธ [${processed}/${sites.length}] Skipped (cached): ${site.display_name || site.rkey}`); 76 + return; 77 + } 78 + 79 + // Fetch site record 80 + const siteData = await fetchSiteRecord(site.did, site.rkey); 81 + if (!siteData) { 82 + stats.failed++; 83 + processed++; 84 + logger.error('Site record not found during backfill', null, { did: site.did, rkey: site.rkey }); 85 + console.log(`โŒ [${processed}/${sites.length}] Failed (not found): ${site.display_name || site.rkey}`); 86 + return; 87 + } 88 + 89 + // Get PDS endpoint 90 + const pdsEndpoint = await getPdsForDid(site.did); 91 + if (!pdsEndpoint) { 92 + stats.failed++; 93 + processed++; 94 + logger.error('PDS not found during backfill', null, { did: site.did }); 95 + console.log(`โŒ [${processed}/${sites.length}] Failed (no PDS): ${site.display_name || site.rkey}`); 96 + return; 97 + } 98 + 99 + // Download and cache site 100 + await downloadAndCacheSite(site.did, site.rkey, siteData.record, pdsEndpoint, siteData.cid); 101 + stats.cached++; 102 + processed++; 103 + logger.info('Successfully cached site during backfill', { did: site.did, rkey: site.rkey }); 104 + console.log(`โœ… [${processed}/${sites.length}] Cached: ${site.display_name || site.rkey}`); 105 + } catch (err) { 106 + stats.failed++; 107 + processed++; 108 + logger.error('Failed to cache site during backfill', err, { did: site.did, rkey: site.rkey }); 109 + console.log(`โŒ [${processed}/${sites.length}] Failed: ${site.display_name || site.rkey}`); 110 + } 111 + }) 112 + ); 113 + } 114 + 115 + stats.duration = Date.now() - startTime; 116 + 117 + console.log(` 118 + โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— 119 + โ•‘ CACHE BACKFILL COMPLETED โ•‘ 120 + โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 121 + 122 + ๐Ÿ“Š Total Sites: ${stats.total} 123 + โœ… Cached: ${stats.cached} 124 + โญ๏ธ Skipped: ${stats.skipped} 125 + โŒ Failed: ${stats.failed} 126 + โฑ๏ธ Duration: ${(stats.duration / 1000).toFixed(2)}s 127 + `); 128 + 129 + logger.info('Cache backfill completed', stats); 130 + } catch (err) { 131 + logger.error('Cache backfill failed', err); 132 + console.error('โŒ Cache backfill failed:', err); 133 + } 134 + 135 + return stats; 136 + }
+27 -86
hosting-service/src/lib/db.ts
··· 1 1 import postgres from 'postgres'; 2 + import { createHash } from 'crypto'; 2 3 3 4 const sql = postgres( 4 5 process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp', ··· 21 22 verified: boolean; 22 23 } 23 24 24 - // In-memory cache with TTL 25 - interface CacheEntry<T> { 26 - data: T; 27 - expiry: number; 28 - } 29 - 30 - const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes 31 - 32 - class SimpleCache<T> { 33 - private cache = new Map<string, CacheEntry<T>>(); 34 - 35 - get(key: string): T | null { 36 - const entry = this.cache.get(key); 37 - if (!entry) return null; 38 25 39 - if (Date.now() > entry.expiry) { 40 - this.cache.delete(key); 41 - return null; 42 - } 43 - 44 - return entry.data; 45 - } 46 - 47 - set(key: string, data: T): void { 48 - this.cache.set(key, { 49 - data, 50 - expiry: Date.now() + CACHE_TTL_MS, 51 - }); 52 - } 53 - 54 - // Periodic cleanup to prevent memory leaks 55 - cleanup(): void { 56 - const now = Date.now(); 57 - for (const [key, entry] of this.cache.entries()) { 58 - if (now > entry.expiry) { 59 - this.cache.delete(key); 60 - } 61 - } 62 - } 63 - } 64 - 65 - // Create cache instances 66 - const wispDomainCache = new SimpleCache<DomainLookup | null>(); 67 - const customDomainCache = new SimpleCache<CustomDomainLookup | null>(); 68 - const customDomainHashCache = new SimpleCache<CustomDomainLookup | null>(); 69 - 70 - // Run cleanup every 5 minutes 71 - setInterval(() => { 72 - wispDomainCache.cleanup(); 73 - customDomainCache.cleanup(); 74 - customDomainHashCache.cleanup(); 75 - }, 5 * 60 * 1000); 76 26 77 27 export async function getWispDomain(domain: string): Promise<DomainLookup | null> { 78 28 const key = domain.toLowerCase(); 79 29 80 - // Check cache first 81 - const cached = wispDomainCache.get(key); 82 - if (cached !== null) { 83 - return cached; 84 - } 85 - 86 30 // Query database 87 31 const result = await sql<DomainLookup[]>` 88 32 SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1 89 33 `; 90 34 const data = result[0] || null; 91 - 92 - // Store in cache 93 - wispDomainCache.set(key, data); 94 35 95 36 return data; 96 37 } ··· 98 39 export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> { 99 40 const key = domain.toLowerCase(); 100 41 101 - // Check cache first 102 - const cached = customDomainCache.get(key); 103 - if (cached !== null) { 104 - return cached; 105 - } 106 - 107 42 // Query database 108 43 const result = await sql<CustomDomainLookup[]>` 109 44 SELECT id, domain, did, rkey, verified FROM custom_domains ··· 111 46 `; 112 47 const data = result[0] || null; 113 48 114 - // Store in cache 115 - customDomainCache.set(key, data); 116 - 117 49 return data; 118 50 } 119 51 120 52 export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> { 121 - // Check cache first 122 - const cached = customDomainHashCache.get(hash); 123 - if (cached !== null) { 124 - return cached; 125 - } 126 - 127 53 // Query database 128 54 const result = await sql<CustomDomainLookup[]>` 129 55 SELECT id, domain, did, rkey, verified FROM custom_domains 130 56 WHERE id = ${hash} AND verified = true LIMIT 1 131 57 `; 132 58 const data = result[0] || null; 133 - 134 - // Store in cache 135 - customDomainHashCache.set(hash, data); 136 59 137 60 return data; 138 61 } ··· 158 81 } 159 82 } 160 83 84 + export interface SiteRecord { 85 + did: string; 86 + rkey: string; 87 + display_name?: string; 88 + } 89 + 90 + export async function getAllSites(): Promise<SiteRecord[]> { 91 + try { 92 + const result = await sql<SiteRecord[]>` 93 + SELECT did, rkey, display_name FROM sites 94 + ORDER BY created_at DESC 95 + `; 96 + return result; 97 + } catch (err) { 98 + console.error('Failed to get all sites', err); 99 + return []; 100 + } 101 + } 102 + 161 103 /** 162 104 * Generate a numeric lock ID from a string key 163 105 * PostgreSQL advisory locks use bigint (64-bit signed integer) 164 106 */ 165 107 function stringToLockId(key: string): bigint { 166 - let hash = 0n; 167 - for (let i = 0; i < key.length; i++) { 168 - const char = BigInt(key.charCodeAt(i)); 169 - hash = ((hash << 5n) - hash + char) & 0x7FFFFFFFFFFFFFFFn; // Keep within signed int64 range 170 - } 171 - return hash; 108 + const hash = createHash('sha256').update(key).digest('hex'); 109 + // Take first 16 hex characters (64 bits) and convert to bigint 110 + const hashNum = BigInt('0x' + hash.substring(0, 16)); 111 + // Keep within signed int64 range 112 + return hashNum & 0x7FFFFFFFFFFFFFFFn; 172 113 } 173 114 174 115 /** ··· 180 121 const lockId = stringToLockId(key); 181 122 182 123 try { 183 - const result = await sql`SELECT pg_try_advisory_lock(${lockId}) as acquired`; 124 + const result = await sql`SELECT pg_try_advisory_lock(${Number(lockId)}) as acquired`; 184 125 return result[0]?.acquired === true; 185 126 } catch (err) { 186 127 console.error('Failed to acquire lock', { key, error: err }); ··· 195 136 const lockId = stringToLockId(key); 196 137 197 138 try { 198 - await sql`SELECT pg_advisory_unlock(${lockId})`; 139 + await sql`SELECT pg_advisory_unlock(${Number(lockId)})`; 199 140 } catch (err) { 200 141 console.error('Failed to release lock', { key, error: err }); 201 142 }
+259 -219
hosting-service/src/lib/firehose.ts
··· 1 - import { existsSync, rmSync } from 'fs'; 2 - import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils'; 3 - import { upsertSite, tryAcquireLock, releaseLock } from './db'; 4 - import { safeFetch } from './safe-fetch'; 5 - import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'; 6 - import { Firehose } from '@atproto/sync'; 7 - import { IdResolver } from '@atproto/identity'; 1 + import { existsSync, rmSync } from 'fs' 2 + import { 3 + getPdsForDid, 4 + downloadAndCacheSite, 5 + extractBlobCid, 6 + fetchSiteRecord 7 + } from './utils' 8 + import { upsertSite, tryAcquireLock, releaseLock } from './db' 9 + import { safeFetch } from './safe-fetch' 10 + import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs' 11 + import { Firehose } from '@atproto/sync' 12 + import { IdResolver } from '@atproto/identity' 8 13 9 - const CACHE_DIR = './cache/sites'; 14 + const CACHE_DIR = './cache/sites' 10 15 11 16 export class FirehoseWorker { 12 - private firehose: Firehose | null = null; 13 - private idResolver: IdResolver; 14 - private isShuttingDown = false; 15 - private lastEventTime = Date.now(); 17 + private firehose: Firehose | null = null 18 + private idResolver: IdResolver 19 + private isShuttingDown = false 20 + private lastEventTime = Date.now() 16 21 17 - constructor( 18 - private logger?: (msg: string, data?: Record<string, unknown>) => void, 19 - ) { 20 - this.idResolver = new IdResolver(); 21 - } 22 + constructor( 23 + private logger?: (msg: string, data?: Record<string, unknown>) => void 24 + ) { 25 + this.idResolver = new IdResolver() 26 + } 22 27 23 - private log(msg: string, data?: Record<string, unknown>) { 24 - const log = this.logger || console.log; 25 - log(`[FirehoseWorker] ${msg}`, data || {}); 26 - } 28 + private log(msg: string, data?: Record<string, unknown>) { 29 + const log = this.logger || console.log 30 + log(`[FirehoseWorker] ${msg}`, data || {}) 31 + } 27 32 28 - start() { 29 - this.log('Starting firehose worker'); 30 - this.connect(); 31 - } 33 + start() { 34 + this.log('Starting firehose worker') 35 + this.connect() 36 + } 32 37 33 - stop() { 34 - this.log('Stopping firehose worker'); 35 - this.isShuttingDown = true; 38 + stop() { 39 + this.log('Stopping firehose worker') 40 + this.isShuttingDown = true 36 41 37 - if (this.firehose) { 38 - this.firehose.destroy(); 39 - this.firehose = null; 40 - } 41 - } 42 + if (this.firehose) { 43 + this.firehose.destroy() 44 + this.firehose = null 45 + } 46 + } 42 47 43 - private connect() { 44 - if (this.isShuttingDown) return; 48 + private connect() { 49 + if (this.isShuttingDown) return 45 50 46 - this.log('Connecting to AT Protocol firehose'); 51 + this.log('Connecting to AT Protocol firehose') 47 52 48 - this.firehose = new Firehose({ 49 - idResolver: this.idResolver, 50 - service: 'wss://bsky.network', 51 - filterCollections: ['place.wisp.fs'], 52 - handleEvent: async (evt) => { 53 - this.lastEventTime = Date.now(); 53 + this.firehose = new Firehose({ 54 + idResolver: this.idResolver, 55 + service: 'wss://bsky.network', 56 + filterCollections: ['place.wisp.fs'], 57 + handleEvent: async (evt: any) => { 58 + this.lastEventTime = Date.now() 54 59 55 - // Watch for write events 56 - if (evt.event === 'create' || evt.event === 'update') { 57 - const record = evt.record; 60 + // Watch for write events 61 + if (evt.event === 'create' || evt.event === 'update') { 62 + const record = evt.record 58 63 59 - // If the write is a valid place.wisp.fs record 60 - if ( 61 - evt.collection === 'place.wisp.fs' && 62 - isRecord(record) && 63 - validateRecord(record).success 64 - ) { 65 - this.log('Received place.wisp.fs event', { 66 - did: evt.did, 67 - event: evt.event, 68 - rkey: evt.rkey, 69 - }); 64 + // If the write is a valid place.wisp.fs record 65 + if ( 66 + evt.collection === 'place.wisp.fs' && 67 + isRecord(record) && 68 + validateRecord(record).success 69 + ) { 70 + this.log('Received place.wisp.fs event', { 71 + did: evt.did, 72 + event: evt.event, 73 + rkey: evt.rkey 74 + }) 70 75 71 - try { 72 - await this.handleCreateOrUpdate(evt.did, evt.rkey, record, evt.cid?.toString()); 73 - } catch (err) { 74 - this.log('Error handling event', { 75 - did: evt.did, 76 - event: evt.event, 77 - rkey: evt.rkey, 78 - error: err instanceof Error ? err.message : String(err), 79 - }); 80 - } 81 - } 82 - } else if (evt.event === 'delete' && evt.collection === 'place.wisp.fs') { 83 - this.log('Received delete event', { 84 - did: evt.did, 85 - rkey: evt.rkey, 86 - }); 76 + try { 77 + await this.handleCreateOrUpdate( 78 + evt.did, 79 + evt.rkey, 80 + record, 81 + evt.cid?.toString() 82 + ) 83 + } catch (err) { 84 + this.log('Error handling event', { 85 + did: evt.did, 86 + event: evt.event, 87 + rkey: evt.rkey, 88 + error: 89 + err instanceof Error 90 + ? err.message 91 + : String(err) 92 + }) 93 + } 94 + } 95 + } else if ( 96 + evt.event === 'delete' && 97 + evt.collection === 'place.wisp.fs' 98 + ) { 99 + this.log('Received delete event', { 100 + did: evt.did, 101 + rkey: evt.rkey 102 + }) 87 103 88 - try { 89 - await this.handleDelete(evt.did, evt.rkey); 90 - } catch (err) { 91 - this.log('Error handling delete', { 92 - did: evt.did, 93 - rkey: evt.rkey, 94 - error: err instanceof Error ? err.message : String(err), 95 - }); 96 - } 97 - } 98 - }, 99 - onError: (err) => { 100 - this.log('Firehose error', { 101 - error: err instanceof Error ? err.message : String(err), 102 - stack: err instanceof Error ? err.stack : undefined, 103 - fullError: err, 104 - }); 105 - console.error('Full firehose error:', err); 106 - }, 107 - }); 104 + try { 105 + await this.handleDelete(evt.did, evt.rkey) 106 + } catch (err) { 107 + this.log('Error handling delete', { 108 + did: evt.did, 109 + rkey: evt.rkey, 110 + error: 111 + err instanceof Error ? err.message : String(err) 112 + }) 113 + } 114 + } 115 + }, 116 + onError: (err: any) => { 117 + this.log('Firehose error', { 118 + error: err instanceof Error ? err.message : String(err), 119 + stack: err instanceof Error ? err.stack : undefined, 120 + fullError: err 121 + }) 122 + console.error('Full firehose error:', err) 123 + } 124 + }) 108 125 109 - this.firehose.start(); 110 - this.log('Firehose started'); 111 - } 126 + this.firehose.start() 127 + this.log('Firehose started') 128 + } 112 129 113 - private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) { 114 - this.log('Processing create/update', { did, site }); 130 + private async handleCreateOrUpdate( 131 + did: string, 132 + site: string, 133 + record: any, 134 + eventCid?: string 135 + ) { 136 + this.log('Processing create/update', { did, site }) 115 137 116 - // Record is already validated in handleEvent 117 - const fsRecord = record; 138 + // Record is already validated in handleEvent 139 + const fsRecord = record 118 140 119 - const pdsEndpoint = await getPdsForDid(did); 120 - if (!pdsEndpoint) { 121 - this.log('Could not resolve PDS for DID', { did }); 122 - return; 123 - } 141 + const pdsEndpoint = await getPdsForDid(did) 142 + if (!pdsEndpoint) { 143 + this.log('Could not resolve PDS for DID', { did }) 144 + return 145 + } 124 146 125 - this.log('Resolved PDS', { did, pdsEndpoint }); 147 + this.log('Resolved PDS', { did, pdsEndpoint }) 126 148 127 - // Verify record exists on PDS and fetch its CID 128 - let verifiedCid: string; 129 - try { 130 - const result = await fetchSiteRecord(did, site); 149 + // Verify record exists on PDS and fetch its CID 150 + let verifiedCid: string 151 + try { 152 + const result = await fetchSiteRecord(did, site) 131 153 132 - if (!result) { 133 - this.log('Record not found on PDS, skipping cache', { did, site }); 134 - return; 135 - } 154 + if (!result) { 155 + this.log('Record not found on PDS, skipping cache', { 156 + did, 157 + site 158 + }) 159 + return 160 + } 136 161 137 - verifiedCid = result.cid; 162 + verifiedCid = result.cid 138 163 139 - // Verify event CID matches PDS CID (prevent cache poisoning) 140 - if (eventCid && eventCid !== verifiedCid) { 141 - this.log('CID mismatch detected - potential spoofed event', { 142 - did, 143 - site, 144 - eventCid, 145 - verifiedCid 146 - }); 147 - return; 148 - } 164 + // Verify event CID matches PDS CID (prevent cache poisoning) 165 + if (eventCid && eventCid !== verifiedCid) { 166 + this.log('CID mismatch detected - potential spoofed event', { 167 + did, 168 + site, 169 + eventCid, 170 + verifiedCid 171 + }) 172 + return 173 + } 149 174 150 - this.log('Record verified on PDS', { did, site, cid: verifiedCid }); 151 - } catch (err) { 152 - this.log('Failed to verify record on PDS', { 153 - did, 154 - site, 155 - error: err instanceof Error ? err.message : String(err), 156 - }); 157 - return; 158 - } 175 + this.log('Record verified on PDS', { did, site, cid: verifiedCid }) 176 + } catch (err) { 177 + this.log('Failed to verify record on PDS', { 178 + did, 179 + site, 180 + error: err instanceof Error ? err.message : String(err) 181 + }) 182 + return 183 + } 159 184 160 - // Cache the record with verified CID (uses atomic swap internally) 161 - // All instances cache locally for edge serving 162 - await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid); 185 + // Cache the record with verified CID (uses atomic swap internally) 186 + // All instances cache locally for edge serving 187 + await downloadAndCacheSite( 188 + did, 189 + site, 190 + fsRecord, 191 + pdsEndpoint, 192 + verifiedCid 193 + ) 163 194 164 - // Acquire distributed lock only for database write to prevent duplicate writes 165 - const lockKey = `db:upsert:${did}:${site}`; 166 - const lockAcquired = await tryAcquireLock(lockKey); 195 + // Acquire distributed lock only for database write to prevent duplicate writes 196 + const lockKey = `db:upsert:${did}:${site}` 197 + const lockAcquired = await tryAcquireLock(lockKey) 167 198 168 - if (!lockAcquired) { 169 - this.log('Another instance is writing to DB, skipping upsert', { did, site }); 170 - this.log('Successfully processed create/update (cached locally)', { did, site }); 171 - return; 172 - } 199 + if (!lockAcquired) { 200 + this.log('Another instance is writing to DB, skipping upsert', { 201 + did, 202 + site 203 + }) 204 + this.log('Successfully processed create/update (cached locally)', { 205 + did, 206 + site 207 + }) 208 + return 209 + } 173 210 174 - try { 175 - // Upsert site to database (only one instance does this) 176 - await upsertSite(did, site, fsRecord.site); 177 - this.log('Successfully processed create/update (cached + DB updated)', { did, site }); 178 - } finally { 179 - // Always release lock, even if DB write fails 180 - await releaseLock(lockKey); 181 - } 182 - } 211 + try { 212 + // Upsert site to database (only one instance does this) 213 + await upsertSite(did, site, fsRecord.site) 214 + this.log( 215 + 'Successfully processed create/update (cached + DB updated)', 216 + { did, site } 217 + ) 218 + } finally { 219 + // Always release lock, even if DB write fails 220 + await releaseLock(lockKey) 221 + } 222 + } 183 223 184 - private async handleDelete(did: string, site: string) { 185 - this.log('Processing delete', { did, site }); 224 + private async handleDelete(did: string, site: string) { 225 + this.log('Processing delete', { did, site }) 186 226 187 - // All instances should delete their local cache (no lock needed) 188 - const pdsEndpoint = await getPdsForDid(did); 189 - if (!pdsEndpoint) { 190 - this.log('Could not resolve PDS for DID', { did }); 191 - return; 192 - } 227 + // All instances should delete their local cache (no lock needed) 228 + const pdsEndpoint = await getPdsForDid(did) 229 + if (!pdsEndpoint) { 230 + this.log('Could not resolve PDS for DID', { did }) 231 + return 232 + } 193 233 194 - // Verify record is actually deleted from PDS 195 - try { 196 - const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`; 197 - const recordRes = await safeFetch(recordUrl); 234 + // Verify record is actually deleted from PDS 235 + try { 236 + const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}` 237 + const recordRes = await safeFetch(recordUrl) 198 238 199 - if (recordRes.ok) { 200 - this.log('Record still exists on PDS, not deleting cache', { 201 - did, 202 - site, 203 - }); 204 - return; 205 - } 239 + if (recordRes.ok) { 240 + this.log('Record still exists on PDS, not deleting cache', { 241 + did, 242 + site 243 + }) 244 + return 245 + } 206 246 207 - this.log('Verified record is deleted from PDS', { 208 - did, 209 - site, 210 - status: recordRes.status, 211 - }); 212 - } catch (err) { 213 - this.log('Error verifying deletion on PDS', { 214 - did, 215 - site, 216 - error: err instanceof Error ? err.message : String(err), 217 - }); 218 - } 247 + this.log('Verified record is deleted from PDS', { 248 + did, 249 + site, 250 + status: recordRes.status 251 + }) 252 + } catch (err) { 253 + this.log('Error verifying deletion on PDS', { 254 + did, 255 + site, 256 + error: err instanceof Error ? err.message : String(err) 257 + }) 258 + } 219 259 220 - // Delete cache 221 - this.deleteCache(did, site); 260 + // Delete cache 261 + this.deleteCache(did, site) 222 262 223 - this.log('Successfully processed delete', { did, site }); 224 - } 263 + this.log('Successfully processed delete', { did, site }) 264 + } 225 265 226 - private deleteCache(did: string, site: string) { 227 - const cacheDir = `${CACHE_DIR}/${did}/${site}`; 266 + private deleteCache(did: string, site: string) { 267 + const cacheDir = `${CACHE_DIR}/${did}/${site}` 228 268 229 - if (!existsSync(cacheDir)) { 230 - this.log('Cache directory does not exist, nothing to delete', { 231 - did, 232 - site, 233 - }); 234 - return; 235 - } 269 + if (!existsSync(cacheDir)) { 270 + this.log('Cache directory does not exist, nothing to delete', { 271 + did, 272 + site 273 + }) 274 + return 275 + } 236 276 237 - try { 238 - rmSync(cacheDir, { recursive: true, force: true }); 239 - this.log('Cache deleted', { did, site, path: cacheDir }); 240 - } catch (err) { 241 - this.log('Failed to delete cache', { 242 - did, 243 - site, 244 - path: cacheDir, 245 - error: err instanceof Error ? err.message : String(err), 246 - }); 247 - } 248 - } 277 + try { 278 + rmSync(cacheDir, { recursive: true, force: true }) 279 + this.log('Cache deleted', { did, site, path: cacheDir }) 280 + } catch (err) { 281 + this.log('Failed to delete cache', { 282 + did, 283 + site, 284 + path: cacheDir, 285 + error: err instanceof Error ? err.message : String(err) 286 + }) 287 + } 288 + } 249 289 250 - getHealth() { 251 - const isConnected = this.firehose !== null; 252 - const timeSinceLastEvent = Date.now() - this.lastEventTime; 290 + getHealth() { 291 + const isConnected = this.firehose !== null 292 + const timeSinceLastEvent = Date.now() - this.lastEventTime 253 293 254 - return { 255 - connected: isConnected, 256 - lastEventTime: this.lastEventTime, 257 - timeSinceLastEvent, 258 - healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes 259 - }; 260 - } 294 + return { 295 + connected: isConnected, 296 + lastEventTime: this.lastEventTime, 297 + timeSinceLastEvent, 298 + healthy: isConnected && timeSinceLastEvent < 300000 // 5 minutes 299 + } 300 + } 261 301 }
+434 -84
hosting-service/src/lib/html-rewriter.test.ts
··· 1 - /** 2 - * Simple tests for HTML path rewriter 3 - * Run with: bun test 4 - */ 1 + import { describe, test, expect } from 'bun:test' 2 + import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter' 3 + 4 + describe('rewriteHtmlPaths', () => { 5 + const basePath = '/identifier/site/' 6 + 7 + describe('absolute paths', () => { 8 + test('rewrites absolute paths with leading slash', () => { 9 + const html = '<img src="/image.png">' 10 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 11 + expect(result).toBe('<img src="/identifier/site/image.png">') 12 + }) 13 + 14 + test('rewrites nested absolute paths', () => { 15 + const html = '<link href="/css/style.css">' 16 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 17 + expect(result).toBe('<link href="/identifier/site/css/style.css">') 18 + }) 19 + }) 20 + 21 + describe('relative paths from root document', () => { 22 + test('rewrites relative paths with ./ prefix', () => { 23 + const html = '<img src="./image.png">' 24 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 25 + expect(result).toBe('<img src="/identifier/site/image.png">') 26 + }) 27 + 28 + test('rewrites relative paths without prefix', () => { 29 + const html = '<img src="image.png">' 30 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 31 + expect(result).toBe('<img src="/identifier/site/image.png">') 32 + }) 33 + 34 + test('rewrites relative paths with ../ (should stay at root)', () => { 35 + const html = '<img src="../image.png">' 36 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 37 + expect(result).toBe('<img src="/identifier/site/image.png">') 38 + }) 39 + }) 40 + 41 + describe('relative paths from nested documents', () => { 42 + test('rewrites relative path from nested document', () => { 43 + const html = '<img src="./photo.jpg">' 44 + const result = rewriteHtmlPaths( 45 + html, 46 + basePath, 47 + 'folder1/folder2/index.html' 48 + ) 49 + expect(result).toBe( 50 + '<img src="/identifier/site/folder1/folder2/photo.jpg">' 51 + ) 52 + }) 53 + 54 + test('rewrites plain filename from nested document', () => { 55 + const html = '<script src="app.js"></script>' 56 + const result = rewriteHtmlPaths( 57 + html, 58 + basePath, 59 + 'folder1/folder2/index.html' 60 + ) 61 + expect(result).toBe( 62 + '<script src="/identifier/site/folder1/folder2/app.js"></script>' 63 + ) 64 + }) 65 + 66 + test('rewrites ../ to go up one level', () => { 67 + const html = '<img src="../image.png">' 68 + const result = rewriteHtmlPaths( 69 + html, 70 + basePath, 71 + 'folder1/folder2/folder3/index.html' 72 + ) 73 + expect(result).toBe( 74 + '<img src="/identifier/site/folder1/folder2/image.png">' 75 + ) 76 + }) 77 + 78 + test('rewrites multiple ../ to go up multiple levels', () => { 79 + const html = '<link href="../../css/style.css">' 80 + const result = rewriteHtmlPaths( 81 + html, 82 + basePath, 83 + 'folder1/folder2/folder3/index.html' 84 + ) 85 + expect(result).toBe( 86 + '<link href="/identifier/site/folder1/css/style.css">' 87 + ) 88 + }) 89 + 90 + test('rewrites ../ with additional path segments', () => { 91 + const html = '<img src="../assets/logo.png">' 92 + const result = rewriteHtmlPaths( 93 + html, 94 + basePath, 95 + 'pages/about/index.html' 96 + ) 97 + expect(result).toBe( 98 + '<img src="/identifier/site/pages/assets/logo.png">' 99 + ) 100 + }) 101 + 102 + test('handles complex nested relative paths', () => { 103 + const html = '<script src="../../lib/vendor/jquery.js"></script>' 104 + const result = rewriteHtmlPaths( 105 + html, 106 + basePath, 107 + 'pages/blog/post/index.html' 108 + ) 109 + expect(result).toBe( 110 + '<script src="/identifier/site/pages/lib/vendor/jquery.js"></script>' 111 + ) 112 + }) 113 + 114 + test('handles ../ going past root (stays at root)', () => { 115 + const html = '<img src="../../../image.png">' 116 + const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html') 117 + expect(result).toBe('<img src="/identifier/site/image.png">') 118 + }) 119 + }) 120 + 121 + describe('external URLs and special schemes', () => { 122 + test('does not rewrite http URLs', () => { 123 + const html = '<img src="http://example.com/image.png">' 124 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 125 + expect(result).toBe('<img src="http://example.com/image.png">') 126 + }) 5 127 6 - import { test, expect } from 'bun:test'; 7 - import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter'; 128 + test('does not rewrite https URLs', () => { 129 + const html = '<link href="https://cdn.example.com/style.css">' 130 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 131 + expect(result).toBe( 132 + '<link href="https://cdn.example.com/style.css">' 133 + ) 134 + }) 8 135 9 - test('rewriteHtmlPaths - rewrites absolute paths in src attributes', () => { 10 - const html = '<img src="/logo.png">'; 11 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 12 - expect(result).toBe('<img src="/did:plc:123/mysite/logo.png">'); 13 - }); 136 + test('does not rewrite protocol-relative URLs', () => { 137 + const html = '<script src="//cdn.example.com/script.js"></script>' 138 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 139 + expect(result).toBe( 140 + '<script src="//cdn.example.com/script.js"></script>' 141 + ) 142 + }) 14 143 15 - test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => { 16 - const html = '<link rel="stylesheet" href="/style.css">'; 17 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 18 - expect(result).toBe('<link rel="stylesheet" href="/did:plc:123/mysite/style.css">'); 19 - }); 144 + test('does not rewrite data URIs', () => { 145 + const html = 146 + '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">' 147 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 148 + expect(result).toBe( 149 + '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">' 150 + ) 151 + }) 20 152 21 - test('rewriteHtmlPaths - preserves external URLs', () => { 22 - const html = '<img src="https://example.com/logo.png">'; 23 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 24 - expect(result).toBe('<img src="https://example.com/logo.png">'); 25 - }); 153 + test('does not rewrite mailto links', () => { 154 + const html = '<a href="mailto:test@example.com">Email</a>' 155 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 156 + expect(result).toBe('<a href="mailto:test@example.com">Email</a>') 157 + }) 26 158 27 - test('rewriteHtmlPaths - preserves protocol-relative URLs', () => { 28 - const html = '<script src="//cdn.example.com/script.js"></script>'; 29 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 30 - expect(result).toBe('<script src="//cdn.example.com/script.js"></script>'); 31 - }); 159 + test('does not rewrite tel links', () => { 160 + const html = '<a href="tel:+1234567890">Call</a>' 161 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 162 + expect(result).toBe('<a href="tel:+1234567890">Call</a>') 163 + }) 164 + }) 32 165 33 - test('rewriteHtmlPaths - preserves data URIs', () => { 34 - const html = '<img src="data:image/png;base64,abc123">'; 35 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 36 - expect(result).toBe('<img src="data:image/png;base64,abc123">'); 37 - }); 166 + describe('different HTML attributes', () => { 167 + test('rewrites src attribute', () => { 168 + const html = '<img src="/image.png">' 169 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 170 + expect(result).toBe('<img src="/identifier/site/image.png">') 171 + }) 38 172 39 - test('rewriteHtmlPaths - preserves anchors', () => { 40 - const html = '<a href="/#section">Jump</a>'; 41 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 42 - expect(result).toBe('<a href="/#section">Jump</a>'); 43 - }); 173 + test('rewrites href attribute', () => { 174 + const html = '<a href="/page.html">Link</a>' 175 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 176 + expect(result).toBe('<a href="/identifier/site/page.html">Link</a>') 177 + }) 44 178 45 - test('rewriteHtmlPaths - preserves relative paths', () => { 46 - const html = '<img src="./logo.png">'; 47 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 48 - expect(result).toBe('<img src="./logo.png">'); 49 - }); 179 + test('rewrites action attribute', () => { 180 + const html = '<form action="/submit"></form>' 181 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 182 + expect(result).toBe('<form action="/identifier/site/submit"></form>') 183 + }) 50 184 51 - test('rewriteHtmlPaths - handles single quotes', () => { 52 - const html = "<img src='/logo.png'>"; 53 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 54 - expect(result).toBe("<img src='/did:plc:123/mysite/logo.png'>"); 55 - }); 185 + test('rewrites data attribute', () => { 186 + const html = '<object data="/document.pdf"></object>' 187 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 188 + expect(result).toBe( 189 + '<object data="/identifier/site/document.pdf"></object>' 190 + ) 191 + }) 56 192 57 - test('rewriteHtmlPaths - handles srcset', () => { 58 - const html = '<img srcset="/logo.png 1x, /logo@2x.png 2x">'; 59 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 60 - expect(result).toBe('<img srcset="/did:plc:123/mysite/logo.png 1x, /did:plc:123/mysite/logo@2x.png 2x">'); 61 - }); 193 + test('rewrites poster attribute', () => { 194 + const html = '<video poster="/thumbnail.jpg"></video>' 195 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 196 + expect(result).toBe( 197 + '<video poster="/identifier/site/thumbnail.jpg"></video>' 198 + ) 199 + }) 62 200 63 - test('rewriteHtmlPaths - handles form actions', () => { 64 - const html = '<form action="/submit"></form>'; 65 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 66 - expect(result).toBe('<form action="/did:plc:123/mysite/submit"></form>'); 67 - }); 201 + test('rewrites srcset attribute with single URL', () => { 202 + const html = '<img srcset="/image.png 1x">' 203 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 204 + expect(result).toBe( 205 + '<img srcset="/identifier/site/image.png 1x">' 206 + ) 207 + }) 68 208 69 - test('rewriteHtmlPaths - handles complex HTML', () => { 70 - const html = ` 209 + test('rewrites srcset attribute with multiple URLs', () => { 210 + const html = '<img srcset="/image-1x.png 1x, /image-2x.png 2x">' 211 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 212 + expect(result).toBe( 213 + '<img srcset="/identifier/site/image-1x.png 1x, /identifier/site/image-2x.png 2x">' 214 + ) 215 + }) 216 + 217 + test('rewrites srcset with width descriptors', () => { 218 + const html = '<img srcset="/small.jpg 320w, /large.jpg 1024w">' 219 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 220 + expect(result).toBe( 221 + '<img srcset="/identifier/site/small.jpg 320w, /identifier/site/large.jpg 1024w">' 222 + ) 223 + }) 224 + 225 + test('rewrites srcset with relative paths from nested document', () => { 226 + const html = '<img srcset="../img1.png 1x, ../img2.png 2x">' 227 + const result = rewriteHtmlPaths( 228 + html, 229 + basePath, 230 + 'folder1/folder2/index.html' 231 + ) 232 + expect(result).toBe( 233 + '<img srcset="/identifier/site/folder1/img1.png 1x, /identifier/site/folder1/img2.png 2x">' 234 + ) 235 + }) 236 + }) 237 + 238 + describe('quote handling', () => { 239 + test('handles double quotes', () => { 240 + const html = '<img src="/image.png">' 241 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 242 + expect(result).toBe('<img src="/identifier/site/image.png">') 243 + }) 244 + 245 + test('handles single quotes', () => { 246 + const html = "<img src='/image.png'>" 247 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 248 + expect(result).toBe("<img src='/identifier/site/image.png'>") 249 + }) 250 + 251 + test('handles mixed quotes in same document', () => { 252 + const html = '<img src="/img1.png"><link href=\'/style.css\'>' 253 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 254 + expect(result).toBe( 255 + '<img src="/identifier/site/img1.png"><link href=\'/identifier/site/style.css\'>' 256 + ) 257 + }) 258 + }) 259 + 260 + describe('multiple rewrites in same document', () => { 261 + test('rewrites multiple attributes in complex HTML', () => { 262 + const html = ` 71 263 <!DOCTYPE html> 72 264 <html> 73 265 <head> 74 - <link rel="stylesheet" href="/style.css"> 75 - <script src="/app.js"></script> 266 + <link href="/css/style.css" rel="stylesheet"> 267 + <script src="/js/app.js"></script> 268 + </head> 269 + <body> 270 + <img src="/images/logo.png" alt="Logo"> 271 + <a href="/about.html">About</a> 272 + <form action="/submit"> 273 + <button type="submit">Submit</button> 274 + </form> 275 + </body> 276 + </html> 277 + ` 278 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 279 + expect(result).toContain('href="/identifier/site/css/style.css"') 280 + expect(result).toContain('src="/identifier/site/js/app.js"') 281 + expect(result).toContain('src="/identifier/site/images/logo.png"') 282 + expect(result).toContain('href="/identifier/site/about.html"') 283 + expect(result).toContain('action="/identifier/site/submit"') 284 + }) 285 + 286 + test('handles mix of relative and absolute paths', () => { 287 + const html = ` 288 + <img src="/abs/image.png"> 289 + <img src="./rel/image.png"> 290 + <img src="../parent/image.png"> 291 + <img src="https://external.com/image.png"> 292 + ` 293 + const result = rewriteHtmlPaths( 294 + html, 295 + basePath, 296 + 'folder1/folder2/page.html' 297 + ) 298 + expect(result).toContain('src="/identifier/site/abs/image.png"') 299 + expect(result).toContain( 300 + 'src="/identifier/site/folder1/folder2/rel/image.png"' 301 + ) 302 + expect(result).toContain( 303 + 'src="/identifier/site/folder1/parent/image.png"' 304 + ) 305 + expect(result).toContain('src="https://external.com/image.png"') 306 + }) 307 + }) 308 + 309 + describe('edge cases', () => { 310 + test('handles empty src attribute', () => { 311 + const html = '<img src="">' 312 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 313 + expect(result).toBe('<img src="">') 314 + }) 315 + 316 + test('handles basePath without trailing slash', () => { 317 + const html = '<img src="/image.png">' 318 + const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html') 319 + expect(result).toBe('<img src="/identifier/site/image.png">') 320 + }) 321 + 322 + test('handles basePath with trailing slash', () => { 323 + const html = '<img src="/image.png">' 324 + const result = rewriteHtmlPaths( 325 + html, 326 + '/identifier/site/', 327 + 'index.html' 328 + ) 329 + expect(result).toBe('<img src="/identifier/site/image.png">') 330 + }) 331 + 332 + test('handles whitespace around equals sign', () => { 333 + const html = '<img src = "/image.png">' 334 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 335 + expect(result).toBe('<img src="/identifier/site/image.png">') 336 + }) 337 + 338 + test('preserves query strings in URLs', () => { 339 + const html = '<img src="/image.png?v=123">' 340 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 341 + expect(result).toBe('<img src="/identifier/site/image.png?v=123">') 342 + }) 343 + 344 + test('preserves hash fragments in URLs', () => { 345 + const html = '<a href="/page.html#section">Link</a>' 346 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 347 + expect(result).toBe( 348 + '<a href="/identifier/site/page.html#section">Link</a>' 349 + ) 350 + }) 351 + 352 + test('handles paths with special characters', () => { 353 + const html = '<img src="/folder-name/file_name.png">' 354 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 355 + expect(result).toBe( 356 + '<img src="/identifier/site/folder-name/file_name.png">' 357 + ) 358 + }) 359 + }) 360 + 361 + describe('real-world scenario', () => { 362 + test('handles the example from the bug report', () => { 363 + // HTML file at: /folder1/folder2/folder3/index.html 364 + // Image at: /folder1/folder2/img.png 365 + // Reference: src="../img.png" 366 + const html = '<img src="../img.png">' 367 + const result = rewriteHtmlPaths( 368 + html, 369 + basePath, 370 + 'folder1/folder2/folder3/index.html' 371 + ) 372 + expect(result).toBe( 373 + '<img src="/identifier/site/folder1/folder2/img.png">' 374 + ) 375 + }) 376 + 377 + test('handles deeply nested static site structure', () => { 378 + // A typical static site with nested pages and shared assets 379 + const html = ` 380 + <!DOCTYPE html> 381 + <html> 382 + <head> 383 + <link href="../../css/style.css" rel="stylesheet"> 384 + <link href="../../css/theme.css" rel="stylesheet"> 385 + <script src="../../js/main.js"></script> 76 386 </head> 77 387 <body> 78 - <img src="/images/logo.png" srcset="/images/logo.png 1x, /images/logo@2x.png 2x"> 79 - <a href="/about">About</a> 80 - <a href="https://example.com">External</a> 81 - <a href="#section">Anchor</a> 388 + <img src="../../images/logo.png" alt="Logo"> 389 + <img src="./post-image.jpg" alt="Post"> 390 + <a href="../index.html">Back to Blog</a> 391 + <a href="../../index.html">Home</a> 82 392 </body> 83 393 </html> 84 - `.trim(); 394 + ` 395 + const result = rewriteHtmlPaths( 396 + html, 397 + basePath, 398 + 'blog/posts/my-post.html' 399 + ) 400 + 401 + // Assets two levels up 402 + expect(result).toContain('href="/identifier/site/css/style.css"') 403 + expect(result).toContain('href="/identifier/site/css/theme.css"') 404 + expect(result).toContain('src="/identifier/site/js/main.js"') 405 + expect(result).toContain('src="/identifier/site/images/logo.png"') 406 + 407 + // Same directory 408 + expect(result).toContain( 409 + 'src="/identifier/site/blog/posts/post-image.jpg"' 410 + ) 85 411 86 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 412 + // One level up 413 + expect(result).toContain('href="/identifier/site/blog/index.html"') 87 414 88 - expect(result).toContain('href="/did:plc:123/mysite/style.css"'); 89 - expect(result).toContain('src="/did:plc:123/mysite/app.js"'); 90 - expect(result).toContain('src="/did:plc:123/mysite/images/logo.png"'); 91 - expect(result).toContain('href="/did:plc:123/mysite/about"'); 92 - expect(result).toContain('href="https://example.com"'); // External preserved 93 - expect(result).toContain('href="#section"'); // Anchor preserved 94 - }); 415 + // Two levels up 416 + expect(result).toContain('href="/identifier/site/index.html"') 417 + }) 418 + }) 419 + }) 95 420 96 - test('isHtmlContent - detects HTML by extension', () => { 97 - expect(isHtmlContent('index.html')).toBe(true); 98 - expect(isHtmlContent('page.htm')).toBe(true); 99 - expect(isHtmlContent('style.css')).toBe(false); 100 - expect(isHtmlContent('script.js')).toBe(false); 101 - }); 421 + describe('isHtmlContent', () => { 422 + test('identifies HTML by content type', () => { 423 + expect(isHtmlContent('file.txt', 'text/html')).toBe(true) 424 + expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe( 425 + true 426 + ) 427 + }) 102 428 103 - test('isHtmlContent - detects HTML by content type', () => { 104 - expect(isHtmlContent('index', 'text/html')).toBe(true); 105 - expect(isHtmlContent('index', 'text/html; charset=utf-8')).toBe(true); 106 - expect(isHtmlContent('index', 'application/json')).toBe(false); 107 - }); 429 + test('identifies HTML by .html extension', () => { 430 + expect(isHtmlContent('index.html')).toBe(true) 431 + expect(isHtmlContent('page.html', undefined)).toBe(true) 432 + expect(isHtmlContent('/path/to/file.html')).toBe(true) 433 + }) 434 + 435 + test('identifies HTML by .htm extension', () => { 436 + expect(isHtmlContent('index.htm')).toBe(true) 437 + expect(isHtmlContent('page.htm', undefined)).toBe(true) 438 + }) 439 + 440 + test('handles case-insensitive extensions', () => { 441 + expect(isHtmlContent('INDEX.HTML')).toBe(true) 442 + expect(isHtmlContent('page.HTM')).toBe(true) 443 + expect(isHtmlContent('File.HtMl')).toBe(true) 444 + }) 445 + 446 + test('returns false for non-HTML files', () => { 447 + expect(isHtmlContent('script.js')).toBe(false) 448 + expect(isHtmlContent('style.css')).toBe(false) 449 + expect(isHtmlContent('image.png')).toBe(false) 450 + expect(isHtmlContent('data.json')).toBe(false) 451 + }) 452 + 453 + test('returns false for files with no extension', () => { 454 + expect(isHtmlContent('README')).toBe(false) 455 + expect(isHtmlContent('Makefile')).toBe(false) 456 + }) 457 + })
+178 -104
hosting-service/src/lib/html-rewriter.ts
··· 4 4 */ 5 5 6 6 const REWRITABLE_ATTRIBUTES = [ 7 - 'src', 8 - 'href', 9 - 'action', 10 - 'data', 11 - 'poster', 12 - 'srcset', 13 - ] as const; 7 + 'src', 8 + 'href', 9 + 'action', 10 + 'data', 11 + 'poster', 12 + 'srcset' 13 + ] as const 14 14 15 15 /** 16 16 * Check if a path should be rewritten 17 17 */ 18 18 function shouldRewritePath(path: string): boolean { 19 - // Don't rewrite empty paths 20 - if (!path) return false; 19 + // Don't rewrite empty paths 20 + if (!path) return false 21 + 22 + // Don't rewrite external URLs (http://, https://, //) 23 + if ( 24 + path.startsWith('http://') || 25 + path.startsWith('https://') || 26 + path.startsWith('//') 27 + ) { 28 + return false 29 + } 30 + 31 + // Don't rewrite data URIs or other schemes (except file paths) 32 + if ( 33 + path.includes(':') && 34 + !path.startsWith('./') && 35 + !path.startsWith('../') 36 + ) { 37 + return false 38 + } 39 + 40 + // Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames) 41 + return true 42 + } 21 43 22 - // Don't rewrite external URLs (http://, https://, //) 23 - if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) { 24 - return false; 25 - } 44 + /** 45 + * Normalize a path by resolving . and .. segments 46 + */ 47 + function normalizePath(path: string): string { 48 + const parts = path.split('/') 49 + const result: string[] = [] 26 50 27 - // Don't rewrite data URIs or other schemes (except file paths) 28 - if (path.includes(':') && !path.startsWith('./') && !path.startsWith('../')) { 29 - return false; 30 - } 51 + for (const part of parts) { 52 + if (part === '.' || part === '') { 53 + // Skip current directory and empty parts (but keep leading empty for absolute paths) 54 + if (part === '' && result.length === 0) { 55 + result.push(part) 56 + } 57 + continue 58 + } 59 + if (part === '..') { 60 + // Go up one directory (but not past root) 61 + if (result.length > 0 && result[result.length - 1] !== '..') { 62 + result.pop() 63 + } 64 + continue 65 + } 66 + result.push(part) 67 + } 31 68 32 - // Don't rewrite pure anchors 33 - if (path.startsWith('#')) return false; 69 + return result.join('/') 70 + } 34 71 35 - // Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames) 36 - return true; 72 + /** 73 + * Get the directory path from a file path 74 + * e.g., "folder1/folder2/file.html" -> "folder1/folder2/" 75 + */ 76 + function getDirectory(filepath: string): string { 77 + const lastSlash = filepath.lastIndexOf('/') 78 + if (lastSlash === -1) { 79 + return '' 80 + } 81 + return filepath.substring(0, lastSlash + 1) 37 82 } 38 83 39 84 /** 40 85 * Rewrite a single path 41 86 */ 42 - function rewritePath(path: string, basePath: string): string { 43 - if (!shouldRewritePath(path)) { 44 - return path; 45 - } 87 + function rewritePath( 88 + path: string, 89 + basePath: string, 90 + documentPath: string 91 + ): string { 92 + if (!shouldRewritePath(path)) { 93 + return path 94 + } 46 95 47 - // Handle absolute paths: /file.js -> /base/file.js 48 - if (path.startsWith('/')) { 49 - return basePath + path.slice(1); 50 - } 96 + // Handle absolute paths: /file.js -> /base/file.js 97 + if (path.startsWith('/')) { 98 + return basePath + path.slice(1) 99 + } 51 100 52 - // Handle relative paths: ./file.js or ../file.js or file.js -> /base/file.js 53 - // Strip leading ./ or ../ and just use the base path 54 - let cleanPath = path; 55 - if (cleanPath.startsWith('./')) { 56 - cleanPath = cleanPath.slice(2); 57 - } else if (cleanPath.startsWith('../')) { 58 - // For sites.wisp.place, we can't go up from the site root, so just use base path 59 - cleanPath = cleanPath.replace(/^(\.\.\/)+/, ''); 60 - } 101 + // Handle relative paths by resolving against document directory 102 + const documentDir = getDirectory(documentPath) 103 + let resolvedPath: string 104 + 105 + if (path.startsWith('./')) { 106 + // ./file.js relative to current directory 107 + resolvedPath = documentDir + path.slice(2) 108 + } else if (path.startsWith('../')) { 109 + // ../file.js relative to parent directory 110 + resolvedPath = documentDir + path 111 + } else { 112 + // file.js (no prefix) - treat as relative to current directory 113 + resolvedPath = documentDir + path 114 + } 115 + 116 + // Normalize the path to resolve .. and . 117 + resolvedPath = normalizePath(resolvedPath) 61 118 62 - return basePath + cleanPath; 119 + return basePath + resolvedPath 63 120 } 64 121 65 122 /** 66 123 * Rewrite srcset attribute (can contain multiple URLs) 67 124 * Format: "url1 1x, url2 2x" or "url1 100w, url2 200w" 68 125 */ 69 - function rewriteSrcset(srcset: string, basePath: string): string { 70 - return srcset 71 - .split(',') 72 - .map(part => { 73 - const trimmed = part.trim(); 74 - const spaceIndex = trimmed.indexOf(' '); 126 + function rewriteSrcset( 127 + srcset: string, 128 + basePath: string, 129 + documentPath: string 130 + ): string { 131 + return srcset 132 + .split(',') 133 + .map((part) => { 134 + const trimmed = part.trim() 135 + const spaceIndex = trimmed.indexOf(' ') 75 136 76 - if (spaceIndex === -1) { 77 - // No descriptor, just URL 78 - return rewritePath(trimmed, basePath); 79 - } 137 + if (spaceIndex === -1) { 138 + // No descriptor, just URL 139 + return rewritePath(trimmed, basePath, documentPath) 140 + } 80 141 81 - const url = trimmed.substring(0, spaceIndex); 82 - const descriptor = trimmed.substring(spaceIndex); 83 - return rewritePath(url, basePath) + descriptor; 84 - }) 85 - .join(', '); 142 + const url = trimmed.substring(0, spaceIndex) 143 + const descriptor = trimmed.substring(spaceIndex) 144 + return rewritePath(url, basePath, documentPath) + descriptor 145 + }) 146 + .join(', ') 86 147 } 87 148 88 149 /** 89 - * Rewrite absolute paths in HTML content 150 + * Rewrite absolute and relative paths in HTML content 90 151 * Uses simple regex matching for safety (no full HTML parsing) 91 152 */ 92 - export function rewriteHtmlPaths(html: string, basePath: string): string { 93 - // Ensure base path ends with / 94 - const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/'; 153 + export function rewriteHtmlPaths( 154 + html: string, 155 + basePath: string, 156 + documentPath: string 157 + ): string { 158 + // Ensure base path ends with / 159 + const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/' 95 160 96 - let rewritten = html; 161 + let rewritten = html 97 162 98 - // Rewrite each attribute type 99 - // Use more specific patterns to prevent ReDoS attacks 100 - for (const attr of REWRITABLE_ATTRIBUTES) { 101 - if (attr === 'srcset') { 102 - // Special handling for srcset - use possessive quantifiers via atomic grouping simulation 103 - // Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS 104 - const srcsetRegex = new RegExp( 105 - `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 106 - 'gi' 107 - ); 108 - rewritten = rewritten.replace(srcsetRegex, (match, value) => { 109 - const rewrittenValue = rewriteSrcset(value, normalizedBase); 110 - return `${attr}="${rewrittenValue}"`; 111 - }); 112 - } else { 113 - // Regular attributes with quoted values 114 - // Limit whitespace to prevent catastrophic backtracking 115 - const doubleQuoteRegex = new RegExp( 116 - `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 117 - 'gi' 118 - ); 119 - const singleQuoteRegex = new RegExp( 120 - `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`, 121 - 'gi' 122 - ); 163 + // Rewrite each attribute type 164 + // Use more specific patterns to prevent ReDoS attacks 165 + for (const attr of REWRITABLE_ATTRIBUTES) { 166 + if (attr === 'srcset') { 167 + // Special handling for srcset - use possessive quantifiers via atomic grouping simulation 168 + // Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS 169 + const srcsetRegex = new RegExp( 170 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 171 + 'gi' 172 + ) 173 + rewritten = rewritten.replace(srcsetRegex, (match, value) => { 174 + const rewrittenValue = rewriteSrcset( 175 + value, 176 + normalizedBase, 177 + documentPath 178 + ) 179 + return `${attr}="${rewrittenValue}"` 180 + }) 181 + } else { 182 + // Regular attributes with quoted values 183 + // Limit whitespace to prevent catastrophic backtracking 184 + const doubleQuoteRegex = new RegExp( 185 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 186 + 'gi' 187 + ) 188 + const singleQuoteRegex = new RegExp( 189 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`, 190 + 'gi' 191 + ) 123 192 124 - rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => { 125 - const rewrittenValue = rewritePath(value, normalizedBase); 126 - return `${attr}="${rewrittenValue}"`; 127 - }); 193 + rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => { 194 + const rewrittenValue = rewritePath( 195 + value, 196 + normalizedBase, 197 + documentPath 198 + ) 199 + return `${attr}="${rewrittenValue}"` 200 + }) 128 201 129 - rewritten = rewritten.replace(singleQuoteRegex, (match, value) => { 130 - const rewrittenValue = rewritePath(value, normalizedBase); 131 - return `${attr}='${rewrittenValue}'`; 132 - }); 133 - } 134 - } 202 + rewritten = rewritten.replace(singleQuoteRegex, (match, value) => { 203 + const rewrittenValue = rewritePath( 204 + value, 205 + normalizedBase, 206 + documentPath 207 + ) 208 + return `${attr}='${rewrittenValue}'` 209 + }) 210 + } 211 + } 135 212 136 - return rewritten; 213 + return rewritten 137 214 } 138 215 139 216 /** 140 217 * Check if content is HTML based on content or filename 141 218 */ 142 - export function isHtmlContent( 143 - filepath: string, 144 - contentType?: string 145 - ): boolean { 146 - if (contentType && contentType.includes('text/html')) { 147 - return true; 148 - } 219 + export function isHtmlContent(filepath: string, contentType?: string): boolean { 220 + if (contentType && contentType.includes('text/html')) { 221 + return true 222 + } 149 223 150 - const ext = filepath.toLowerCase().split('.').pop(); 151 - return ext === 'html' || ext === 'htm'; 224 + const ext = filepath.toLowerCase().split('.').pop() 225 + return ext === 'html' || ext === 'htm' 152 226 }
+36 -38
hosting-service/src/lib/observability.ts
··· 1 1 // DIY Observability for Hosting Service 2 - import type { Context } from 'elysia' 2 + import type { Context } from 'hono' 3 3 4 4 // Types 5 5 export interface LogEntry { ··· 175 175 // Rotate if needed 176 176 if (errors.size > MAX_ERRORS) { 177 177 const oldest = Array.from(errors.keys())[0] 178 - errors.delete(oldest) 178 + if (oldest !== undefined) { 179 + errors.delete(oldest) 180 + } 179 181 } 180 182 } 181 183 }, ··· 262 264 return { 263 265 totalRequests: filtered.length, 264 266 avgDuration: Math.round(totalDuration / filtered.length), 265 - p50Duration: Math.round(p50), 266 - p95Duration: Math.round(p95), 267 - p99Duration: Math.round(p99), 267 + p50Duration: Math.round(p50 ?? 0), 268 + p95Duration: Math.round(p95 ?? 0), 269 + p99Duration: Math.round(p99 ?? 0), 268 270 errorRate: (errors / filtered.length) * 100, 269 271 requestsPerMinute: Math.round(filtered.length / timeWindowMinutes) 270 272 } ··· 275 277 } 276 278 } 277 279 278 - // Elysia middleware for request timing 280 + // Hono middleware for request timing 279 281 export function observabilityMiddleware(service: string) { 280 - return { 281 - beforeHandle: ({ request }: any) => { 282 - (request as any).__startTime = Date.now() 283 - }, 284 - afterHandle: ({ request, set }: any) => { 285 - const duration = Date.now() - ((request as any).__startTime || Date.now()) 286 - const url = new URL(request.url) 282 + return async (c: Context, next: () => Promise<void>) => { 283 + const startTime = Date.now() 284 + 285 + await next() 286 + 287 + const duration = Date.now() - startTime 288 + const { pathname } = new URL(c.req.url) 287 289 288 - metricsCollector.recordRequest( 289 - url.pathname, 290 - request.method, 291 - set.status || 200, 292 - duration, 293 - service 294 - ) 295 - }, 296 - onError: ({ request, error, set }: any) => { 297 - const duration = Date.now() - ((request as any).__startTime || Date.now()) 298 - const url = new URL(request.url) 290 + metricsCollector.recordRequest( 291 + pathname, 292 + c.req.method, 293 + c.res.status, 294 + duration, 295 + service 296 + ) 297 + } 298 + } 299 299 300 - metricsCollector.recordRequest( 301 - url.pathname, 302 - request.method, 303 - set.status || 500, 304 - duration, 305 - service 306 - ) 300 + // Hono error handler 301 + export function observabilityErrorHandler(service: string) { 302 + return (err: Error, c: Context) => { 303 + const { pathname } = new URL(c.req.url) 304 + 305 + logCollector.error( 306 + `Request failed: ${c.req.method} ${pathname}`, 307 + service, 308 + err, 309 + { statusCode: c.res.status || 500 } 310 + ) 307 311 308 - logCollector.error( 309 - `Request failed: ${request.method} ${url.pathname}`, 310 - service, 311 - error, 312 - { statusCode: set.status || 500 } 313 - ) 314 - } 312 + return c.text('Internal Server Error', 500) 315 313 } 316 314 } 317 315
+8 -3
hosting-service/src/lib/safe-fetch.ts
··· 24 24 const FETCH_TIMEOUT = 120000; // 120 seconds 25 25 const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads 26 26 const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB 27 + const MAX_JSON_SIZE = 1024 * 1024; // 1MB 28 + const MAX_BLOB_SIZE = 100 * 1024 * 1024; // 100MB 29 + const MAX_REDIRECTS = 10; 27 30 28 31 function isBlockedHost(hostname: string): boolean { 29 32 const lowerHost = hostname.toLowerCase(); ··· 72 75 const response = await fetch(url, { 73 76 ...options, 74 77 signal: controller.signal, 78 + redirect: 'follow', 75 79 }); 76 80 77 81 const contentLength = response.headers.get('content-length'); ··· 94 98 url: string, 95 99 options?: RequestInit & { maxSize?: number; timeout?: number } 96 100 ): Promise<T> { 97 - const maxJsonSize = options?.maxSize ?? 1024 * 1024; // 1MB default for JSON 101 + const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE; 98 102 const response = await safeFetch(url, { ...options, maxSize: maxJsonSize }); 99 103 100 104 if (!response.ok) { ··· 140 144 url: string, 141 145 options?: RequestInit & { maxSize?: number; timeout?: number } 142 146 ): Promise<Uint8Array> { 143 - const maxBlobSize = options?.maxSize ?? MAX_RESPONSE_SIZE; 144 - const response = await safeFetch(url, { ...options, maxSize: maxBlobSize }); 147 + const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE; 148 + const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB; 149 + const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs }); 145 150 146 151 if (!response.ok) { 147 152 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+169
hosting-service/src/lib/utils.test.ts
··· 1 + import { describe, test, expect } from 'bun:test' 2 + import { sanitizePath, extractBlobCid } from './utils' 3 + import { CID } from 'multiformats' 4 + 5 + describe('sanitizePath', () => { 6 + test('allows normal file paths', () => { 7 + expect(sanitizePath('index.html')).toBe('index.html') 8 + expect(sanitizePath('css/styles.css')).toBe('css/styles.css') 9 + expect(sanitizePath('images/logo.png')).toBe('images/logo.png') 10 + expect(sanitizePath('js/app.js')).toBe('js/app.js') 11 + }) 12 + 13 + test('allows deeply nested paths', () => { 14 + expect(sanitizePath('assets/images/icons/favicon.ico')).toBe('assets/images/icons/favicon.ico') 15 + expect(sanitizePath('a/b/c/d/e/f.txt')).toBe('a/b/c/d/e/f.txt') 16 + }) 17 + 18 + test('removes leading slashes', () => { 19 + expect(sanitizePath('/index.html')).toBe('index.html') 20 + expect(sanitizePath('//index.html')).toBe('index.html') 21 + expect(sanitizePath('///index.html')).toBe('index.html') 22 + expect(sanitizePath('/css/styles.css')).toBe('css/styles.css') 23 + }) 24 + 25 + test('blocks parent directory traversal', () => { 26 + expect(sanitizePath('../etc/passwd')).toBe('etc/passwd') 27 + expect(sanitizePath('../../etc/passwd')).toBe('etc/passwd') 28 + expect(sanitizePath('../../../etc/passwd')).toBe('etc/passwd') 29 + expect(sanitizePath('css/../../../etc/passwd')).toBe('css/etc/passwd') 30 + }) 31 + 32 + test('blocks directory traversal in middle of path', () => { 33 + expect(sanitizePath('images/../../../etc/passwd')).toBe('images/etc/passwd') 34 + // Note: sanitizePath only filters out ".." segments, doesn't resolve paths 35 + expect(sanitizePath('a/b/../c')).toBe('a/b/c') 36 + expect(sanitizePath('a/../b/../c')).toBe('a/b/c') 37 + }) 38 + 39 + test('removes current directory references', () => { 40 + expect(sanitizePath('./index.html')).toBe('index.html') 41 + expect(sanitizePath('././index.html')).toBe('index.html') 42 + expect(sanitizePath('css/./styles.css')).toBe('css/styles.css') 43 + expect(sanitizePath('./css/./styles.css')).toBe('css/styles.css') 44 + }) 45 + 46 + test('removes empty path segments', () => { 47 + expect(sanitizePath('css//styles.css')).toBe('css/styles.css') 48 + expect(sanitizePath('css///styles.css')).toBe('css/styles.css') 49 + expect(sanitizePath('a//b//c')).toBe('a/b/c') 50 + }) 51 + 52 + test('blocks null bytes', () => { 53 + // Null bytes cause the entire segment to be filtered out 54 + expect(sanitizePath('index.html\0.txt')).toBe('') 55 + expect(sanitizePath('test\0')).toBe('') 56 + // Null byte in middle segment 57 + expect(sanitizePath('css/bad\0name/styles.css')).toBe('css/styles.css') 58 + }) 59 + 60 + test('handles mixed attacks', () => { 61 + expect(sanitizePath('/../../../etc/passwd')).toBe('etc/passwd') 62 + expect(sanitizePath('/./././../etc/passwd')).toBe('etc/passwd') 63 + expect(sanitizePath('//../../.\0./etc/passwd')).toBe('etc/passwd') 64 + }) 65 + 66 + test('handles edge cases', () => { 67 + expect(sanitizePath('')).toBe('') 68 + expect(sanitizePath('/')).toBe('') 69 + expect(sanitizePath('//')).toBe('') 70 + expect(sanitizePath('.')).toBe('') 71 + expect(sanitizePath('..')).toBe('') 72 + expect(sanitizePath('../..')).toBe('') 73 + }) 74 + 75 + test('preserves valid special characters in filenames', () => { 76 + expect(sanitizePath('file-name.html')).toBe('file-name.html') 77 + expect(sanitizePath('file_name.html')).toBe('file_name.html') 78 + expect(sanitizePath('file.name.html')).toBe('file.name.html') 79 + expect(sanitizePath('file (1).html')).toBe('file (1).html') 80 + expect(sanitizePath('file@2x.png')).toBe('file@2x.png') 81 + }) 82 + 83 + test('handles Unicode characters', () => { 84 + expect(sanitizePath('ๆ–‡ไปถ.html')).toBe('ๆ–‡ไปถ.html') 85 + expect(sanitizePath('ั„ะฐะนะป.html')).toBe('ั„ะฐะนะป.html') 86 + expect(sanitizePath('ใƒ•ใ‚กใ‚คใƒซ.html')).toBe('ใƒ•ใ‚กใ‚คใƒซ.html') 87 + }) 88 + }) 89 + 90 + describe('extractBlobCid', () => { 91 + const TEST_CID = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y' 92 + 93 + test('extracts CID from IPLD link', () => { 94 + const blobRef = { $link: TEST_CID } 95 + expect(extractBlobCid(blobRef)).toBe(TEST_CID) 96 + }) 97 + 98 + test('extracts CID from typed BlobRef with CID object', () => { 99 + const cid = CID.parse(TEST_CID) 100 + const blobRef = { ref: cid } 101 + const result = extractBlobCid(blobRef) 102 + expect(result).toBe(TEST_CID) 103 + }) 104 + 105 + test('extracts CID from typed BlobRef with IPLD link', () => { 106 + const blobRef = { 107 + ref: { $link: TEST_CID } 108 + } 109 + expect(extractBlobCid(blobRef)).toBe(TEST_CID) 110 + }) 111 + 112 + test('extracts CID from untyped BlobRef', () => { 113 + const blobRef = { cid: TEST_CID } 114 + expect(extractBlobCid(blobRef)).toBe(TEST_CID) 115 + }) 116 + 117 + test('returns null for invalid blob ref', () => { 118 + expect(extractBlobCid(null)).toBe(null) 119 + expect(extractBlobCid(undefined)).toBe(null) 120 + expect(extractBlobCid({})).toBe(null) 121 + expect(extractBlobCid('not-an-object')).toBe(null) 122 + expect(extractBlobCid(123)).toBe(null) 123 + }) 124 + 125 + test('returns null for malformed objects', () => { 126 + expect(extractBlobCid({ wrongKey: 'value' })).toBe(null) 127 + expect(extractBlobCid({ ref: 'not-a-cid' })).toBe(null) 128 + expect(extractBlobCid({ ref: {} })).toBe(null) 129 + }) 130 + 131 + test('handles nested structures from AT Proto API', () => { 132 + // Real structure from AT Proto 133 + const blobRef = { 134 + $type: 'blob', 135 + ref: CID.parse(TEST_CID), 136 + mimeType: 'text/html', 137 + size: 1234 138 + } 139 + expect(extractBlobCid(blobRef)).toBe(TEST_CID) 140 + }) 141 + 142 + test('handles BlobRef with additional properties', () => { 143 + const blobRef = { 144 + ref: { $link: TEST_CID }, 145 + mimeType: 'image/png', 146 + size: 5678, 147 + someOtherField: 'value' 148 + } 149 + expect(extractBlobCid(blobRef)).toBe(TEST_CID) 150 + }) 151 + 152 + test('prioritizes checking IPLD link first', () => { 153 + // Direct $link takes precedence 154 + const directLink = { $link: TEST_CID } 155 + expect(extractBlobCid(directLink)).toBe(TEST_CID) 156 + }) 157 + 158 + test('handles CID v0 format', () => { 159 + const cidV0 = 'QmZ4tDuvesekSs4qM5ZBKpXiZGun7S2CYtEZRB3DYXkjGx' 160 + const blobRef = { $link: cidV0 } 161 + expect(extractBlobCid(blobRef)).toBe(cidV0) 162 + }) 163 + 164 + test('handles CID v1 format', () => { 165 + const cidV1 = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' 166 + const blobRef = { $link: cidV1 } 167 + expect(extractBlobCid(blobRef)).toBe(cidV1) 168 + }) 169 + })
+85 -8
hosting-service/src/lib/utils.ts
··· 3 3 import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; 4 4 import { writeFile, readFile, rename } from 'fs/promises'; 5 5 import { safeFetchJson, safeFetchBlob } from './safe-fetch'; 6 - import { CID } from 'multiformats/cid'; 6 + import { CID } from 'multiformats'; 7 7 8 - const CACHE_DIR = './cache/sites'; 8 + const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; 9 9 const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL 10 10 11 11 interface CacheMetadata { ··· 13 13 cachedAt: number; 14 14 did: string; 15 15 rkey: string; 16 + } 17 + 18 + /** 19 + * Determines if a MIME type should benefit from gzip compression. 20 + * Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG). 21 + * Returns false for already-compressed formats (images, video, audio, PDFs). 22 + * 23 + */ 24 + export function shouldCompressMimeType(mimeType: string | undefined): boolean { 25 + if (!mimeType) return false; 26 + 27 + const mime = mimeType.toLowerCase(); 28 + 29 + // Text-based web assets that benefit from compression 30 + const compressibleTypes = [ 31 + 'text/html', 32 + 'text/css', 33 + 'text/javascript', 34 + 'application/javascript', 35 + 'application/x-javascript', 36 + 'text/xml', 37 + 'application/xml', 38 + 'application/json', 39 + 'text/plain', 40 + 'image/svg+xml', 41 + ]; 42 + 43 + if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) { 44 + return true; 45 + } 46 + 47 + // Already-compressed formats that should NOT be double-compressed 48 + const alreadyCompressedPrefixes = [ 49 + 'video/', 50 + 'audio/', 51 + 'image/', 52 + 'application/pdf', 53 + 'application/zip', 54 + 'application/gzip', 55 + ]; 56 + 57 + if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) { 58 + return false; 59 + } 60 + 61 + // Default to not compressing for unknown types 62 + return false; 16 63 } 17 64 18 65 interface IpldLink { ··· 268 315 // Allow up to 100MB per file blob, with 2 minute timeout 269 316 let content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024, timeout: 120000 }); 270 317 271 - // If content is base64-encoded, decode it back to gzipped binary 272 - if (base64 && encoding === 'gzip') { 273 - // Convert Uint8Array to Buffer for proper string conversion 274 - const buffer = Buffer.from(content); 275 - const base64String = buffer.toString('utf-8'); 318 + console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`); 319 + 320 + // If content is base64-encoded, decode it back to raw binary (gzipped or not) 321 + if (base64) { 322 + const originalSize = content.length; 323 + // Decode base64 directly from raw bytes - no string conversion 324 + // The blob contains base64-encoded text as raw bytes, decode it in-place 325 + const textDecoder = new TextDecoder(); 326 + const base64String = textDecoder.decode(content); 276 327 content = Buffer.from(base64String, 'base64'); 328 + console.log(`[DEBUG] ${filePath}: decoded base64 from ${originalSize} bytes to ${content.length} bytes`); 329 + 330 + // Check if it's actually gzipped by looking at magic bytes 331 + if (content.length >= 2) { 332 + const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b; 333 + console.log(`[DEBUG] ${filePath}: has gzip magic bytes: ${hasGzipMagic}`); 334 + } 277 335 } 278 336 279 337 const cacheFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`; ··· 283 341 mkdirSync(fileDir, { recursive: true }); 284 342 } 285 343 344 + // Use the shared function to determine if this should remain compressed 345 + const shouldStayCompressed = shouldCompressMimeType(mimeType); 346 + 347 + // Decompress files that shouldn't be stored compressed 348 + if (encoding === 'gzip' && !shouldStayCompressed && content.length >= 2 && 349 + content[0] === 0x1f && content[1] === 0x8b) { 350 + console.log(`[DEBUG] ${filePath}: decompressing non-compressible type (${mimeType}) before caching`); 351 + try { 352 + const { gunzipSync } = await import('zlib'); 353 + const decompressed = gunzipSync(content); 354 + console.log(`[DEBUG] ${filePath}: decompressed from ${content.length} to ${decompressed.length} bytes`); 355 + content = decompressed; 356 + // Clear the encoding flag since we're storing decompressed 357 + encoding = undefined; 358 + } catch (error) { 359 + console.log(`[DEBUG] ${filePath}: failed to decompress, storing original gzipped content. Error:`, error); 360 + } 361 + } 362 + 286 363 await writeFile(cacheFile, content); 287 364 288 - // Store metadata if file is compressed 365 + // Store metadata only if file is still compressed 289 366 if (encoding === 'gzip' && mimeType) { 290 367 const metaFile = `${cacheFile}.meta`; 291 368 await writeFile(metaFile, JSON.stringify({ encoding, mimeType }));
+201 -151
hosting-service/src/server.ts
··· 1 - import { Elysia } from 'elysia'; 2 - import { node } from '@elysiajs/node' 3 - import { opentelemetry } from '@elysiajs/opentelemetry'; 1 + import { Hono } from 'hono'; 4 2 import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 5 - import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils'; 3 + import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils'; 6 4 import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 7 5 import { existsSync, readFileSync } from 'fs'; 8 6 import { lookup } from 'mime-types'; 9 - import { logger, observabilityMiddleware, logCollector, errorTracker, metricsCollector } from './lib/observability'; 7 + import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 10 8 11 9 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 12 10 ··· 37 35 const content = readFileSync(cachedFile); 38 36 const metaFile = `${cachedFile}.meta`; 39 37 38 + console.log(`[DEBUG SERVE] ${requestPath}: file size=${content.length} bytes, metaFile exists=${existsSync(metaFile)}`); 39 + 40 40 // Check if file has compression metadata 41 41 if (existsSync(metaFile)) { 42 42 const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 43 + console.log(`[DEBUG SERVE] ${requestPath}: meta=${JSON.stringify(meta)}`); 44 + 45 + // Check actual content for gzip magic bytes 46 + if (content.length >= 2) { 47 + const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b; 48 + console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic}`); 49 + } 50 + 43 51 if (meta.encoding === 'gzip' && meta.mimeType) { 44 - // Serve gzipped content with proper headers 52 + // Use shared function to determine if this should be served compressed 53 + const shouldServeCompressed = shouldCompressMimeType(meta.mimeType); 54 + 55 + if (!shouldServeCompressed) { 56 + // This shouldn't happen if caching is working correctly, but handle it gracefully 57 + console.log(`[DEBUG SERVE] ${requestPath}: decompressing file that shouldn't be compressed (${meta.mimeType})`); 58 + const { gunzipSync } = await import('zlib'); 59 + const decompressed = gunzipSync(content); 60 + console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`); 61 + return new Response(decompressed, { 62 + headers: { 63 + 'Content-Type': meta.mimeType, 64 + }, 65 + }); 66 + } 67 + 68 + // Serve gzipped content with proper headers (for HTML, CSS, JS, etc.) 69 + console.log(`[DEBUG SERVE] ${requestPath}: serving as gzipped with Content-Encoding header`); 45 70 return new Response(content, { 46 71 headers: { 47 72 'Content-Type': meta.mimeType, ··· 121 146 } 122 147 123 148 // Check if this is HTML content that needs rewriting 124 - // Note: For gzipped HTML with path rewriting, we need to decompress, rewrite, and serve uncompressed 125 - // This is a trade-off for the sites.wisp.place domain which needs path rewriting 149 + // We decompress, rewrite paths, then recompress for efficient delivery 126 150 if (isHtmlContent(requestPath, mimeType)) { 127 151 let content: string; 128 152 if (isGzipped) { ··· 132 156 } else { 133 157 content = readFileSync(cachedFile, 'utf-8'); 134 158 } 135 - const rewritten = rewriteHtmlPaths(content, basePath); 136 - return new Response(rewritten, { 159 + const rewritten = rewriteHtmlPaths(content, basePath, requestPath); 160 + 161 + // Recompress the HTML for efficient delivery 162 + const { gzipSync } = await import('zlib'); 163 + const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 164 + 165 + return new Response(recompressed, { 137 166 headers: { 138 167 'Content-Type': 'text/html; charset=utf-8', 168 + 'Content-Encoding': 'gzip', 139 169 }, 140 170 }); 141 171 } ··· 143 173 // Non-HTML files: serve gzipped content as-is with proper headers 144 174 const content = readFileSync(cachedFile); 145 175 if (isGzipped) { 176 + // Use shared function to determine if this should be served compressed 177 + const shouldServeCompressed = shouldCompressMimeType(mimeType); 178 + 179 + if (!shouldServeCompressed) { 180 + // This shouldn't happen if caching is working correctly, but handle it gracefully 181 + const { gunzipSync } = await import('zlib'); 182 + const decompressed = gunzipSync(content); 183 + return new Response(decompressed, { 184 + headers: { 185 + 'Content-Type': mimeType, 186 + }, 187 + }); 188 + } 189 + 146 190 return new Response(content, { 147 191 headers: { 148 192 'Content-Type': mimeType, ··· 171 215 } 172 216 } 173 217 174 - // HTML needs path rewriting, so decompress if needed 218 + // HTML needs path rewriting, decompress, rewrite, then recompress 175 219 let content: string; 176 220 if (isGzipped) { 177 221 const { gunzipSync } = await import('zlib'); ··· 180 224 } else { 181 225 content = readFileSync(indexFile, 'utf-8'); 182 226 } 183 - const rewritten = rewriteHtmlPaths(content, basePath); 184 - return new Response(rewritten, { 227 + const indexPath = `${requestPath}/index.html`; 228 + const rewritten = rewriteHtmlPaths(content, basePath, indexPath); 229 + 230 + // Recompress the HTML for efficient delivery 231 + const { gzipSync } = await import('zlib'); 232 + const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 233 + 234 + return new Response(recompressed, { 185 235 headers: { 186 236 'Content-Type': 'text/html; charset=utf-8', 237 + 'Content-Encoding': 'gzip', 187 238 }, 188 239 }); 189 240 } ··· 221 272 } 222 273 } 223 274 224 - const app = new Elysia({ adapter: node() }) 225 - .use(opentelemetry()) 226 - .onBeforeHandle(observabilityMiddleware('hosting-service').beforeHandle) 227 - .onAfterHandle(observabilityMiddleware('hosting-service').afterHandle) 228 - .onError(observabilityMiddleware('hosting-service').onError) 229 - .get('/*', async ({ request, set }) => { 230 - const url = new URL(request.url); 231 - const hostname = request.headers.get('host') || ''; 232 - const rawPath = url.pathname.replace(/^\//, ''); 233 - const path = sanitizePath(rawPath); 234 - 235 - // Check if this is sites.wisp.place subdomain 236 - if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 237 - // Sanitize the path FIRST to prevent path traversal 238 - const sanitizedFullPath = sanitizePath(rawPath); 239 - 240 - // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 241 - const pathParts = sanitizedFullPath.split('/'); 242 - if (pathParts.length < 2) { 243 - set.status = 400; 244 - return 'Invalid path format. Expected: /identifier/sitename/path'; 245 - } 246 - 247 - const identifier = pathParts[0]; 248 - const site = pathParts[1]; 249 - const filePath = pathParts.slice(2).join('/'); 275 + const app = new Hono(); 250 276 251 - // Additional validation: identifier must be a valid DID or handle format 252 - if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 253 - set.status = 400; 254 - return 'Invalid identifier'; 255 - } 277 + // Add observability middleware 278 + app.use('*', observabilityMiddleware('hosting-service')); 256 279 257 - // Validate site name (rkey) 258 - if (!isValidRkey(site)) { 259 - set.status = 400; 260 - return 'Invalid site name'; 261 - } 280 + // Error handler 281 + app.onError(observabilityErrorHandler('hosting-service')); 262 282 263 - // Resolve identifier to DID 264 - const did = await resolveDid(identifier); 265 - if (!did) { 266 - set.status = 400; 267 - return 'Invalid identifier'; 268 - } 283 + // Main site serving route 284 + app.get('/*', async (c) => { 285 + const url = new URL(c.req.url); 286 + const hostname = c.req.header('host') || ''; 287 + const rawPath = url.pathname.replace(/^\//, ''); 288 + const path = sanitizePath(rawPath); 269 289 270 - // Ensure site is cached 271 - const cached = await ensureSiteCached(did, site); 272 - if (!cached) { 273 - set.status = 404; 274 - return 'Site not found'; 275 - } 290 + // Check if this is sites.wisp.place subdomain 291 + if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 292 + // Sanitize the path FIRST to prevent path traversal 293 + const sanitizedFullPath = sanitizePath(rawPath); 276 294 277 - // Serve with HTML path rewriting to handle absolute paths 278 - const basePath = `/${identifier}/${site}/`; 279 - return serveFromCacheWithRewrite(did, site, filePath, basePath); 295 + // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 296 + const pathParts = sanitizedFullPath.split('/'); 297 + if (pathParts.length < 2) { 298 + return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 280 299 } 281 300 282 - // Check if this is a DNS hash subdomain 283 - const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 284 - if (dnsMatch) { 285 - const hash = dnsMatch[1]; 286 - const baseDomain = dnsMatch[2]; 287 - 288 - if (baseDomain !== BASE_HOST) { 289 - set.status = 400; 290 - return 'Invalid base domain'; 291 - } 292 - 293 - const customDomain = await getCustomDomainByHash(hash); 294 - if (!customDomain) { 295 - set.status = 404; 296 - return 'Custom domain not found or not verified'; 297 - } 298 - 299 - if (!customDomain.rkey) { 300 - set.status = 404; 301 - return 'Domain not mapped to a site'; 302 - } 301 + const identifier = pathParts[0]; 302 + const site = pathParts[1]; 303 + const filePath = pathParts.slice(2).join('/'); 303 304 304 - const rkey = customDomain.rkey; 305 - if (!isValidRkey(rkey)) { 306 - set.status = 500; 307 - return 'Invalid site configuration'; 308 - } 305 + // Additional validation: identifier must be a valid DID or handle format 306 + if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 307 + return c.text('Invalid identifier', 400); 308 + } 309 309 310 - const cached = await ensureSiteCached(customDomain.did, rkey); 311 - if (!cached) { 312 - set.status = 404; 313 - return 'Site not found'; 314 - } 310 + // Validate site parameter exists 311 + if (!site) { 312 + return c.text('Site name required', 400); 313 + } 315 314 316 - return serveFromCache(customDomain.did, rkey, path); 315 + // Validate site name (rkey) 316 + if (!isValidRkey(site)) { 317 + return c.text('Invalid site name', 400); 317 318 } 318 319 319 - // Route 2: Registered subdomains - /*.wisp.place/* 320 - if (hostname.endsWith(`.${BASE_HOST}`)) { 321 - const subdomain = hostname.replace(`.${BASE_HOST}`, ''); 320 + // Resolve identifier to DID 321 + const did = await resolveDid(identifier); 322 + if (!did) { 323 + return c.text('Invalid identifier', 400); 324 + } 322 325 323 - const domainInfo = await getWispDomain(hostname); 324 - if (!domainInfo) { 325 - set.status = 404; 326 - return 'Subdomain not registered'; 327 - } 326 + // Ensure site is cached 327 + const cached = await ensureSiteCached(did, site); 328 + if (!cached) { 329 + return c.text('Site not found', 404); 330 + } 328 331 329 - if (!domainInfo.rkey) { 330 - set.status = 404; 331 - return 'Domain not mapped to a site'; 332 - } 332 + // Serve with HTML path rewriting to handle absolute paths 333 + const basePath = `/${identifier}/${site}/`; 334 + return serveFromCacheWithRewrite(did, site, filePath, basePath); 335 + } 333 336 334 - const rkey = domainInfo.rkey; 335 - if (!isValidRkey(rkey)) { 336 - set.status = 500; 337 - return 'Invalid site configuration'; 338 - } 337 + // Check if this is a DNS hash subdomain 338 + const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 339 + if (dnsMatch) { 340 + const hash = dnsMatch[1]; 341 + const baseDomain = dnsMatch[2]; 339 342 340 - const cached = await ensureSiteCached(domainInfo.did, rkey); 341 - if (!cached) { 342 - set.status = 404; 343 - return 'Site not found'; 344 - } 343 + if (!hash) { 344 + return c.text('Invalid DNS hash', 400); 345 + } 345 346 346 - return serveFromCache(domainInfo.did, rkey, path); 347 + if (baseDomain !== BASE_HOST) { 348 + return c.text('Invalid base domain', 400); 347 349 } 348 350 349 - // Route 1: Custom domains - /* 350 - const customDomain = await getCustomDomain(hostname); 351 + const customDomain = await getCustomDomainByHash(hash); 351 352 if (!customDomain) { 352 - set.status = 404; 353 - return 'Custom domain not found or not verified'; 353 + return c.text('Custom domain not found or not verified', 404); 354 354 } 355 355 356 356 if (!customDomain.rkey) { 357 - set.status = 404; 358 - return 'Domain not mapped to a site'; 357 + return c.text('Domain not mapped to a site', 404); 359 358 } 360 359 361 360 const rkey = customDomain.rkey; 362 361 if (!isValidRkey(rkey)) { 363 - set.status = 500; 364 - return 'Invalid site configuration'; 362 + return c.text('Invalid site configuration', 500); 365 363 } 366 364 367 365 const cached = await ensureSiteCached(customDomain.did, rkey); 368 366 if (!cached) { 369 - set.status = 404; 370 - return 'Site not found'; 367 + return c.text('Site not found', 404); 371 368 } 372 369 373 370 return serveFromCache(customDomain.did, rkey, path); 374 - }) 375 - // Internal observability endpoints (for admin panel) 376 - .get('/__internal__/observability/logs', ({ query }) => { 377 - const filter: any = {}; 378 - if (query.level) filter.level = query.level; 379 - if (query.service) filter.service = query.service; 380 - if (query.search) filter.search = query.search; 381 - if (query.eventType) filter.eventType = query.eventType; 382 - if (query.limit) filter.limit = parseInt(query.limit as string); 383 - return { logs: logCollector.getLogs(filter) }; 384 - }) 385 - .get('/__internal__/observability/errors', ({ query }) => { 386 - const filter: any = {}; 387 - if (query.service) filter.service = query.service; 388 - if (query.limit) filter.limit = parseInt(query.limit as string); 389 - return { errors: errorTracker.getErrors(filter) }; 390 - }) 391 - .get('/__internal__/observability/metrics', ({ query }) => { 392 - const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 393 - const stats = metricsCollector.getStats('hosting-service', timeWindow); 394 - return { stats, timeWindow }; 395 - }); 371 + } 372 + 373 + // Route 2: Registered subdomains - /*.wisp.place/* 374 + if (hostname.endsWith(`.${BASE_HOST}`)) { 375 + const domainInfo = await getWispDomain(hostname); 376 + if (!domainInfo) { 377 + return c.text('Subdomain not registered', 404); 378 + } 379 + 380 + if (!domainInfo.rkey) { 381 + return c.text('Domain not mapped to a site', 404); 382 + } 383 + 384 + const rkey = domainInfo.rkey; 385 + if (!isValidRkey(rkey)) { 386 + return c.text('Invalid site configuration', 500); 387 + } 388 + 389 + const cached = await ensureSiteCached(domainInfo.did, rkey); 390 + if (!cached) { 391 + return c.text('Site not found', 404); 392 + } 393 + 394 + return serveFromCache(domainInfo.did, rkey, path); 395 + } 396 + 397 + // Route 1: Custom domains - /* 398 + const customDomain = await getCustomDomain(hostname); 399 + if (!customDomain) { 400 + return c.text('Custom domain not found or not verified', 404); 401 + } 402 + 403 + if (!customDomain.rkey) { 404 + return c.text('Domain not mapped to a site', 404); 405 + } 406 + 407 + const rkey = customDomain.rkey; 408 + if (!isValidRkey(rkey)) { 409 + return c.text('Invalid site configuration', 500); 410 + } 411 + 412 + const cached = await ensureSiteCached(customDomain.did, rkey); 413 + if (!cached) { 414 + return c.text('Site not found', 404); 415 + } 416 + 417 + return serveFromCache(customDomain.did, rkey, path); 418 + }); 419 + 420 + // Internal observability endpoints (for admin panel) 421 + app.get('/__internal__/observability/logs', (c) => { 422 + const query = c.req.query(); 423 + const filter: any = {}; 424 + if (query.level) filter.level = query.level; 425 + if (query.service) filter.service = query.service; 426 + if (query.search) filter.search = query.search; 427 + if (query.eventType) filter.eventType = query.eventType; 428 + if (query.limit) filter.limit = parseInt(query.limit as string); 429 + return c.json({ logs: logCollector.getLogs(filter) }); 430 + }); 431 + 432 + app.get('/__internal__/observability/errors', (c) => { 433 + const query = c.req.query(); 434 + const filter: any = {}; 435 + if (query.service) filter.service = query.service; 436 + if (query.limit) filter.limit = parseInt(query.limit as string); 437 + return c.json({ errors: errorTracker.getErrors(filter) }); 438 + }); 439 + 440 + app.get('/__internal__/observability/metrics', (c) => { 441 + const query = c.req.query(); 442 + const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 443 + const stats = metricsCollector.getStats('hosting-service', timeWindow); 444 + return c.json({ stats, timeWindow }); 445 + }); 396 446 397 447 export default app;
+28
hosting-service/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + /* Base Options */ 4 + "esModuleInterop": true, 5 + "skipLibCheck": true, 6 + "target": "es2022", 7 + "allowJs": true, 8 + "resolveJsonModule": true, 9 + "moduleDetection": "force", 10 + "isolatedModules": true, 11 + "verbatimModuleSyntax": true, 12 + 13 + /* Strictness */ 14 + "strict": true, 15 + "noUncheckedIndexedAccess": true, 16 + "noImplicitOverride": true, 17 + "forceConsistentCasingInFileNames": true, 18 + 19 + /* Transpiling with TypeScript */ 20 + "module": "ESNext", 21 + "moduleResolution": "bundler", 22 + "outDir": "dist", 23 + "sourceMap": true, 24 + 25 + /* Code doesn't run in DOM */ 26 + "lib": ["es2022"], 27 + } 28 + }
+2 -1
package.json
··· 2 2 "name": "elysia-static", 3 3 "version": "1.0.50", 4 4 "scripts": { 5 - "test": "echo \"Error: no test specified\" && exit 1", 5 + "test": "bun test", 6 6 "dev": "bun run --watch src/index.ts", 7 7 "start": "bun run src/index.ts", 8 8 "build": "bun build --compile --target bun --outfile server src/index.ts" ··· 30 30 "lucide-react": "^0.546.0", 31 31 "react": "^19.2.0", 32 32 "react-dom": "^19.2.0", 33 + "react-shiki": "^0.9.0", 33 34 "tailwind-merge": "^3.3.1", 34 35 "tailwindcss": "4", 35 36 "tw-animate-css": "^1.4.0",
+23
public/components/ui/code-block.tsx
··· 1 + import ShikiHighlighter from 'react-shiki' 2 + 3 + interface CodeBlockProps { 4 + code: string 5 + language?: string 6 + className?: string 7 + } 8 + 9 + export function CodeBlock({ code, language = 'bash', className = '' }: CodeBlockProps) { 10 + return ( 11 + <ShikiHighlighter 12 + language={language} 13 + theme={{ 14 + light: 'catppuccin-latte', 15 + dark: 'catppuccin-mocha', 16 + }} 17 + defaultColor="light-dark()" 18 + className={className} 19 + > 20 + {code.trim()} 21 + </ShikiHighlighter> 22 + ) 23 + }
+1 -1
public/components/ui/radio-group.tsx
··· 27 27 <RadioGroupPrimitive.Item 28 28 data-slot="radio-group-item" 29 29 className={cn( 30 - "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", 30 + "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/50 aspect-square size-4 shrink-0 rounded-full border border-black/30 dark:border-white/30 shadow-inner transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", 31 31 className 32 32 )} 33 33 {...props}
+2 -2
public/components/ui/tabs.tsx
··· 24 24 <TabsPrimitive.List 25 25 data-slot="tabs-list" 26 26 className={cn( 27 - "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", 27 + "bg-muted dark:bg-muted/80 text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", 28 28 className 29 29 )} 30 30 {...props} ··· 40 40 <TabsPrimitive.Trigger 41 41 data-slot="tabs-trigger" 42 42 className={cn( 43 - "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 43 + "data-[state=active]:bg-background dark:data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-border focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-border dark:data-[state=active]:shadow-sm text-foreground dark:text-muted-foreground/70 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 44 44 className 45 45 )} 46 46 {...props}
+268 -19
public/editor/editor.tsx
··· 38 38 Settings 39 39 } from 'lucide-react' 40 40 import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 41 + import { CodeBlock } from '@public/components/ui/code-block' 41 42 42 43 import Layout from '@public/layouts' 43 44 ··· 579 580 </div> 580 581 581 582 <Tabs defaultValue="sites" className="space-y-6 w-full"> 582 - <TabsList className="grid w-full grid-cols-3 max-w-md"> 583 + <TabsList className="grid w-full grid-cols-4"> 583 584 <TabsTrigger value="sites">Sites</TabsTrigger> 584 585 <TabsTrigger value="domains">Domains</TabsTrigger> 585 586 <TabsTrigger value="upload">Upload</TabsTrigger> 587 + <TabsTrigger value="cli">CLI</TabsTrigger> 586 588 </TabsList> 587 589 588 590 {/* Sites Tab */} ··· 884 886 </CardHeader> 885 887 <CardContent className="space-y-6"> 886 888 <div className="space-y-4"> 887 - <RadioGroup 888 - value={siteMode} 889 - onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 890 - disabled={isUploading} 891 - > 892 - <div className="flex items-center space-x-2"> 893 - <RadioGroupItem value="existing" id="existing" /> 894 - <Label htmlFor="existing" className="cursor-pointer"> 895 - Update existing site 896 - </Label> 897 - </div> 898 - <div className="flex items-center space-x-2"> 899 - <RadioGroupItem value="new" id="new" /> 900 - <Label htmlFor="new" className="cursor-pointer"> 901 - Create new site 902 - </Label> 903 - </div> 904 - </RadioGroup> 889 + <div className="p-4 bg-muted/50 rounded-lg"> 890 + <RadioGroup 891 + value={siteMode} 892 + onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 893 + disabled={isUploading} 894 + > 895 + <div className="flex items-center space-x-2"> 896 + <RadioGroupItem value="existing" id="existing" /> 897 + <Label htmlFor="existing" className="cursor-pointer"> 898 + Update existing site 899 + </Label> 900 + </div> 901 + <div className="flex items-center space-x-2"> 902 + <RadioGroupItem value="new" id="new" /> 903 + <Label htmlFor="new" className="cursor-pointer"> 904 + Create new site 905 + </Label> 906 + </div> 907 + </RadioGroup> 908 + </div> 905 909 906 910 {siteMode === 'existing' ? ( 907 911 <div className="space-y-2"> ··· 1074 1078 </> 1075 1079 )} 1076 1080 </Button> 1081 + </CardContent> 1082 + </Card> 1083 + </TabsContent> 1084 + 1085 + {/* CLI Tab */} 1086 + <TabsContent value="cli" className="space-y-4 min-h-[400px]"> 1087 + <Card> 1088 + <CardHeader> 1089 + <div className="flex items-center gap-2 mb-2"> 1090 + <CardTitle>Wisp CLI Tool</CardTitle> 1091 + <Badge variant="secondary" className="text-xs">v0.1.0</Badge> 1092 + <Badge variant="outline" className="text-xs">Alpha</Badge> 1093 + </div> 1094 + <CardDescription> 1095 + Deploy static sites directly from your terminal 1096 + </CardDescription> 1097 + </CardHeader> 1098 + <CardContent className="space-y-6"> 1099 + <div className="prose prose-sm max-w-none dark:prose-invert"> 1100 + <p className="text-sm text-muted-foreground"> 1101 + The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account. 1102 + Authenticate with app password or OAuth and deploy from CI/CD pipelines. 1103 + </p> 1104 + </div> 1105 + 1106 + <div className="space-y-3"> 1107 + <h3 className="text-sm font-semibold">Download CLI</h3> 1108 + <div className="grid gap-2"> 1109 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1110 + <a 1111 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64" 1112 + target="_blank" 1113 + rel="noopener noreferrer" 1114 + className="flex items-center justify-between mb-2" 1115 + > 1116 + <span className="font-mono text-sm">macOS (Apple Silicon)</span> 1117 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1118 + </a> 1119 + <div className="text-xs text-muted-foreground"> 1120 + <span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span> 1121 + </div> 1122 + </div> 1123 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1124 + <a 1125 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux" 1126 + target="_blank" 1127 + rel="noopener noreferrer" 1128 + className="flex items-center justify-between mb-2" 1129 + > 1130 + <span className="font-mono text-sm">Linux (ARM64)</span> 1131 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1132 + </a> 1133 + <div className="text-xs text-muted-foreground"> 1134 + <span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span> 1135 + </div> 1136 + </div> 1137 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1138 + <a 1139 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux" 1140 + target="_blank" 1141 + rel="noopener noreferrer" 1142 + className="flex items-center justify-between mb-2" 1143 + > 1144 + <span className="font-mono text-sm">Linux (x86_64)</span> 1145 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1146 + </a> 1147 + <div className="text-xs text-muted-foreground"> 1148 + <span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span> 1149 + </div> 1150 + </div> 1151 + </div> 1152 + </div> 1153 + 1154 + <div className="space-y-3"> 1155 + <h3 className="text-sm font-semibold">Basic Usage</h3> 1156 + <CodeBlock 1157 + code={`# Download and make executable 1158 + curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64 1159 + chmod +x wisp-cli-macos-arm64 1160 + 1161 + # Deploy your site (will use OAuth) 1162 + ./wisp-cli-macos-arm64 your-handle.bsky.social \\ 1163 + --path ./dist \\ 1164 + --site my-site 1165 + 1166 + # Your site will be available at: 1167 + # https://sites.wisp.place/your-handle/my-site`} 1168 + language="bash" 1169 + /> 1170 + </div> 1171 + 1172 + <div className="space-y-3"> 1173 + <h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3> 1174 + <p className="text-xs text-muted-foreground"> 1175 + Deploy automatically on every push using{' '} 1176 + <a 1177 + href="https://blog.tangled.org/ci" 1178 + target="_blank" 1179 + rel="noopener noreferrer" 1180 + className="text-accent hover:underline" 1181 + > 1182 + Tangled Spindle 1183 + </a> 1184 + </p> 1185 + 1186 + <div className="space-y-4"> 1187 + <div> 1188 + <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 1189 + <span>Example 1: Simple Asset Publishing</span> 1190 + <Badge variant="secondary" className="text-xs">Copy Files</Badge> 1191 + </h4> 1192 + <CodeBlock 1193 + code={`when: 1194 + - event: ['push'] 1195 + branch: ['main'] 1196 + - event: ['manual'] 1197 + 1198 + engine: 'nixery' 1199 + 1200 + clone: 1201 + skip: false 1202 + depth: 1 1203 + 1204 + dependencies: 1205 + nixpkgs: 1206 + - coreutils 1207 + - curl 1208 + 1209 + environment: 1210 + SITE_PATH: '.' # Copy entire repo 1211 + SITE_NAME: 'myWebbedSite' 1212 + WISP_HANDLE: 'your-handle.bsky.social' 1213 + 1214 + steps: 1215 + - name: deploy assets to wisp 1216 + command: | 1217 + # Download Wisp CLI 1218 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 1219 + chmod +x wisp-cli 1220 + 1221 + # Deploy to Wisp 1222 + ./wisp-cli \\ 1223 + "$WISP_HANDLE" \\ 1224 + --path "$SITE_PATH" \\ 1225 + --site "$SITE_NAME" \\ 1226 + --password "$WISP_APP_PASSWORD" 1227 + 1228 + # Output 1229 + #Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite 1230 + #Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite 1231 + `} 1232 + language="yaml" 1233 + /> 1234 + </div> 1235 + 1236 + <div> 1237 + <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 1238 + <span>Example 2: React/Vite Build & Deploy</span> 1239 + <Badge variant="secondary" className="text-xs">Full Build</Badge> 1240 + </h4> 1241 + <CodeBlock 1242 + code={`when: 1243 + - event: ['push'] 1244 + branch: ['main'] 1245 + - event: ['manual'] 1246 + 1247 + engine: 'nixery' 1248 + 1249 + clone: 1250 + skip: false 1251 + depth: 1 1252 + submodules: false 1253 + 1254 + dependencies: 1255 + nixpkgs: 1256 + - nodejs 1257 + - coreutils 1258 + - curl 1259 + github:NixOS/nixpkgs/nixpkgs-unstable: 1260 + - bun 1261 + 1262 + environment: 1263 + SITE_PATH: 'dist' 1264 + SITE_NAME: 'my-react-site' 1265 + WISP_HANDLE: 'your-handle.bsky.social' 1266 + 1267 + steps: 1268 + - name: build site 1269 + command: | 1270 + # necessary to ensure bun is in PATH 1271 + export PATH="$HOME/.nix-profile/bin:$PATH" 1272 + 1273 + bun install --frozen-lockfile 1274 + 1275 + # build with vite, run directly to get around env issues 1276 + bun node_modules/.bin/vite build 1277 + 1278 + - name: deploy to wisp 1279 + command: | 1280 + # Download Wisp CLI 1281 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 1282 + chmod +x wisp-cli 1283 + 1284 + # Deploy to Wisp 1285 + ./wisp-cli \\ 1286 + "$WISP_HANDLE" \\ 1287 + --path "$SITE_PATH" \\ 1288 + --site "$SITE_NAME" \\ 1289 + --password "$WISP_APP_PASSWORD"`} 1290 + language="yaml" 1291 + /> 1292 + </div> 1293 + </div> 1294 + 1295 + <div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent"> 1296 + <p className="text-xs text-muted-foreground"> 1297 + <strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings. 1298 + Generate an app password from your AT Protocol account settings. 1299 + </p> 1300 + </div> 1301 + </div> 1302 + 1303 + <div className="space-y-3"> 1304 + <h3 className="text-sm font-semibold">Learn More</h3> 1305 + <div className="grid gap-2"> 1306 + <a 1307 + href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli" 1308 + target="_blank" 1309 + rel="noopener noreferrer" 1310 + className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 1311 + > 1312 + <span className="text-sm">Source Code</span> 1313 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1314 + </a> 1315 + <a 1316 + href="https://blog.tangled.org/ci" 1317 + target="_blank" 1318 + rel="noopener noreferrer" 1319 + className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 1320 + > 1321 + <span className="text-sm">Tangled Spindle CI/CD</span> 1322 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1323 + </a> 1324 + </div> 1325 + </div> 1077 1326 </CardContent> 1078 1327 </Card> 1079 1328 </TabsContent>
+1
public/editor/index.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <title>Elysia Static</title> 7 + <link rel="icon" type="image/x-icon" href="../favicon.ico"> 7 8 </head> 8 9 <body> 9 10 <div id="elysia"></div>
public/favicon.ico

This is a binary file and will not be displayed.

+14
public/favicon.svg
··· 1 + <!--?xml version="1.0" encoding="utf-8"?--> 2 + <svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Centered large wisp on black background"> 3 + <!-- black background --> 4 + <rect width="64" height="64" fill="#000000"></rect> 5 + 6 + <!-- outer faint glow --> 7 + <circle cx="32" cy="32" r="14" fill="none" stroke="#7CF5D8" stroke-opacity="0.35" stroke-width="2.6"></circle> 8 + 9 + <!-- bright halo --> 10 + <circle cx="32" cy="32" r="10" fill="none" stroke="#CFF8EE" stroke-opacity="0.95" stroke-width="2.4"></circle> 11 + 12 + <!-- bright core --> 13 + <circle cx="32" cy="32" r="4" fill="#FFFFFF"></circle> 14 + </svg>
+1
public/index.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <title>Elysia Static</title> 7 + <link rel="icon" type="image/x-icon" href="./favicon.ico"> 7 8 </head> 8 9 <body> 9 10 <div id="elysia"></div>
+24
public/layouts/index.tsx
··· 1 1 import type { PropsWithChildren } from 'react' 2 + import { useEffect } from 'react' 2 3 3 4 import { QueryClientProvider, QueryClient } from '@tanstack/react-query' 4 5 import clsx from 'clsx' ··· 12 13 } 13 14 14 15 export default function Layout({ children, className }: LayoutProps) { 16 + useEffect(() => { 17 + // Function to update dark mode based on system preference 18 + const updateDarkMode = (e: MediaQueryList | MediaQueryListEvent) => { 19 + if (e.matches) { 20 + document.documentElement.classList.add('dark') 21 + } else { 22 + document.documentElement.classList.remove('dark') 23 + } 24 + } 25 + 26 + // Create media query 27 + const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)') 28 + 29 + // Set initial value 30 + updateDarkMode(darkModeQuery) 31 + 32 + // Listen for changes 33 + darkModeQuery.addEventListener('change', updateDarkMode) 34 + 35 + // Cleanup 36 + return () => darkModeQuery.removeEventListener('change', updateDarkMode) 37 + }, []) 38 + 15 39 return ( 16 40 <QueryClientProvider client={client}> 17 41 <div
+62 -36
public/styles/global.css
··· 1 1 @import "tailwindcss"; 2 2 @import "tw-animate-css"; 3 3 4 - @custom-variant dark (&:is(.dark *)); 4 + @custom-variant dark (@media (prefers-color-scheme: dark)); 5 5 6 6 :root { 7 + color-scheme: light; 8 + 7 9 /* Warm beige background inspired by Sunset design #E9DDD8 */ 8 10 --background: oklch(0.90 0.012 35); 9 11 /* Very dark brown text for strong contrast #2A2420 */ ··· 57 59 } 58 60 59 61 .dark { 60 - /* #413C58 - violet background for dark mode */ 61 - --background: oklch(0.28 0.04 285); 62 - /* #F2E7C9 - parchment text */ 63 - --foreground: oklch(0.93 0.03 85); 62 + color-scheme: dark; 64 63 65 - --card: oklch(0.32 0.04 285); 66 - --card-foreground: oklch(0.93 0.03 85); 64 + /* Slate violet background - #2C2C2C with violet tint */ 65 + --background: oklch(0.23 0.015 285); 66 + /* Light gray text - #E4E4E4 */ 67 + --foreground: oklch(0.90 0.005 285); 67 68 68 - --popover: oklch(0.32 0.04 285); 69 - --popover-foreground: oklch(0.93 0.03 85); 69 + /* Slightly lighter slate for cards */ 70 + --card: oklch(0.28 0.015 285); 71 + --card-foreground: oklch(0.90 0.005 285); 70 72 71 - /* #FFAAD2 - pink primary in dark mode */ 72 - --primary: oklch(0.78 0.15 345); 73 - --primary-foreground: oklch(0.32 0.04 285); 73 + --popover: oklch(0.28 0.015 285); 74 + --popover-foreground: oklch(0.90 0.005 285); 74 75 75 - --accent: oklch(0.78 0.15 345); 76 - --accent-foreground: oklch(0.32 0.04 285); 76 + /* Lavender buttons - #B39CD0 */ 77 + --primary: oklch(0.70 0.10 295); 78 + --primary-foreground: oklch(0.23 0.015 285); 77 79 78 - --secondary: oklch(0.56 0.08 220); 79 - --secondary-foreground: oklch(0.93 0.03 85); 80 + /* Soft pink accent - #FFC1CC */ 81 + --accent: oklch(0.85 0.08 5); 82 + --accent-foreground: oklch(0.23 0.015 285); 80 83 81 - --muted: oklch(0.38 0.03 285); 82 - --muted-foreground: oklch(0.75 0.02 85); 84 + /* Light cyan secondary - #A8DADC */ 85 + --secondary: oklch(0.82 0.05 200); 86 + --secondary-foreground: oklch(0.23 0.015 285); 83 87 84 - --border: oklch(0.42 0.03 285); 85 - --input: oklch(0.42 0.03 285); 86 - --ring: oklch(0.78 0.15 345); 88 + /* Muted slate areas */ 89 + --muted: oklch(0.33 0.015 285); 90 + --muted-foreground: oklch(0.72 0.01 285); 87 91 88 - --destructive: oklch(0.577 0.245 27.325); 89 - --destructive-foreground: oklch(0.985 0 0); 92 + /* Subtle borders */ 93 + --border: oklch(0.38 0.02 285); 94 + --input: oklch(0.30 0.015 285); 95 + --ring: oklch(0.70 0.10 295); 90 96 91 - --chart-1: oklch(0.78 0.15 345); 92 - --chart-2: oklch(0.93 0.03 85); 93 - --chart-3: oklch(0.56 0.08 220); 94 - --chart-4: oklch(0.85 0.02 130); 95 - --chart-5: oklch(0.32 0.04 285); 96 - --sidebar: oklch(0.205 0 0); 97 - --sidebar-foreground: oklch(0.985 0 0); 98 - --sidebar-primary: oklch(0.488 0.243 264.376); 99 - --sidebar-primary-foreground: oklch(0.985 0 0); 100 - --sidebar-accent: oklch(0.269 0 0); 101 - --sidebar-accent-foreground: oklch(0.985 0 0); 102 - --sidebar-border: oklch(0.269 0 0); 103 - --sidebar-ring: oklch(0.439 0 0); 97 + /* Warm destructive color */ 98 + --destructive: oklch(0.60 0.22 27); 99 + --destructive-foreground: oklch(0.98 0.01 85); 100 + 101 + /* Chart colors using the accent palette */ 102 + --chart-1: oklch(0.85 0.08 5); 103 + --chart-2: oklch(0.82 0.05 200); 104 + --chart-3: oklch(0.70 0.10 295); 105 + --chart-4: oklch(0.75 0.08 340); 106 + --chart-5: oklch(0.65 0.08 180); 107 + 108 + /* Sidebar slate */ 109 + --sidebar: oklch(0.20 0.015 285); 110 + --sidebar-foreground: oklch(0.90 0.005 285); 111 + --sidebar-primary: oklch(0.70 0.10 295); 112 + --sidebar-primary-foreground: oklch(0.20 0.015 285); 113 + --sidebar-accent: oklch(0.28 0.015 285); 114 + --sidebar-accent-foreground: oklch(0.90 0.005 285); 115 + --sidebar-border: oklch(0.32 0.02 285); 116 + --sidebar-ring: oklch(0.70 0.10 295); 104 117 } 105 118 106 119 @theme inline { ··· 164 177 .arrow-animate { 165 178 animation: arrow-bounce 1.5s ease-in-out infinite; 166 179 } 180 + 181 + /* Shiki syntax highlighting styles */ 182 + .shiki-wrapper { 183 + border-radius: 0.5rem; 184 + padding: 1rem; 185 + overflow-x: auto; 186 + border: 1px solid hsl(var(--border)); 187 + } 188 + 189 + .shiki-wrapper pre { 190 + margin: 0 !important; 191 + padding: 0 !important; 192 + }
+16 -8
src/index.ts
··· 1 1 import { Elysia } from 'elysia' 2 + import type { Context } from 'elysia' 2 3 import { cors } from '@elysiajs/cors' 3 - import { openapi, fromTypes } from '@elysiajs/openapi' 4 4 import { staticPlugin } from '@elysiajs/static' 5 5 6 6 import type { Config } from './lib/types' ··· 58 58 dnsVerifier.start() 59 59 logger.info('DNS Verifier Started - checking custom domains every 10 minutes') 60 60 61 - export const app = new Elysia() 62 - .use(openapi({ 63 - references: fromTypes() 64 - })) 61 + export const app = new Elysia({ 62 + serve: { 63 + maxPayloadLength: 1024 * 1024 * 128 * 3, 64 + development: Bun.env.NODE_ENV !== 'production' ? true : false, 65 + id: Bun.env.NODE_ENV !== 'production' ? undefined : null, 66 + } 67 + }) 65 68 // Observability middleware 66 69 .onBeforeHandle(observabilityMiddleware('main-app').beforeHandle) 67 - .onAfterHandle((ctx) => { 70 + .onAfterHandle((ctx: Context) => { 68 71 observabilityMiddleware('main-app').afterHandle(ctx) 69 72 // Security headers middleware 70 73 const { set } = ctx ··· 104 107 prefix: '/' 105 108 }) 106 109 ) 107 - .get('/client-metadata.json', (c) => { 110 + .get('/client-metadata.json', () => { 108 111 return createClientMetadata(config) 109 112 }) 110 - .get('/jwks.json', async (c) => { 113 + .get('/jwks.json', async () => { 111 114 const keys = await getCurrentKeys() 112 115 if (!keys.length) return { keys: [] } 113 116 ··· 143 146 error: error instanceof Error ? error.message : String(error) 144 147 } 145 148 } 149 + }) 150 + .get('/.well-known/atproto-did', ({ set }) => { 151 + // Return plain text DID for AT Protocol domain verification 152 + set.headers['Content-Type'] = 'text/plain' 153 + return 'did:plc:7puq73yz2hkvbcpdhnsze2qw' 146 154 }) 147 155 .use(cors({ 148 156 origin: config.domain,
+81
src/lib/csrf.test.ts
··· 1 + import { describe, test, expect } from 'bun:test' 2 + import { verifyRequestOrigin } from './csrf' 3 + 4 + describe('verifyRequestOrigin', () => { 5 + test('should accept matching origin and host', () => { 6 + expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true) 7 + expect(verifyRequestOrigin('http://localhost:8000', ['localhost:8000'])).toBe(true) 8 + expect(verifyRequestOrigin('https://app.example.com', ['app.example.com'])).toBe(true) 9 + }) 10 + 11 + test('should accept origin matching one of multiple allowed hosts', () => { 12 + const allowedHosts = ['example.com', 'app.example.com', 'localhost:8000'] 13 + expect(verifyRequestOrigin('https://example.com', allowedHosts)).toBe(true) 14 + expect(verifyRequestOrigin('https://app.example.com', allowedHosts)).toBe(true) 15 + expect(verifyRequestOrigin('http://localhost:8000', allowedHosts)).toBe(true) 16 + }) 17 + 18 + test('should reject non-matching origin', () => { 19 + expect(verifyRequestOrigin('https://evil.com', ['example.com'])).toBe(false) 20 + expect(verifyRequestOrigin('https://fake-example.com', ['example.com'])).toBe(false) 21 + expect(verifyRequestOrigin('https://example.com.evil.com', ['example.com'])).toBe(false) 22 + }) 23 + 24 + test('should reject empty origin', () => { 25 + expect(verifyRequestOrigin('', ['example.com'])).toBe(false) 26 + }) 27 + 28 + test('should reject invalid URL format', () => { 29 + expect(verifyRequestOrigin('not-a-url', ['example.com'])).toBe(false) 30 + expect(verifyRequestOrigin('javascript:alert(1)', ['example.com'])).toBe(false) 31 + expect(verifyRequestOrigin('file:///etc/passwd', ['example.com'])).toBe(false) 32 + }) 33 + 34 + test('should handle different protocols correctly', () => { 35 + // Same host, different protocols should match (we only check host) 36 + expect(verifyRequestOrigin('http://example.com', ['example.com'])).toBe(true) 37 + expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true) 38 + }) 39 + 40 + test('should handle port numbers correctly', () => { 41 + expect(verifyRequestOrigin('http://localhost:3000', ['localhost:3000'])).toBe(true) 42 + expect(verifyRequestOrigin('http://localhost:3000', ['localhost:8000'])).toBe(false) 43 + expect(verifyRequestOrigin('http://localhost', ['localhost'])).toBe(true) 44 + }) 45 + 46 + test('should handle subdomains correctly', () => { 47 + expect(verifyRequestOrigin('https://sub.example.com', ['sub.example.com'])).toBe(true) 48 + expect(verifyRequestOrigin('https://sub.example.com', ['example.com'])).toBe(false) 49 + }) 50 + 51 + test('should handle case sensitivity (exact match required)', () => { 52 + // URL host is automatically lowercased by URL parser 53 + expect(verifyRequestOrigin('https://EXAMPLE.COM', ['example.com'])).toBe(true) 54 + expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true) 55 + // But allowed hosts are case-sensitive 56 + expect(verifyRequestOrigin('https://example.com', ['EXAMPLE.COM'])).toBe(false) 57 + }) 58 + 59 + test('should handle trailing slashes in origin', () => { 60 + expect(verifyRequestOrigin('https://example.com/', ['example.com'])).toBe(true) 61 + }) 62 + 63 + test('should handle paths in origin (host extraction)', () => { 64 + expect(verifyRequestOrigin('https://example.com/path/to/page', ['example.com'])).toBe(true) 65 + expect(verifyRequestOrigin('https://evil.com/example.com', ['example.com'])).toBe(false) 66 + }) 67 + 68 + test('should reject when allowed hosts is empty', () => { 69 + expect(verifyRequestOrigin('https://example.com', [])).toBe(false) 70 + }) 71 + 72 + test('should handle IPv4 addresses', () => { 73 + expect(verifyRequestOrigin('http://127.0.0.1:8000', ['127.0.0.1:8000'])).toBe(true) 74 + expect(verifyRequestOrigin('http://192.168.1.1', ['192.168.1.1'])).toBe(true) 75 + }) 76 + 77 + test('should handle IPv6 addresses', () => { 78 + expect(verifyRequestOrigin('http://[::1]:8000', ['[::1]:8000'])).toBe(true) 79 + expect(verifyRequestOrigin('http://[2001:db8::1]', ['[2001:db8::1]'])).toBe(true) 80 + }) 81 + })
+35 -15
src/lib/dns-verification-worker.ts
··· 71 71 }; 72 72 73 73 try { 74 - // Get all verified custom domains 75 - const domains = await db` 76 - SELECT id, domain, did FROM custom_domains WHERE verified = true 74 + // Get all custom domains (both verified and pending) 75 + const domains = await db<Array<{ 76 + id: string; 77 + domain: string; 78 + did: string; 79 + verified: boolean; 80 + }>>` 81 + SELECT id, domain, did, verified FROM custom_domains 77 82 `; 78 83 79 84 if (!domains || domains.length === 0) { 80 - this.log('No verified custom domains to check'); 85 + this.log('No custom domains to check'); 81 86 this.lastRunTime = Date.now(); 82 87 return; 83 88 } 84 89 85 - this.log(`Checking ${domains.length} verified custom domains`); 90 + const verifiedCount = domains.filter(d => d.verified).length; 91 + const pendingCount = domains.filter(d => !d.verified).length; 92 + this.log(`Checking ${domains.length} custom domains (${verifiedCount} verified, ${pendingCount} pending)`); 86 93 87 94 // Verify each domain 88 95 for (const row of domains) { 89 96 runStats.totalChecked++; 90 - const { id, domain, did } = row; 97 + const { id, domain, did, verified: wasVerified } = row; 91 98 92 99 try { 93 100 // Extract hash from id (SHA256 of did:domain) ··· 97 104 const result = await verifyCustomDomain(domain, did, expectedHash); 98 105 99 106 if (result.verified) { 100 - // Update last_verified_at timestamp 107 + // Update verified status and last_verified_at timestamp 101 108 await db` 102 109 UPDATE custom_domains 103 - SET last_verified_at = EXTRACT(EPOCH FROM NOW()) 110 + SET verified = true, 111 + last_verified_at = EXTRACT(EPOCH FROM NOW()) 104 112 WHERE id = ${id} 105 113 `; 106 114 runStats.verified++; 107 - this.log(`Domain verified: ${domain}`, { did }); 115 + if (!wasVerified) { 116 + this.log(`Domain newly verified: ${domain}`, { did }); 117 + } else { 118 + this.log(`Domain re-verified: ${domain}`, { did }); 119 + } 108 120 } else { 109 - // Mark domain as unverified 121 + // Mark domain as unverified or keep it pending 110 122 await db` 111 123 UPDATE custom_domains 112 124 SET verified = false, ··· 114 126 WHERE id = ${id} 115 127 `; 116 128 runStats.failed++; 117 - this.log(`Domain verification failed: ${domain}`, { 118 - did, 119 - error: result.error, 120 - found: result.found, 121 - }); 129 + if (wasVerified) { 130 + this.log(`Domain verification failed (was verified): ${domain}`, { 131 + did, 132 + error: result.error, 133 + found: result.found, 134 + }); 135 + } else { 136 + this.log(`Domain still pending: ${domain}`, { 137 + did, 138 + error: result.error, 139 + found: result.found, 140 + }); 141 + } 122 142 } 123 143 } catch (error) { 124 144 runStats.errors++;
+10 -6
src/lib/observability.ts
··· 312 312 service 313 313 ) 314 314 315 - logCollector.error( 316 - `Request failed: ${request.method} ${url.pathname}`, 317 - service, 318 - error, 319 - { statusCode: set.status || 500 } 320 - ) 315 + // Don't log 404 errors 316 + const statusCode = set.status || 500 317 + if (statusCode !== 404) { 318 + logCollector.error( 319 + `Request failed: ${request.method} ${url.pathname}`, 320 + service, 321 + error, 322 + { statusCode } 323 + ) 324 + } 321 325 } 322 326 } 323 327 }
+639
src/lib/wisp-utils.test.ts
··· 1 + import { describe, test, expect } from 'bun:test' 2 + import { 3 + shouldCompressFile, 4 + compressFile, 5 + processUploadedFiles, 6 + createManifest, 7 + updateFileBlobs, 8 + type UploadedFile, 9 + type FileUploadResult, 10 + } from './wisp-utils' 11 + import type { Directory } from '../lexicons/types/place/wisp/fs' 12 + import { gunzipSync } from 'zlib' 13 + import { BlobRef } from '@atproto/api' 14 + import { CID } from 'multiformats/cid' 15 + 16 + // Helper function to create a valid CID for testing 17 + // Using a real valid CID from actual AT Protocol usage 18 + const TEST_CID_STRING = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y' 19 + 20 + function createMockBlobRef(mimeType: string, size: number): BlobRef { 21 + // Create a properly formatted CID 22 + const cid = CID.parse(TEST_CID_STRING) 23 + return new BlobRef(cid, mimeType, size) 24 + } 25 + 26 + describe('shouldCompressFile', () => { 27 + test('should compress HTML files', () => { 28 + expect(shouldCompressFile('text/html')).toBe(true) 29 + expect(shouldCompressFile('text/html; charset=utf-8')).toBe(true) 30 + }) 31 + 32 + test('should compress CSS files', () => { 33 + expect(shouldCompressFile('text/css')).toBe(true) 34 + }) 35 + 36 + test('should compress JavaScript files', () => { 37 + expect(shouldCompressFile('text/javascript')).toBe(true) 38 + expect(shouldCompressFile('application/javascript')).toBe(true) 39 + expect(shouldCompressFile('application/x-javascript')).toBe(true) 40 + }) 41 + 42 + test('should compress JSON files', () => { 43 + expect(shouldCompressFile('application/json')).toBe(true) 44 + }) 45 + 46 + test('should compress SVG files', () => { 47 + expect(shouldCompressFile('image/svg+xml')).toBe(true) 48 + }) 49 + 50 + test('should compress XML files', () => { 51 + expect(shouldCompressFile('text/xml')).toBe(true) 52 + expect(shouldCompressFile('application/xml')).toBe(true) 53 + }) 54 + 55 + test('should compress plain text files', () => { 56 + expect(shouldCompressFile('text/plain')).toBe(true) 57 + }) 58 + 59 + test('should NOT compress images', () => { 60 + expect(shouldCompressFile('image/png')).toBe(false) 61 + expect(shouldCompressFile('image/jpeg')).toBe(false) 62 + expect(shouldCompressFile('image/jpg')).toBe(false) 63 + expect(shouldCompressFile('image/gif')).toBe(false) 64 + expect(shouldCompressFile('image/webp')).toBe(false) 65 + }) 66 + 67 + test('should NOT compress videos', () => { 68 + expect(shouldCompressFile('video/mp4')).toBe(false) 69 + expect(shouldCompressFile('video/webm')).toBe(false) 70 + }) 71 + 72 + test('should NOT compress already compressed formats', () => { 73 + expect(shouldCompressFile('application/zip')).toBe(false) 74 + expect(shouldCompressFile('application/gzip')).toBe(false) 75 + expect(shouldCompressFile('application/pdf')).toBe(false) 76 + }) 77 + 78 + test('should NOT compress fonts', () => { 79 + expect(shouldCompressFile('font/woff')).toBe(false) 80 + expect(shouldCompressFile('font/woff2')).toBe(false) 81 + expect(shouldCompressFile('font/ttf')).toBe(false) 82 + }) 83 + }) 84 + 85 + describe('compressFile', () => { 86 + test('should compress text content', () => { 87 + const content = Buffer.from('Hello, World! '.repeat(100)) 88 + const compressed = compressFile(content) 89 + 90 + expect(compressed.length).toBeLessThan(content.length) 91 + 92 + // Verify we can decompress it back 93 + const decompressed = gunzipSync(compressed) 94 + expect(decompressed.toString()).toBe(content.toString()) 95 + }) 96 + 97 + test('should compress HTML content significantly', () => { 98 + const html = ` 99 + <!DOCTYPE html> 100 + <html> 101 + <head><title>Test</title></head> 102 + <body> 103 + ${'<p>Hello World!</p>\n'.repeat(50)} 104 + </body> 105 + </html> 106 + ` 107 + const content = Buffer.from(html) 108 + const compressed = compressFile(content) 109 + 110 + expect(compressed.length).toBeLessThan(content.length) 111 + 112 + // Verify decompression 113 + const decompressed = gunzipSync(compressed) 114 + expect(decompressed.toString()).toBe(html) 115 + }) 116 + 117 + test('should handle empty content', () => { 118 + const content = Buffer.from('') 119 + const compressed = compressFile(content) 120 + const decompressed = gunzipSync(compressed) 121 + expect(decompressed.toString()).toBe('') 122 + }) 123 + 124 + test('should produce deterministic compression', () => { 125 + const content = Buffer.from('Test content') 126 + const compressed1 = compressFile(content) 127 + const compressed2 = compressFile(content) 128 + 129 + expect(compressed1.toString('base64')).toBe(compressed2.toString('base64')) 130 + }) 131 + }) 132 + 133 + describe('processUploadedFiles', () => { 134 + test('should process single root-level file', () => { 135 + const files: UploadedFile[] = [ 136 + { 137 + name: 'index.html', 138 + content: Buffer.from('<html></html>'), 139 + mimeType: 'text/html', 140 + size: 13, 141 + }, 142 + ] 143 + 144 + const result = processUploadedFiles(files) 145 + 146 + expect(result.fileCount).toBe(1) 147 + expect(result.directory.type).toBe('directory') 148 + expect(result.directory.entries).toHaveLength(1) 149 + expect(result.directory.entries[0].name).toBe('index.html') 150 + 151 + const node = result.directory.entries[0].node 152 + expect('blob' in node).toBe(true) // It's a file node 153 + }) 154 + 155 + test('should process multiple root-level files', () => { 156 + const files: UploadedFile[] = [ 157 + { 158 + name: 'index.html', 159 + content: Buffer.from('<html></html>'), 160 + mimeType: 'text/html', 161 + size: 13, 162 + }, 163 + { 164 + name: 'styles.css', 165 + content: Buffer.from('body {}'), 166 + mimeType: 'text/css', 167 + size: 7, 168 + }, 169 + { 170 + name: 'script.js', 171 + content: Buffer.from('console.log("hi")'), 172 + mimeType: 'application/javascript', 173 + size: 17, 174 + }, 175 + ] 176 + 177 + const result = processUploadedFiles(files) 178 + 179 + expect(result.fileCount).toBe(3) 180 + expect(result.directory.entries).toHaveLength(3) 181 + 182 + const names = result.directory.entries.map(e => e.name) 183 + expect(names).toContain('index.html') 184 + expect(names).toContain('styles.css') 185 + expect(names).toContain('script.js') 186 + }) 187 + 188 + test('should process files with subdirectories', () => { 189 + const files: UploadedFile[] = [ 190 + { 191 + name: 'dist/index.html', 192 + content: Buffer.from('<html></html>'), 193 + mimeType: 'text/html', 194 + size: 13, 195 + }, 196 + { 197 + name: 'dist/css/styles.css', 198 + content: Buffer.from('body {}'), 199 + mimeType: 'text/css', 200 + size: 7, 201 + }, 202 + { 203 + name: 'dist/js/app.js', 204 + content: Buffer.from('console.log()'), 205 + mimeType: 'application/javascript', 206 + size: 13, 207 + }, 208 + ] 209 + 210 + const result = processUploadedFiles(files) 211 + 212 + expect(result.fileCount).toBe(3) 213 + expect(result.directory.entries).toHaveLength(3) // index.html, css/, js/ 214 + 215 + // Check root has index.html (after base folder removal) 216 + const indexEntry = result.directory.entries.find(e => e.name === 'index.html') 217 + expect(indexEntry).toBeDefined() 218 + 219 + // Check css directory exists 220 + const cssDir = result.directory.entries.find(e => e.name === 'css') 221 + expect(cssDir).toBeDefined() 222 + expect('entries' in cssDir!.node).toBe(true) 223 + 224 + if ('entries' in cssDir!.node) { 225 + expect(cssDir!.node.entries).toHaveLength(1) 226 + expect(cssDir!.node.entries[0].name).toBe('styles.css') 227 + } 228 + 229 + // Check js directory exists 230 + const jsDir = result.directory.entries.find(e => e.name === 'js') 231 + expect(jsDir).toBeDefined() 232 + expect('entries' in jsDir!.node).toBe(true) 233 + }) 234 + 235 + test('should handle deeply nested subdirectories', () => { 236 + const files: UploadedFile[] = [ 237 + { 238 + name: 'dist/deep/nested/folder/file.txt', 239 + content: Buffer.from('content'), 240 + mimeType: 'text/plain', 241 + size: 7, 242 + }, 243 + ] 244 + 245 + const result = processUploadedFiles(files) 246 + 247 + expect(result.fileCount).toBe(1) 248 + 249 + // Navigate through the directory structure (base folder removed) 250 + const deepDir = result.directory.entries.find(e => e.name === 'deep') 251 + expect(deepDir).toBeDefined() 252 + expect('entries' in deepDir!.node).toBe(true) 253 + 254 + if ('entries' in deepDir!.node) { 255 + const nestedDir = deepDir!.node.entries.find(e => e.name === 'nested') 256 + expect(nestedDir).toBeDefined() 257 + 258 + if (nestedDir && 'entries' in nestedDir.node) { 259 + const folderDir = nestedDir.node.entries.find(e => e.name === 'folder') 260 + expect(folderDir).toBeDefined() 261 + 262 + if (folderDir && 'entries' in folderDir.node) { 263 + expect(folderDir.node.entries).toHaveLength(1) 264 + expect(folderDir.node.entries[0].name).toBe('file.txt') 265 + } 266 + } 267 + } 268 + }) 269 + 270 + test('should remove base folder name from paths', () => { 271 + const files: UploadedFile[] = [ 272 + { 273 + name: 'dist/index.html', 274 + content: Buffer.from('<html></html>'), 275 + mimeType: 'text/html', 276 + size: 13, 277 + }, 278 + { 279 + name: 'dist/css/styles.css', 280 + content: Buffer.from('body {}'), 281 + mimeType: 'text/css', 282 + size: 7, 283 + }, 284 + ] 285 + 286 + const result = processUploadedFiles(files) 287 + 288 + // After removing 'dist/', we should have index.html and css/ at root 289 + expect(result.directory.entries.find(e => e.name === 'index.html')).toBeDefined() 290 + expect(result.directory.entries.find(e => e.name === 'css')).toBeDefined() 291 + expect(result.directory.entries.find(e => e.name === 'dist')).toBeUndefined() 292 + }) 293 + 294 + test('should handle empty file list', () => { 295 + const files: UploadedFile[] = [] 296 + const result = processUploadedFiles(files) 297 + 298 + expect(result.fileCount).toBe(0) 299 + expect(result.directory.entries).toHaveLength(0) 300 + }) 301 + 302 + test('should handle multiple files in same subdirectory', () => { 303 + const files: UploadedFile[] = [ 304 + { 305 + name: 'dist/assets/image1.png', 306 + content: Buffer.from('png1'), 307 + mimeType: 'image/png', 308 + size: 4, 309 + }, 310 + { 311 + name: 'dist/assets/image2.png', 312 + content: Buffer.from('png2'), 313 + mimeType: 'image/png', 314 + size: 4, 315 + }, 316 + ] 317 + 318 + const result = processUploadedFiles(files) 319 + 320 + expect(result.fileCount).toBe(2) 321 + 322 + const assetsDir = result.directory.entries.find(e => e.name === 'assets') 323 + expect(assetsDir).toBeDefined() 324 + 325 + if ('entries' in assetsDir!.node) { 326 + expect(assetsDir!.node.entries).toHaveLength(2) 327 + const names = assetsDir!.node.entries.map(e => e.name) 328 + expect(names).toContain('image1.png') 329 + expect(names).toContain('image2.png') 330 + } 331 + }) 332 + }) 333 + 334 + describe('createManifest', () => { 335 + test('should create valid manifest', () => { 336 + const root: Directory = { 337 + $type: 'place.wisp.fs#directory', 338 + type: 'directory', 339 + entries: [], 340 + } 341 + 342 + const manifest = createManifest('example.com', root, 0) 343 + 344 + expect(manifest.$type).toBe('place.wisp.fs') 345 + expect(manifest.site).toBe('example.com') 346 + expect(manifest.root).toBe(root) 347 + expect(manifest.fileCount).toBe(0) 348 + expect(manifest.createdAt).toBeDefined() 349 + 350 + // Verify it's a valid ISO date string 351 + const date = new Date(manifest.createdAt) 352 + expect(date.toISOString()).toBe(manifest.createdAt) 353 + }) 354 + 355 + test('should create manifest with file count', () => { 356 + const root: Directory = { 357 + $type: 'place.wisp.fs#directory', 358 + type: 'directory', 359 + entries: [], 360 + } 361 + 362 + const manifest = createManifest('test-site', root, 42) 363 + 364 + expect(manifest.fileCount).toBe(42) 365 + expect(manifest.site).toBe('test-site') 366 + }) 367 + 368 + test('should create manifest with populated directory', () => { 369 + const mockBlob = createMockBlobRef('text/html', 100) 370 + 371 + const root: Directory = { 372 + $type: 'place.wisp.fs#directory', 373 + type: 'directory', 374 + entries: [ 375 + { 376 + name: 'index.html', 377 + node: { 378 + $type: 'place.wisp.fs#file', 379 + type: 'file', 380 + blob: mockBlob, 381 + }, 382 + }, 383 + ], 384 + } 385 + 386 + const manifest = createManifest('populated-site', root, 1) 387 + 388 + expect(manifest).toBeDefined() 389 + expect(manifest.site).toBe('populated-site') 390 + expect(manifest.root.entries).toHaveLength(1) 391 + }) 392 + }) 393 + 394 + describe('updateFileBlobs', () => { 395 + test('should update single file blob at root', () => { 396 + const directory: Directory = { 397 + $type: 'place.wisp.fs#directory', 398 + type: 'directory', 399 + entries: [ 400 + { 401 + name: 'index.html', 402 + node: { 403 + $type: 'place.wisp.fs#file', 404 + type: 'file', 405 + blob: undefined as any, 406 + }, 407 + }, 408 + ], 409 + } 410 + 411 + const mockBlob = createMockBlobRef('text/html', 100) 412 + const uploadResults: FileUploadResult[] = [ 413 + { 414 + hash: TEST_CID_STRING, 415 + blobRef: mockBlob, 416 + mimeType: 'text/html', 417 + }, 418 + ] 419 + 420 + const filePaths = ['index.html'] 421 + 422 + const updated = updateFileBlobs(directory, uploadResults, filePaths) 423 + 424 + expect(updated.entries).toHaveLength(1) 425 + const fileNode = updated.entries[0].node 426 + 427 + if ('blob' in fileNode) { 428 + expect(fileNode.blob).toBeDefined() 429 + expect(fileNode.blob.mimeType).toBe('text/html') 430 + expect(fileNode.blob.size).toBe(100) 431 + } else { 432 + throw new Error('Expected file node') 433 + } 434 + }) 435 + 436 + test('should update files in nested directories', () => { 437 + const directory: Directory = { 438 + $type: 'place.wisp.fs#directory', 439 + type: 'directory', 440 + entries: [ 441 + { 442 + name: 'css', 443 + node: { 444 + $type: 'place.wisp.fs#directory', 445 + type: 'directory', 446 + entries: [ 447 + { 448 + name: 'styles.css', 449 + node: { 450 + $type: 'place.wisp.fs#file', 451 + type: 'file', 452 + blob: undefined as any, 453 + }, 454 + }, 455 + ], 456 + }, 457 + }, 458 + ], 459 + } 460 + 461 + const mockBlob = createMockBlobRef('text/css', 50) 462 + const uploadResults: FileUploadResult[] = [ 463 + { 464 + hash: TEST_CID_STRING, 465 + blobRef: mockBlob, 466 + mimeType: 'text/css', 467 + encoding: 'gzip', 468 + }, 469 + ] 470 + 471 + const filePaths = ['css/styles.css'] 472 + 473 + const updated = updateFileBlobs(directory, uploadResults, filePaths) 474 + 475 + const cssDir = updated.entries[0] 476 + expect(cssDir.name).toBe('css') 477 + 478 + if ('entries' in cssDir.node) { 479 + const cssFile = cssDir.node.entries[0] 480 + expect(cssFile.name).toBe('styles.css') 481 + 482 + if ('blob' in cssFile.node) { 483 + expect(cssFile.node.blob.mimeType).toBe('text/css') 484 + if ('encoding' in cssFile.node) { 485 + expect(cssFile.node.encoding).toBe('gzip') 486 + } 487 + } else { 488 + throw new Error('Expected file node') 489 + } 490 + } else { 491 + throw new Error('Expected directory node') 492 + } 493 + }) 494 + 495 + test('should handle normalized paths with base folder removed', () => { 496 + const directory: Directory = { 497 + $type: 'place.wisp.fs#directory', 498 + type: 'directory', 499 + entries: [ 500 + { 501 + name: 'index.html', 502 + node: { 503 + $type: 'place.wisp.fs#file', 504 + type: 'file', 505 + blob: undefined as any, 506 + }, 507 + }, 508 + ], 509 + } 510 + 511 + const mockBlob = createMockBlobRef('text/html', 100) 512 + const uploadResults: FileUploadResult[] = [ 513 + { 514 + hash: TEST_CID_STRING, 515 + blobRef: mockBlob, 516 + }, 517 + ] 518 + 519 + // Path includes base folder that should be normalized 520 + const filePaths = ['dist/index.html'] 521 + 522 + const updated = updateFileBlobs(directory, uploadResults, filePaths) 523 + 524 + const fileNode = updated.entries[0].node 525 + if ('blob' in fileNode) { 526 + expect(fileNode.blob).toBeDefined() 527 + } else { 528 + throw new Error('Expected file node') 529 + } 530 + }) 531 + 532 + test('should preserve file metadata (encoding, mimeType, base64)', () => { 533 + const directory: Directory = { 534 + $type: 'place.wisp.fs#directory', 535 + type: 'directory', 536 + entries: [ 537 + { 538 + name: 'data.json', 539 + node: { 540 + $type: 'place.wisp.fs#file', 541 + type: 'file', 542 + blob: undefined as any, 543 + }, 544 + }, 545 + ], 546 + } 547 + 548 + const mockBlob = createMockBlobRef('application/json', 200) 549 + const uploadResults: FileUploadResult[] = [ 550 + { 551 + hash: TEST_CID_STRING, 552 + blobRef: mockBlob, 553 + mimeType: 'application/json', 554 + encoding: 'gzip', 555 + base64: true, 556 + }, 557 + ] 558 + 559 + const filePaths = ['data.json'] 560 + 561 + const updated = updateFileBlobs(directory, uploadResults, filePaths) 562 + 563 + const fileNode = updated.entries[0].node 564 + if ('blob' in fileNode && 'mimeType' in fileNode && 'encoding' in fileNode && 'base64' in fileNode) { 565 + expect(fileNode.mimeType).toBe('application/json') 566 + expect(fileNode.encoding).toBe('gzip') 567 + expect(fileNode.base64).toBe(true) 568 + } else { 569 + throw new Error('Expected file node with metadata') 570 + } 571 + }) 572 + 573 + test('should handle multiple files at different directory levels', () => { 574 + const directory: Directory = { 575 + $type: 'place.wisp.fs#directory', 576 + type: 'directory', 577 + entries: [ 578 + { 579 + name: 'index.html', 580 + node: { 581 + $type: 'place.wisp.fs#file', 582 + type: 'file', 583 + blob: undefined as any, 584 + }, 585 + }, 586 + { 587 + name: 'assets', 588 + node: { 589 + $type: 'place.wisp.fs#directory', 590 + type: 'directory', 591 + entries: [ 592 + { 593 + name: 'logo.svg', 594 + node: { 595 + $type: 'place.wisp.fs#file', 596 + type: 'file', 597 + blob: undefined as any, 598 + }, 599 + }, 600 + ], 601 + }, 602 + }, 603 + ], 604 + } 605 + 606 + const htmlBlob = createMockBlobRef('text/html', 100) 607 + const svgBlob = createMockBlobRef('image/svg+xml', 500) 608 + 609 + const uploadResults: FileUploadResult[] = [ 610 + { 611 + hash: TEST_CID_STRING, 612 + blobRef: htmlBlob, 613 + }, 614 + { 615 + hash: TEST_CID_STRING, 616 + blobRef: svgBlob, 617 + }, 618 + ] 619 + 620 + const filePaths = ['index.html', 'assets/logo.svg'] 621 + 622 + const updated = updateFileBlobs(directory, uploadResults, filePaths) 623 + 624 + // Check root file 625 + const indexNode = updated.entries[0].node 626 + if ('blob' in indexNode) { 627 + expect(indexNode.blob.mimeType).toBe('text/html') 628 + } 629 + 630 + // Check nested file 631 + const assetsDir = updated.entries[1] 632 + if ('entries' in assetsDir.node) { 633 + const logoNode = assetsDir.node.entries[0].node 634 + if ('blob' in logoNode) { 635 + expect(logoNode.blob.mimeType).toBe('image/svg+xml') 636 + } 637 + } 638 + }) 639 + })