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
···
*.log
.vscode
.idea
+
server
+
.prettierrc
+
testDeploy
+
.tangled
+
.crush
+
.claude
+
server
+
hosting-service
+1
.gitignore
···
# production
/build
+
/result
# misc
.DS_Store
+22
.tangled/workflows/test.yml
···
+
when:
+
- event: ["push", "pull_request"]
+
branch: main
+
+
engine: nixery
+
+
dependencies:
+
nixpkgs:
+
- git
+
github:NixOS/nixpkgs/nixpkgs-unstable:
+
- bun
+
+
steps:
+
- name: install dependencies
+
command: |
+
export PATH="$HOME/.nix-profile/bin:$PATH"
+
bun install
+
+
- name: run all tests
+
command: |
+
export PATH="$HOME/.nix-profile/bin:$PATH"
+
bun test
+10 -6
Dockerfile
···
COPY public ./public
# Build the application (if needed)
-
# RUN bun run build
+
RUN bun build \
+
--compile \
+
--minify \
+
--outfile server \
+
src/index.ts
+
+
FROM scratch AS runtime
+
WORKDIR /app
+
COPY --from=base /app/server /app/server
# Set environment variables (can be overridden at runtime)
ENV PORT=3000
···
# Expose the application port
EXPOSE 3000
-
# Health check
-
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
-
CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
-
# Start the application
-
CMD ["bun", "src/index.ts"]
+
CMD ["./server"]
+7 -12
README.md
···
-
# Elysia with Bun runtime
+
# Wisp.place
+
A static site hosting service built on the AT Protocol. [https://wisp.place](https://wisp.place)
-
## Getting Started
-
To get started with this template, simply paste this command into your terminal:
-
```bash
-
bun create elysia ./elysia-example
-
```
+
/src is the main backend
+
+
/hosting-service is the microservice that serves on-disk caches of sites pulled from the firehose and pdses
-
## Development
-
To start the development server run:
-
```bash
-
bun run dev
-
```
+
/cli is the wisp-cli, a way to upload sites directly to the pds
-
Open http://localhost:3000/ with your browser to see the result.
+
full readme soon
-41
api.md
···
-
/**
-
* AUTHENTICATION ROUTES
-
*
-
* Handles OAuth authentication flow for Bluesky/ATProto accounts
-
* All routes are on the editor.wisp.place subdomain
-
*
-
* Routes:
-
* POST /api/auth/signin - Initiate OAuth sign-in flow
-
* GET /api/auth/callback - OAuth callback handler (redirect from PDS)
-
* GET /api/auth/status - Check current authentication status
-
* POST /api/auth/logout - Sign out and clear session
-
*/
-
-
/**
-
* CUSTOM DOMAIN ROUTES
-
*
-
* Handles custom domain (BYOD - Bring Your Own Domain) management
-
* Users can claim custom domains with DNS verification (TXT + CNAME)
-
* and map them to their sites
-
*
-
* Routes:
-
* GET /api/check-domain - Fast verification check for routing (public)
-
* GET /api/custom-domains - List user's custom domains
-
* POST /api/custom-domains/check - Check domain availability and DNS config
-
* POST /api/custom-domains/claim - Claim a custom domain
-
* PUT /api/custom-domains/:id/site - Update site mapping
-
* DELETE /api/custom-domains/:id - Remove a custom domain
-
* POST /api/custom-domains/:id/verify - Manually trigger verification
-
*/
-
-
/**
-
* WISP SITE MANAGEMENT ROUTES
-
*
-
* API endpoints for managing user's Wisp sites stored in ATProto repos
-
* Handles reading site metadata, fetching content, updating sites, and uploads
-
* All routes are on the editor.wisp.place subdomain
-
*
-
* Routes:
-
* GET /wisp/sites - List all sites for authenticated user
-
* POST /wisp/upload-files - Upload and deploy files as a site
-
*/
+179
bun.lock
···
"lucide-react": "^0.546.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+
"react-shiki": "^0.9.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "4",
"tw-animate-css": "^1.4.0",
···
"@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=="],
+
"@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=="],
+
+
"@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=="],
+
+
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="],
+
+
"@shikijs/langs": ["@shikijs/langs@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="],
+
+
"@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="],
+
+
"@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=="],
+
+
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
+
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
···
"@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=="],
+
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
+
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
+
+
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+
+
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+
+
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
···
"@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="],
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
+
+
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
···
"cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="],
+
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
+
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
+
+
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
···
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
···
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
+
+
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
+
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"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=="],
···
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
+
+
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
···
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+
"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=="],
+
+
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
+
+
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+
+
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
+
"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=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
···
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
"inline-style-parser": ["inline-style-parser@0.2.6", "", {}, "sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg=="],
+
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
"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=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
+
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
+
+
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
+
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
+
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
···
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
+
+
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
···
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
"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=="],
+
+
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
+
+
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
+
+
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
+
+
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
+
+
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.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=="],
+
+
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
+
+
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
+
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
+
+
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
+
+
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
+
+
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
+
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
+
+
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
+
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
+
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
···
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
+
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
+
+
"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=="],
+
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
+
+
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
···
"process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="],
+
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
+
"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=="],
"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=="],
···
"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=="],
+
"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=="],
+
"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=="],
"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=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
+
"regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="],
+
+
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
+
+
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
+
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"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=="],
···
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
+
"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=="],
+
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
"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=="],
···
"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=="],
"sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg=="],
+
+
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
···
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
+
"style-to-js": ["style-to-js@1.1.19", "", { "dependencies": { "style-to-object": "1.0.12" } }, "sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ=="],
+
+
"style-to-object": ["style-to-object@1.0.12", "", { "dependencies": { "inline-style-parser": "0.2.6" } }, "sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw=="],
+
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
···
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"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=="],
+
+
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"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=="],
···
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
+
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
+
+
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+
+
"unist-util-visit": ["unist-util-visit@5.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=="],
+
+
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"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=="],
···
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
"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=="],
"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=="],
···
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
+
"micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
···
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+
"micromark/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
+420 -25
claude.md
···
-
Wisp.place - Decentralized Static Site Hosting
+
# Wisp.place - Codebase Overview
+
+
**Project URL**: https://wisp.place
+
+
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.
+
+
---
+
+
## ๐Ÿ—๏ธ Architecture Overview
+
+
### Multi-Part System
+
1. **Main Backend** (`/src`) - OAuth, site management, custom domains
+
2. **Hosting Service** (`/hosting-service`) - Microservice that serves cached sites
+
3. **CLI Tool** (`/cli`) - Rust CLI for direct site uploads to PDS
+
4. **Frontend** (`/public`) - React UI for onboarding, editor, admin
+
+
### Tech Stack
+
- **Backend**: Elysia (Bun) + TypeScript + PostgreSQL
+
- **Frontend**: React 19 + Tailwind CSS 4 + Radix UI
+
- **CLI**: Rust with Jacquard (AT Protocol library)
+
- **Database**: PostgreSQL for session/domain/site caching
+
- **AT Protocol**: OAuth 2.0 + custom lexicons for storage
+
+
---
+
+
## ๐Ÿ“‚ Directory Structure
+
+
### `/src` - Main Backend Server
+
**Purpose**: Core server handling OAuth, site management, custom domains, admin features
+
+
**Key Routes**:
+
- `/api/auth/*` - OAuth signin/callback/logout/status
+
- `/api/domain/*` - Custom domain management (BYOD)
+
- `/wisp/*` - Site upload and management
+
- `/api/user/*` - User info and site listing
+
- `/api/admin/*` - Admin console (logs, metrics, DNS verification)
+
+
**Key Files**:
+
- `index.ts` - Express-like Elysia app setup with middleware (CORS, CSP, security headers)
+
- `lib/oauth-client.ts` - OAuth client setup with session/state persistence
+
- `lib/db.ts` - PostgreSQL schema and queries for all tables
+
- `lib/wisp-auth.ts` - Cookie-based authentication middleware
+
- `lib/wisp-utils.ts` - File compression (gzip), manifest creation, blob handling
+
- `lib/sync-sites.ts` - Syncs user's place.wisp.fs records from PDS to database cache
+
- `lib/dns-verify.ts` - DNS verification for custom domains (TXT + CNAME)
+
- `lib/dns-verification-worker.ts` - Background worker that checks domain verification every 10 minutes
+
- `lib/admin-auth.ts` - Simple username/password admin authentication
+
- `lib/observability.ts` - Logging, error tracking, metrics collection
+
- `routes/auth.ts` - OAuth flow handlers
+
- `routes/wisp.ts` - File upload and site creation (/wisp/upload-files)
+
- `routes/domain.ts` - Domain claiming/verification API
+
- `routes/user.ts` - User status/info/sites listing
+
- `routes/site.ts` - Site metadata and file retrieval
+
- `routes/admin.ts` - Admin dashboard API (logs, system health, manual DNS trigger)
+
+
### `/lexicons` & `src/lexicons/`
+
**Purpose**: AT Protocol Lexicon definitions for custom data types
+
+
**Key File**: `fs.json` - Defines `place.wisp.fs` record format
+
- **structure**: Virtual filesystem manifest with tree structure
+
- **site**: string identifier
+
- **root**: directory object containing entries
+
- **file**: blob reference + metadata (encoding, mimeType, base64 flag)
+
- **directory**: array of entries (recursive)
+
- **entry**: name + node (file or directory)
+
+
**Important**: Files are gzip-compressed and base64-encoded before upload to bypass PDS content sniffing
+
+
### `/hosting-service`
+
**Purpose**: Lightweight microservice that serves cached sites from disk
+
+
**Architecture**:
+
- Routes by domain lookup in PostgreSQL
+
- Caches site content locally on first access or firehose event
+
- Listens to AT Protocol firehose for new site records
+
- Automatically downloads and caches files from PDS
+
- SSRF-protected fetch (timeout, size limits, private IP blocking)
+
+
**Routes**:
+
1. Custom domains (`/*`) โ†’ lookup custom_domains table
+
2. Wisp subdomains (`/*.wisp.place/*`) โ†’ lookup domains table
+
3. DNS hash routing (`/hash.dns.wisp.place/*`) โ†’ lookup custom_domains by hash
+
4. Direct serving (`/s.wisp.place/:identifier/:site/*`) โ†’ fetch from PDS if not cached
+
+
**HTML Path Rewriting**: Absolute paths in HTML (`/style.css`) automatically rewritten to relative (`/:identifier/:site/style.css`)
+
+
### `/cli`
+
**Purpose**: Rust CLI tool for direct site uploads using app password or OAuth
+
+
**Flow**:
+
1. Authenticate with handle + app password or OAuth
+
2. Walk directory tree, compress files
+
3. Upload blobs to PDS via agent
+
4. Create place.wisp.fs record with manifest
+
5. Store site in database cache
+
+
**Auth Methods**:
+
- `--password` flag for app password auth
+
- OAuth loopback server for browser-based auth
+
- Supports both (password preferred if provided)
+
+
---
+
+
## ๐Ÿ” Key Concepts
+
+
### Custom Domains (BYOD - Bring Your Own Domain)
+
**Process**:
+
1. User claims custom domain via API
+
2. System generates hash (SHA256(domain + secret))
+
3. User adds DNS records:
+
- TXT at `_wisp.example.com` = their DID
+
- CNAME at `example.com` = `{hash}.dns.wisp.place`
+
4. Background worker checks verification every 10 minutes
+
5. Once verified, custom domain routes to their hosted sites
+
+
**Tables**: `custom_domains` (id, domain, did, rkey, verified, last_verified_at)
+
+
### Wisp Subdomains
+
**Process**:
+
1. Handle claimed on first signup (e.g., alice โ†’ alice.wisp.place)
+
2. Stored in `domains` table mapping domain โ†’ DID
+
3. Served by hosting service
+
+
### Site Storage
+
**Locations**:
+
- **Authoritative**: PDS (AT Protocol repo) as `place.wisp.fs` record
+
- **Cache**: PostgreSQL `sites` table (rkey, did, site_name, created_at)
+
- **File Cache**: Hosting service caches downloaded files on disk
+
+
**Limits**:
+
- MAX_SITE_SIZE: 300MB total
+
- MAX_FILE_SIZE: 100MB per file
+
- MAX_FILE_COUNT: 2000 files
+
+
### File Compression Strategy
+
**Why**: Bypass PDS content sniffing issues (was treating HTML as images)
+
+
**Process**:
+
1. All files gzip-compressed (level 9)
+
2. Compressed content base64-encoded
+
3. Uploaded as `application/octet-stream` MIME type
+
4. Blob metadata stores original MIME type + encoding flag
+
5. Hosting service decompresses on serve
+
+
---
+
+
## ๐Ÿ”„ Data Flow
+
+
### User Registration โ†’ Site Upload
+
```
+
1. OAuth signin โ†’ state/session stored in DB
+
2. Cookie set with DID
+
3. Sync sites from PDS to cache DB
+
4. If no sites/domain โ†’ redirect to onboarding
+
5. User creates site โ†’ POST /wisp/upload-files
+
6. Files compressed, uploaded as blobs
+
7. place.wisp.fs record created
+
8. Site cached in DB
+
9. Hosting service notified via firehose
+
```
+
+
### Custom Domain Setup
+
```
+
1. User claims domain (DB check + allocation)
+
2. System generates hash
+
3. User adds DNS records (_wisp.domain TXT + CNAME)
+
4. Background worker verifies every 10 min
+
5. Hosting service routes based on verification status
+
```
+
+
### Site Access
+
```
+
Hosting Service:
+
1. Request arrives at custom domain or *.wisp.place
+
2. Domain lookup in PostgreSQL
+
3. Check cache for site files
+
4. If not cached:
+
- Fetch from PDS using DID + rkey
+
- Decompress files
+
- Save to disk cache
+
5. Serve files (with HTML path rewriting)
+
```
+
+
---
+
+
## ๐Ÿ› ๏ธ Important Implementation Details
+
+
### OAuth Implementation
+
- **State & Session Storage**: PostgreSQL (with expiration)
+
- **Key Rotation**: Periodic rotation + expiration cleanup (hourly)
+
- **OAuth Flow**: Redirects to PDS, returns to /api/auth/callback
+
- **Session Timeout**: 30 days
+
- **State Timeout**: 1 hour
+
+
### Security Headers
+
- X-Frame-Options: DENY
+
- X-Content-Type-Options: nosniff
+
- Strict-Transport-Security: max-age=31536000
+
- Content-Security-Policy (configured for Elysia + React)
+
- X-XSS-Protection: 1; mode=block
+
- Referrer-Policy: strict-origin-when-cross-origin
+
+
### Admin Authentication
+
- Simple username/password (hashed with bcrypt)
+
- Session-based cookie auth (24hr expiration)
+
- Separate `admin_session` cookie
+
- Initial setup prompted on startup
+
+
### Observability
+
- **Logging**: Structured logging with service tags + event types
+
- **Error Tracking**: Captures error context (message, stack, etc.)
+
- **Metrics**: Request counts, latencies, error rates
+
- **Log Levels**: debug, info, warn, error
+
- **Collection**: Centralized log collector with in-memory buffer
+
+
---
+
+
## ๐Ÿ“ Database Schema
+
+
### oauth_states
+
- key (primary key)
+
- data (JSON)
+
- created_at, expires_at (timestamps)
-
Architecture Overview
+
### oauth_sessions
+
- sub (primary key - subject/DID)
+
- data (JSON with OAuth session)
+
- updated_at, expires_at
-
Wisp.Place a two-service application that provides static site hosting on the AT
-
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.
+
### oauth_keys
+
- kid (primary key - key ID)
+
- jwk (JSON Web Key)
+
- created_at
+
+
### domains
+
- domain (primary key - e.g., alice.wisp.place)
+
- did (unique - user's DID)
+
- rkey (optional - record key)
+
- created_at
+
+
### custom_domains
+
- id (primary key - UUID)
+
- domain (unique - e.g., example.com)
+
- did (user's DID)
+
- rkey (optional)
+
- verified (boolean)
+
- last_verified_at (timestamp)
+
- created_at
+
+
### sites
+
- id, did, rkey, site_name
+
- created_at, updated_at
+
- Indexes on (did), (did, rkey), (rkey)
+
+
### admin_users
+
- username (primary key)
+
- password_hash (bcrypt)
+
- created_at
+
+
---
+
+
## ๐Ÿš€ Key Workflows
+
+
### Sign In Flow
+
1. POST /api/auth/signin with handle
+
2. System generates state token
+
3. Redirects to PDS OAuth endpoint
+
4. PDS redirects back to /api/auth/callback?code=X&state=Y
+
5. Validate state (CSRF protection)
+
6. Exchange code for session
+
7. Store session in DB, set DID cookie
+
8. Sync sites from PDS
+
9. Redirect to /editor or /onboarding
+
+
### File Upload Flow
+
1. POST /wisp/upload-files with siteName + files
+
2. Validate site name (rkey format rules)
+
3. For each file:
+
- Check size limits
+
- Read as ArrayBuffer
+
- Gzip compress
+
- Base64 encode
+
4. Upload all blobs in parallel via agent.com.atproto.repo.uploadBlob()
+
5. Create manifest with all blob refs
+
6. putRecord() for place.wisp.fs with manifest
+
7. Upsert to sites table
+
8. Return URI + CID
-
Service 1: Main App (Port 8000, Bun runtime, elysia.js)
-
- User-facing editor and API
-
- OAuth authentication (AT Protocol)
-
- File upload processing (gzip + base64 encoding)
-
- Domain management (subdomains + custom domains)
-
- DNS verification worker
-
- React frontend
+
### Domain Verification Flow
+
1. POST /api/custom-domains/claim
+
2. Generate hash = SHA256(domain + secret)
+
3. Store in custom_domains with verified=false
+
4. Return hash for user to configure DNS
+
5. Background worker periodically:
+
- Query custom_domains where verified=false
+
- Verify TXT record at _wisp.domain
+
- Verify CNAME points to hash.dns.wisp.place
+
- Update verified flag + last_verified_at
+
6. Hosting service routes when verified=true
-
Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js)
-
- AT Protocol Firehose listener for real-time updates
-
- Serves hosted websites from local cache
-
- Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain)
-
- Distributed locking for multi-instance coordination
+
---
-
Tech Stack
+
## ๐ŸŽจ Frontend Structure
-
- Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK
-
- Frontend: React 19, Tailwind CSS v4, Shadcn UI
+
### `/public`
+
- **index.tsx** - Landing page with sign-in form
+
- **editor/editor.tsx** - Site editor/management UI
+
- **admin/admin.tsx** - Admin dashboard
+
- **components/ui/** - Reusable components (Button, Card, Dialog, etc.)
+
- **styles/global.css** - Tailwind + custom styles
-
Key Features
+
### Page Flow
+
1. `/` - Landing page (sign in / get started)
+
2. `/editor` - Main app (requires auth)
+
3. `/admin` - Admin console (requires admin auth)
+
4. `/onboarding` - First-time user setup
-
- AT Protocol Integration: Sites stored as place.wisp.fs records in user repos
-
- File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS
-
- Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification
-
- Real-time Sync: Firehose worker listens for site updates and caches files locally
-
- Atomic Updates: Safe cache swapping without downtime
+
---
+
+
## ๐Ÿ” Notable Implementation Patterns
+
+
### File Handling
+
- Files stored as base64-encoded gzip in PDS blobs
+
- Metadata preserves original MIME type
+
- Hosting service decompresses on serve
+
- Workaround for PDS image pipeline issues with HTML
+
+
### Error Handling
+
- Comprehensive logging with context
+
- Graceful degradation (e.g., site sync failure doesn't break auth)
+
- Structured error responses with details
+
+
### Performance
+
- Site sync: Batch fetch up to 100 records per request
+
- Blob upload: Parallel promises for all files
+
- DNS verification: Batched background worker (10 min intervals)
+
- Caching: Two-tier (DB + disk in hosting service)
+
+
### Validation
+
- Lexicon validation on manifest creation
+
- Record type checking
+
- Domain format validation
+
- Site name format validation (AT Protocol rkey rules)
+
- File size limits enforced before upload
+
+
---
+
+
## ๐Ÿ› Known Quirks & Workarounds
+
+
1. **PDS Content Sniffing**: Files must be uploaded as `application/octet-stream` (even HTML) and base64-encoded to prevent PDS from misinterpreting content
+
+
2. **Max URL Query Size**: DNS verification worker queries in batch; may need pagination for users with many custom domains
+
+
3. **File Count Limits**: Max 500 entries per directory (Lexicon constraint); large sites split across multiple directories
+
+
4. **Blob Size Limits**: Individual blobs limited to 100MB by Lexicon; large files handled differently if needed
+
+
5. **HTML Path Rewriting**: Only in hosting service for `/s.wisp.place/:identifier/:site/*` routes; custom domains handled differently
+
+
---
+
+
## ๐Ÿ“‹ Environment Variables
+
+
- `DOMAIN` - Base domain with protocol (default: `https://wisp.place`)
+
- `CLIENT_NAME` - OAuth client name (default: `PDS-View`)
+
- `DATABASE_URL` - PostgreSQL connection (default: `postgres://postgres:postgres@localhost:5432/wisp`)
+
- `NODE_ENV` - production/development
+
- `HOSTING_PORT` - Hosting service port (default: 3001)
+
- `BASE_DOMAIN` - Domain for URLs (default: wisp.place)
+
+
---
+
+
## ๐Ÿง‘โ€๐Ÿ’ป Development Notes
+
+
### Adding New Features
+
1. **New routes**: Add to `/src/routes/*.ts`, import in index.ts
+
2. **DB changes**: Add migration in db.ts
+
3. **New lexicons**: Update `/lexicons/*.json`, regenerate types
+
4. **Admin features**: Add to /api/admin endpoints
+
+
### Testing
+
- Run with `bun test`
+
- CSRF tests in lib/csrf.test.ts
+
- Utility tests in lib/wisp-utils.test.ts
+
+
### Debugging
+
- Check logs via `/api/admin/logs` (requires admin auth)
+
- DNS verification manual trigger: POST /api/admin/verify-dns
+
- Health check: GET /api/health (includes DNS verifier status)
+
+
---
+
+
## ๐Ÿš€ Deployment Considerations
+
+
1. **Secrets**: Admin password, OAuth keys, database credentials
+
2. **HTTPS**: Required (HSTS header enforces it)
+
3. **CDN**: Custom domains require DNS configuration
+
4. **Scaling**:
+
- Main server: Horizontal scaling with session DB
+
- Hosting service: Independent scaling, disk cache per instance
+
5. **Backups**: PostgreSQL database critical; firehose provides recovery
+
+
---
+
+
## ๐Ÿ“š Related Technologies
+
+
- **AT Protocol**: Decentralized identity, OAuth 2.0
+
- **Jacquard**: Rust library for AT Protocol interactions
+
- **Elysia**: Bun web framework (similar to Express/Hono)
+
- **Lexicon**: AT Protocol's schema definition language
+
- **Firehose**: Real-time event stream of repo changes
+
- **PDS**: Personal Data Server (where users' data stored)
+
+
---
+
+
## ๐ŸŽฏ Project Goals
+
+
โœ… Decentralized site hosting (data owned by users)
+
โœ… Custom domain support with DNS verification
+
โœ… Fast CDN distribution via hosting service
+
โœ… Developer tools (CLI + API)
+
โœ… Admin dashboard for monitoring
+
โœ… Zero user data retention (sites in PDS, sessions in DB only)
+
+
---
+
+
**Last Updated**: November 2025
+
**Status**: Active development
+24
cli/.gitignore
···
+
.DS_STORE
+
jacquard/
+
binaries/
+
# Generated by Cargo
+
# will have compiled files and executables
+
debug
+
target
+
+
# These are backup files generated by rustfmt
+
**/*.rs.bk
+
+
# MSVC Windows builds of rustc generate these, which store debugging information
+
*.pdb
+
+
# Generated by cargo mutants
+
# Contains mutation testing data
+
**/mutants.out*/
+
+
# RustRover
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+
#.idea/
+9 -151
cli/Cargo.lock
···
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
-
name = "foreign-types"
-
version = "0.3.2"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
-
dependencies = [
-
"foreign-types-shared",
-
]
-
-
[[package]]
-
name = "foreign-types-shared"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
-
-
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
-
name = "hyper-tls"
-
version = "0.6.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
-
dependencies = [
-
"bytes",
-
"http-body-util",
-
"hyper",
-
"hyper-util",
-
"native-tls",
-
"tokio",
-
"tokio-native-tls",
-
"tower-service",
-
]
-
-
[[package]]
name = "hyper-util"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
name = "jacquard"
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"bytes",
"getrandom 0.2.16",
···
[[package]]
name = "jacquard-api"
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"bon",
"bytes",
···
[[package]]
name = "jacquard-common"
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"base64 0.22.1",
"bon",
···
[[package]]
name = "jacquard-derive"
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"heck 0.5.0",
"jacquard-lexicon",
···
[[package]]
name = "jacquard-identity"
-
version = "0.9.0"
+
version = "0.9.1"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"bon",
"bytes",
···
[[package]]
name = "jacquard-lexicon"
-
version = "0.9.0"
+
version = "0.9.1"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"cid",
"dashmap",
···
[[package]]
name = "jacquard-oauth"
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"base64 0.22.1",
"bytes",
···
[[package]]
-
name = "native-tls"
-
version = "0.2.14"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
-
dependencies = [
-
"libc",
-
"log",
-
"openssl",
-
"openssl-probe",
-
"openssl-sys",
-
"schannel",
-
"security-framework",
-
"security-framework-sys",
-
"tempfile",
-
]
-
-
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
-
name = "openssl"
-
version = "0.10.74"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
-
dependencies = [
-
"bitflags",
-
"cfg-if",
-
"foreign-types",
-
"libc",
-
"once_cell",
-
"openssl-macros",
-
"openssl-sys",
-
]
-
-
[[package]]
-
name = "openssl-macros"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
-
dependencies = [
-
"proc-macro2",
-
"quote",
-
"syn 2.0.108",
-
]
-
-
[[package]]
-
name = "openssl-probe"
-
version = "0.1.6"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
-
-
[[package]]
-
name = "openssl-sys"
-
version = "0.9.110"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
-
dependencies = [
-
"cc",
-
"libc",
-
"pkg-config",
-
"vcpkg",
-
]
-
-
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"der",
"spki",
-
-
[[package]]
-
name = "pkg-config"
-
version = "0.3.32"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "potential_utf"
···
"http-body-util",
"hyper",
"hyper-rustls",
-
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
-
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
···
"serde_urlencoded",
"sync_wrapper",
"tokio",
-
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
···
[[package]]
-
name = "schannel"
-
version = "0.1.28"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
-
dependencies = [
-
"windows-sys 0.61.2",
-
]
-
-
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"pkcs8",
"subtle",
"zeroize",
-
]
-
-
[[package]]
-
name = "security-framework"
-
version = "2.11.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
-
dependencies = [
-
"bitflags",
-
"core-foundation 0.9.4",
-
"core-foundation-sys",
-
"libc",
-
"security-framework-sys",
-
]
-
-
[[package]]
-
name = "security-framework-sys"
-
version = "2.15.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
-
dependencies = [
-
"core-foundation-sys",
-
"libc",
[[package]]
···
[[package]]
-
name = "tokio-native-tls"
-
version = "0.3.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
-
dependencies = [
-
"native-tls",
-
"tokio",
-
]
-
-
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
-
-
[[package]]
-
name = "vcpkg"
-
version = "0.2.15"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
+9 -8
cli/Cargo.toml
···
place_wisp = []
[dependencies]
-
jacquard = { path = "jacquard/crates/jacquard", features = ["loopback"] }
-
jacquard-oauth = { path = "jacquard/crates/jacquard-oauth" }
-
jacquard-api = { path = "jacquard/crates/jacquard-api" }
-
jacquard-common = { path = "jacquard/crates/jacquard-common" }
-
jacquard-identity = { path = "jacquard/crates/jacquard-identity", features = ["dns"] }
-
jacquard-derive = { path = "jacquard/crates/jacquard-derive" }
-
jacquard-lexicon = { path = "jacquard/crates/jacquard-lexicon" }
+
jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] }
+
jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
+
jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
+
jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
+
jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] }
+
jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
+
jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
clap = { version = "4.5.51", features = ["derive"] }
tokio = { version = "1.48", features = ["full"] }
miette = { version = "7.6.0", features = ["fancy"] }
serde_json = "1.0.145"
serde = { version = "1.0", features = ["derive"] }
shellexpand = "3.1.1"
-
reqwest = "0.12"
+
#reqwest = "0.12"
+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
rustversion = "1.0"
flate2 = "1.0"
base64 = "0.22"
+23
cli/build-linux.sh
···
+
#!/usr/bin/env bash
+
# Build Linux binaries (statically linked)
+
set -e
+
mkdir -p binaries
+
+
# Build Linux binaries
+
echo "Building Linux binaries..."
+
+
echo "Building Linux ARM64 (static)..."
+
nix-shell -p rustup --run '
+
rustup target add aarch64-unknown-linux-musl
+
RUSTFLAGS="-C target-feature=+crt-static" cargo zigbuild --release --target aarch64-unknown-linux-musl
+
'
+
cp target/aarch64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-aarch64-linux
+
+
echo "Building Linux x86_64 (static)..."
+
nix-shell -p rustup --run '
+
rustup target add x86_64-unknown-linux-musl
+
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target x86_64-unknown-linux-musl
+
'
+
cp target/x86_64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-x86_64-linux
+
+
echo "Done! Binaries in ./binaries/"
+15
cli/build-macos.sh
···
+
#!/bin/bash
+
# Build Linux and macOS binaries
+
+
set -e
+
+
mkdir -p binaries
+
rm -rf target
+
+
# Build macOS binaries natively
+
echo "Building macOS binaries..."
+
rustup target add aarch64-apple-darwin
+
+
echo "Building macOS arm64 binary."
+
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target aarch64-apple-darwin
+
cp target/aarch64-apple-darwin/release/wisp-cli binaries/wisp-cli-macos-arm64
-14
cli/test_headers.rs
···
-
use http::Request;
-
-
fn main() {
-
let builder = Request::builder()
-
.header(http::header::CONTENT_TYPE, "*/*")
-
.header(http::header::CONTENT_TYPE, "application/octet-stream");
-
-
let req = builder.body(()).unwrap();
-
-
println!("Content-Type headers:");
-
for value in req.headers().get_all(http::header::CONTENT_TYPE) {
-
println!(" {:?}", value);
-
}
-
}
+63
crates.nix
···
+
{...}: {
+
perSystem = {
+
pkgs,
+
config,
+
lib,
+
inputs',
+
...
+
}: {
+
# declare projects
+
nci.projects."wisp-place-cli" = {
+
path = ./cli;
+
export = false;
+
};
+
nci.toolchains.mkBuild = _:
+
with inputs'.fenix.packages;
+
combine [
+
minimal.rustc
+
minimal.cargo
+
targets.x86_64-pc-windows-gnu.latest.rust-std
+
targets.x86_64-unknown-linux-gnu.latest.rust-std
+
targets.aarch64-apple-darwin.latest.rust-std
+
];
+
# configure crates
+
nci.crates."wisp-cli" = {
+
profiles = {
+
dev.runTests = false;
+
release.runTests = false;
+
};
+
targets."x86_64-unknown-linux-gnu" = {
+
default = true;
+
};
+
targets."x86_64-pc-windows-gnu" = let
+
targetPkgs = pkgs.pkgsCross.mingwW64;
+
targetCC = targetPkgs.stdenv.cc;
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
+
in rec {
+
depsDrvConfig.mkDerivation = {
+
nativeBuildInputs = [targetCC];
+
buildInputs = with targetPkgs; [windows.pthreads];
+
};
+
depsDrvConfig.env = rec {
+
TARGET_CC = "${targetCC.targetPrefix}cc";
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
+
};
+
drvConfig = depsDrvConfig;
+
};
+
targets."aarch64-apple-darwin" = let
+
targetPkgs = pkgs.pkgsCross.aarch64-darwin;
+
targetCC = targetPkgs.stdenv.cc;
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
+
in rec {
+
depsDrvConfig.mkDerivation = {
+
nativeBuildInputs = [targetCC];
+
};
+
depsDrvConfig.env = rec {
+
TARGET_CC = "${targetCC.targetPrefix}cc";
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
+
};
+
drvConfig = depsDrvConfig;
+
};
+
};
+
};
+
}
+318
flake.lock
···
+
{
+
"nodes": {
+
"crane": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1758758545,
+
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
+
"owner": "ipetkov",
+
"repo": "crane",
+
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
+
"type": "github"
+
},
+
"original": {
+
"owner": "ipetkov",
+
"ref": "v0.21.1",
+
"repo": "crane",
+
"type": "github"
+
}
+
},
+
"dream2nix": {
+
"inputs": {
+
"nixpkgs": [
+
"nci",
+
"nixpkgs"
+
],
+
"purescript-overlay": "purescript-overlay",
+
"pyproject-nix": "pyproject-nix"
+
},
+
"locked": {
+
"lastModified": 1754978539,
+
"narHash": "sha256-nrDovydywSKRbWim9Ynmgj8SBm8LK3DI2WuhIqzOHYI=",
+
"owner": "nix-community",
+
"repo": "dream2nix",
+
"rev": "fbec3263cb4895ac86ee9506cdc4e6919a1a2214",
+
"type": "github"
+
},
+
"original": {
+
"owner": "nix-community",
+
"repo": "dream2nix",
+
"type": "github"
+
}
+
},
+
"fenix": {
+
"inputs": {
+
"nixpkgs": [
+
"nixpkgs"
+
],
+
"rust-analyzer-src": "rust-analyzer-src"
+
},
+
"locked": {
+
"lastModified": 1762584108,
+
"narHash": "sha256-wZUW7dlXMXaRdvNbaADqhF8gg9bAfFiMV+iyFQiDv+Y=",
+
"owner": "nix-community",
+
"repo": "fenix",
+
"rev": "32f3ad3b6c690061173e1ac16708874975ec6056",
+
"type": "github"
+
},
+
"original": {
+
"owner": "nix-community",
+
"repo": "fenix",
+
"type": "github"
+
}
+
},
+
"flake-compat": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1696426674,
+
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
+
"owner": "edolstra",
+
"repo": "flake-compat",
+
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
+
"type": "github"
+
},
+
"original": {
+
"owner": "edolstra",
+
"repo": "flake-compat",
+
"type": "github"
+
}
+
},
+
"mk-naked-shell": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1681286841,
+
"narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=",
+
"owner": "90-008",
+
"repo": "mk-naked-shell",
+
"rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd",
+
"type": "github"
+
},
+
"original": {
+
"owner": "90-008",
+
"repo": "mk-naked-shell",
+
"type": "github"
+
}
+
},
+
"nci": {
+
"inputs": {
+
"crane": "crane",
+
"dream2nix": "dream2nix",
+
"mk-naked-shell": "mk-naked-shell",
+
"nixpkgs": [
+
"nixpkgs"
+
],
+
"parts": "parts",
+
"rust-overlay": "rust-overlay",
+
"treefmt": "treefmt"
+
},
+
"locked": {
+
"lastModified": 1762582646,
+
"narHash": "sha256-MMzE4xccG+8qbLhdaZoeFDUKWUOn3B4lhp5dZmgukmM=",
+
"owner": "90-008",
+
"repo": "nix-cargo-integration",
+
"rev": "0993c449377049fa8868a664e8290ac6658e0b9a",
+
"type": "github"
+
},
+
"original": {
+
"owner": "90-008",
+
"repo": "nix-cargo-integration",
+
"type": "github"
+
}
+
},
+
"nixpkgs": {
+
"locked": {
+
"lastModified": 1762361079,
+
"narHash": "sha256-lz718rr1BDpZBYk7+G8cE6wee3PiBUpn8aomG/vLLiY=",
+
"owner": "nixos",
+
"repo": "nixpkgs",
+
"rev": "ffcdcf99d65c61956d882df249a9be53e5902ea5",
+
"type": "github"
+
},
+
"original": {
+
"owner": "nixos",
+
"ref": "nixpkgs-unstable",
+
"repo": "nixpkgs",
+
"type": "github"
+
}
+
},
+
"parts": {
+
"inputs": {
+
"nixpkgs-lib": [
+
"nci",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1762440070,
+
"narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=",
+
"owner": "hercules-ci",
+
"repo": "flake-parts",
+
"rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8",
+
"type": "github"
+
},
+
"original": {
+
"owner": "hercules-ci",
+
"repo": "flake-parts",
+
"type": "github"
+
}
+
},
+
"parts_2": {
+
"inputs": {
+
"nixpkgs-lib": [
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1762440070,
+
"narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=",
+
"owner": "hercules-ci",
+
"repo": "flake-parts",
+
"rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8",
+
"type": "github"
+
},
+
"original": {
+
"owner": "hercules-ci",
+
"repo": "flake-parts",
+
"type": "github"
+
}
+
},
+
"purescript-overlay": {
+
"inputs": {
+
"flake-compat": "flake-compat",
+
"nixpkgs": [
+
"nci",
+
"dream2nix",
+
"nixpkgs"
+
],
+
"slimlock": "slimlock"
+
},
+
"locked": {
+
"lastModified": 1728546539,
+
"narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=",
+
"owner": "thomashoneyman",
+
"repo": "purescript-overlay",
+
"rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4",
+
"type": "github"
+
},
+
"original": {
+
"owner": "thomashoneyman",
+
"repo": "purescript-overlay",
+
"type": "github"
+
}
+
},
+
"pyproject-nix": {
+
"inputs": {
+
"nixpkgs": [
+
"nci",
+
"dream2nix",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1752481895,
+
"narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=",
+
"owner": "pyproject-nix",
+
"repo": "pyproject.nix",
+
"rev": "16ee295c25107a94e59a7fc7f2e5322851781162",
+
"type": "github"
+
},
+
"original": {
+
"owner": "pyproject-nix",
+
"repo": "pyproject.nix",
+
"type": "github"
+
}
+
},
+
"root": {
+
"inputs": {
+
"fenix": "fenix",
+
"nci": "nci",
+
"nixpkgs": "nixpkgs",
+
"parts": "parts_2"
+
}
+
},
+
"rust-analyzer-src": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1762438844,
+
"narHash": "sha256-ApIKJf6CcMsV2nYBXhGF95BmZMO/QXPhgfSnkA/rVUo=",
+
"owner": "rust-lang",
+
"repo": "rust-analyzer",
+
"rev": "4bf516ee5a960c1e2eee9fedd9b1c9e976a19c86",
+
"type": "github"
+
},
+
"original": {
+
"owner": "rust-lang",
+
"ref": "nightly",
+
"repo": "rust-analyzer",
+
"type": "github"
+
}
+
},
+
"rust-overlay": {
+
"inputs": {
+
"nixpkgs": [
+
"nci",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1762569282,
+
"narHash": "sha256-vINZAJpXQTZd5cfh06Rcw7hesH7sGSvi+Tn+HUieJn8=",
+
"owner": "oxalica",
+
"repo": "rust-overlay",
+
"rev": "a35a6144b976f70827c2fe2f5c89d16d8f9179d8",
+
"type": "github"
+
},
+
"original": {
+
"owner": "oxalica",
+
"repo": "rust-overlay",
+
"type": "github"
+
}
+
},
+
"slimlock": {
+
"inputs": {
+
"nixpkgs": [
+
"nci",
+
"dream2nix",
+
"purescript-overlay",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1688756706,
+
"narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=",
+
"owner": "thomashoneyman",
+
"repo": "slimlock",
+
"rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c",
+
"type": "github"
+
},
+
"original": {
+
"owner": "thomashoneyman",
+
"repo": "slimlock",
+
"type": "github"
+
}
+
},
+
"treefmt": {
+
"inputs": {
+
"nixpkgs": [
+
"nci",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1762410071,
+
"narHash": "sha256-aF5fvoZeoXNPxT0bejFUBXeUjXfHLSL7g+mjR/p5TEg=",
+
"owner": "numtide",
+
"repo": "treefmt-nix",
+
"rev": "97a30861b13c3731a84e09405414398fbf3e109f",
+
"type": "github"
+
},
+
"original": {
+
"owner": "numtide",
+
"repo": "treefmt-nix",
+
"type": "github"
+
}
+
}
+
},
+
"root": "root",
+
"version": 7
+
}
+36
flake.nix
···
+
{
+
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
+
inputs.nci.url = "github:90-008/nix-cargo-integration";
+
inputs.nci.inputs.nixpkgs.follows = "nixpkgs";
+
inputs.parts.url = "github:hercules-ci/flake-parts";
+
inputs.parts.inputs.nixpkgs-lib.follows = "nixpkgs";
+
inputs.fenix = {
+
url = "github:nix-community/fenix";
+
inputs.nixpkgs.follows = "nixpkgs";
+
};
+
+
outputs = inputs @ {
+
parts,
+
nci,
+
...
+
}:
+
parts.lib.mkFlake {inherit inputs;} {
+
systems = ["x86_64-linux" "aarch64-darwin"];
+
imports = [
+
nci.flakeModule
+
./crates.nix
+
];
+
perSystem = {
+
pkgs,
+
config,
+
...
+
}: let
+
crateOutputs = config.nci.outputs."wisp-cli";
+
in {
+
devShells.default = crateOutputs.devShell;
+
packages.default = crateOutputs.packages.release;
+
packages.wisp-cli-windows = crateOutputs.allTargets."x86_64-pc-windows-gnu".packages.release;
+
packages.wisp-cli-darwin = crateOutputs.allTargets."aarch64-apple-darwin".packages.release;
+
};
+
};
+
}
+14 -192
hosting-service/bun.lock
···
"@atproto/api": "^0.17.4",
"@atproto/identity": "^0.4.9",
"@atproto/lexicon": "^0.5.1",
-
"@atproto/sync": "^0.1.35",
+
"@atproto/sync": "^0.1.36",
"@atproto/xrpc": "^0.7.5",
-
"@elysiajs/node": "^1.4.1",
-
"@elysiajs/opentelemetry": "latest",
-
"elysia": "latest",
+
"@hono/node-server": "^1.19.6",
+
"hono": "^4.10.4",
"mime-types": "^2.1.35",
"multiformats": "^13.4.1",
"postgres": "^3.4.5",
},
"devDependencies": {
+
"@types/bun": "^1.3.1",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.10.5",
"tsx": "^4.19.2",
···
"@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" } }, ""],
-
"@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" } }, ""],
+
"@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=="],
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, ""],
···
"@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" } }, ""],
-
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
-
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, ""],
-
"@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=="],
-
-
"@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=="],
-
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="],
···
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
-
"@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=="],
-
-
"@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=="],
+
"@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="],
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, ""],
-
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
-
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, ""],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, ""],
-
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
-
-
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="],
-
-
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
-
-
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
-
-
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
-
-
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
-
-
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
-
-
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
-
-
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
-
-
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
-
-
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
-
-
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
-
-
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
-
-
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
-
-
"@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=="],
-
-
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
+
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
"@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
"@types/node": ["@types/node@22.18.12", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog=="],
-
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
+
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, ""],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, ""],
-
"acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
-
-
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
-
-
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
-
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
-
"array-flatten": ["array-flatten@1.1.1", "", {}, ""],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, ""],
···
"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" } }, ""],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, ""],
+
+
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
"bytes": ["bytes@3.1.2", "", {}, ""],
···
"cborg": ["cborg@1.10.2", "", { "bin": "cli.js" }, ""],
-
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
-
-
"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=="],
-
-
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
-
-
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
-
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, ""],
"content-type": ["content-type@1.0.5", "", {}, ""],
-
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
+
"cookie": ["cookie@0.7.1", "", {}, ""],
"cookie-signature": ["cookie-signature@1.0.6", "", {}, ""],
-
"crossws": ["crossws@0.4.1", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-E7WKBcHVhAVrY6JYD5kteNqVq1GSZxqGrdSiwXR9at+XHi43HJoCQKXcCczR5LBnBquFZPsB3o7HklulKoBU5w=="],
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, ""],
···
"ee-first": ["ee-first@1.1.1", "", {}, ""],
-
"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=="],
-
-
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
-
"encodeurl": ["encodeurl@2.0.0", "", {}, ""],
"es-define-property": ["es-define-property@1.0.1", "", {}, ""],
···
"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=="],
-
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
-
"escape-html": ["escape-html@1.0.3", "", {}, ""],
"etag": ["etag@1.8.1", "", {}, ""],
···
"eventemitter3": ["eventemitter3@4.0.7", "", {}, ""],
"events": ["events@3.3.0", "", {}, ""],
-
-
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" } }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
"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" } }, ""],
-
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
-
"fast-redact": ["fast-redact@3.5.0", "", {}, ""],
-
-
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
-
-
"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=="],
"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" } }, ""],
···
"function-bind": ["function-bind@1.1.2", "", {}, ""],
-
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
-
"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" } }, ""],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, ""],
···
"has-symbols": ["has-symbols@1.1.0", "", {}, ""],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, ""],
+
+
"hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="],
"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" } }, ""],
···
"ieee754": ["ieee754@1.2.1", "", {}, ""],
-
"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=="],
-
"inherits": ["inherits@2.0.4", "", {}, ""],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, ""],
-
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
-
-
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
-
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, ""],
-
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
-
-
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
-
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, ""],
"media-typer": ["media-typer@0.3.0", "", {}, ""],
-
-
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, ""],
···
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""],
-
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
-
"ms": ["ms@2.0.0", "", {}, ""],
"multiformats": ["multiformats@13.4.1", "", {}, ""],
···
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, ""],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, ""],
-
-
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"p-finally": ["p-finally@1.0.0", "", {}, ""],
···
"parseurl": ["parseurl@1.3.3", "", {}, ""],
-
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
-
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, ""],
"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" }, ""],
···
"process-warning": ["process-warning@3.0.0", "", {}, ""],
-
"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=="],
-
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, ""],
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, ""],
···
"real-require": ["real-require@0.2.0", "", {}, ""],
-
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
-
-
"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=="],
-
-
"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=="],
-
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, ""],
···
"setprototypeof": ["setprototypeof@1.2.0", "", {}, ""],
-
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
-
"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" } }, ""],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, ""],
···
"split2": ["split2@4.2.0", "", {}, ""],
-
"srvx": ["srvx@0.8.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ=="],
-
"statuses": ["statuses@2.0.1", "", {}, ""],
-
-
"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=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, ""],
-
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
-
-
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
-
-
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
-
"thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, ""],
"tlds": ["tlds@1.261.0", "", { "bin": "bin.js" }, ""],
"toidentifier": ["toidentifier@1.0.1", "", {}, ""],
-
"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=="],
-
"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=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, ""],
-
-
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, ""],
···
"vary": ["vary@1.1.2", "", {}, ""],
-
"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=="],
-
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, ""],
-
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
-
-
"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=="],
-
-
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
-
"zod": ["zod@3.25.76", "", {}, ""],
"@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, ""],
···
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, ""],
-
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
-
-
"express/cookie": ["cookie@0.7.1", "", {}, ""],
-
-
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
-
"send/encodeurl": ["encodeurl@1.0.2", "", {}, ""],
"send/ms": ["ms@2.1.3", "", {}, ""],
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, ""],
-
-
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
-
-
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
}
+8 -5
hosting-service/package.json
···
"version": "1.0.0",
"type": "module",
"scripts": {
-
"dev": "tsx watch src/index.ts",
-
"start": "node --loader tsx src/index.ts"
+
"dev": "tsx --env-file=.env watch src/index.ts",
+
"build": "tsc",
+
"start": "tsx --env-file=.env src/index.ts",
+
"backfill": "tsx --env-file=.env src/index.ts --backfill"
},
"dependencies": {
"@atproto/api": "^0.17.4",
"@atproto/identity": "^0.4.9",
"@atproto/lexicon": "^0.5.1",
-
"@atproto/sync": "^0.1.35",
+
"@atproto/sync": "^0.1.36",
"@atproto/xrpc": "^0.7.5",
-
"@elysiajs/opentelemetry": "latest",
-
"elysia": "latest",
+
"@hono/node-server": "^1.19.6",
+
"hono": "^4.10.4",
"mime-types": "^2.1.35",
"multiformats": "^13.4.1",
"postgres": "^3.4.5"
},
"devDependencies": {
+
"@types/bun": "^1.3.1",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.10.5",
"tsx": "^4.19.2"
+33 -10
hosting-service/src/index.ts
···
import app from './server';
+
import { serve } from '@hono/node-server';
import { FirehoseWorker } from './lib/firehose';
import { logger } from './lib/observability';
import { mkdirSync, existsSync } from 'fs';
+
import { backfillCache } from './lib/backfill';
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
-
const CACHE_DIR = './cache/sites';
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
+
+
// Parse CLI arguments
+
const args = process.argv.slice(2);
+
const hasBackfillFlag = args.includes('--backfill');
+
const backfillOnStartup = hasBackfillFlag || process.env.BACKFILL_ON_STARTUP === 'true';
// Ensure cache directory exists
if (!existsSync(CACHE_DIR)) {
···
});
firehose.start();
+
+
// Run backfill if requested
+
if (backfillOnStartup) {
+
console.log('๐Ÿ”„ Backfill requested, starting cache backfill...');
+
backfillCache({
+
skipExisting: true,
+
concurrency: 3,
+
}).then((stats) => {
+
console.log('โœ… Cache backfill completed');
+
}).catch((err) => {
+
console.error('โŒ Cache backfill error:', err);
+
});
+
}
// Add health check endpoint
-
app.get('/health', () => {
+
app.get('/health', (c) => {
const firehoseHealth = firehose.getHealth();
-
return {
+
return c.json({
status: 'ok',
firehose: firehoseHealth,
-
};
+
});
+
});
+
+
// Start HTTP server with Node.js adapter
+
const server = serve({
+
fetch: app.fetch,
+
port: PORT,
});
-
// Start HTTP server
-
app.listen(PORT, () => {
-
console.log(`
+
console.log(`
Wisp Hosting Service
Server: http://localhost:${PORT}
···
Cache: ${CACHE_DIR}
Firehose: Connected to Firehose
`);
-
});
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\n๐Ÿ›‘ Shutting down...');
firehose.stop();
-
app.stop();
+
server.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('\n๐Ÿ›‘ Shutting down...');
firehose.stop();
-
app.stop();
+
server.close();
process.exit(0);
});
+1 -1
hosting-service/src/lexicon/types/place/wisp/fs.ts
···
* GENERATED CODE - DO NOT MODIFY
*/
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
-
import { CID } from 'multiformats/cid'
+
import { CID } from 'multiformats'
import { validate as _validate } from '../../../lexicons'
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
+136
hosting-service/src/lib/backfill.ts
···
+
import { getAllSites } from './db';
+
import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils';
+
import { logger } from './observability';
+
+
export interface BackfillOptions {
+
skipExisting?: boolean; // Skip sites already in cache
+
concurrency?: number; // Number of sites to cache concurrently
+
maxSites?: number; // Maximum number of sites to backfill (for testing)
+
}
+
+
export interface BackfillStats {
+
total: number;
+
cached: number;
+
skipped: number;
+
failed: number;
+
duration: number;
+
}
+
+
/**
+
* Backfill all sites from the database into the local cache
+
*/
+
export async function backfillCache(options: BackfillOptions = {}): Promise<BackfillStats> {
+
const {
+
skipExisting = true,
+
concurrency = 3,
+
maxSites,
+
} = options;
+
+
const startTime = Date.now();
+
const stats: BackfillStats = {
+
total: 0,
+
cached: 0,
+
skipped: 0,
+
failed: 0,
+
duration: 0,
+
};
+
+
logger.info('Starting cache backfill', { skipExisting, concurrency, maxSites });
+
console.log(`
+
โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
+
โ•‘ CACHE BACKFILL STARTING โ•‘
+
โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
+
`);
+
+
try {
+
// Get all sites from database
+
let sites = await getAllSites();
+
stats.total = sites.length;
+
+
logger.info(`Found ${sites.length} sites in database`);
+
console.log(`๐Ÿ“Š Found ${sites.length} sites in database`);
+
+
// Limit if specified
+
if (maxSites && maxSites > 0) {
+
sites = sites.slice(0, maxSites);
+
console.log(`โš™๏ธ Limited to ${maxSites} sites for backfill`);
+
}
+
+
// Process sites in batches
+
const batches: typeof sites[] = [];
+
for (let i = 0; i < sites.length; i += concurrency) {
+
batches.push(sites.slice(i, i + concurrency));
+
}
+
+
let processed = 0;
+
for (const batch of batches) {
+
await Promise.all(
+
batch.map(async (site) => {
+
try {
+
// Check if already cached
+
if (skipExisting && isCached(site.did, site.rkey)) {
+
stats.skipped++;
+
processed++;
+
logger.debug(`Skipping already cached site`, { did: site.did, rkey: site.rkey });
+
console.log(`โญ๏ธ [${processed}/${sites.length}] Skipped (cached): ${site.display_name || site.rkey}`);
+
return;
+
}
+
+
// Fetch site record
+
const siteData = await fetchSiteRecord(site.did, site.rkey);
+
if (!siteData) {
+
stats.failed++;
+
processed++;
+
logger.error('Site record not found during backfill', null, { did: site.did, rkey: site.rkey });
+
console.log(`โŒ [${processed}/${sites.length}] Failed (not found): ${site.display_name || site.rkey}`);
+
return;
+
}
+
+
// Get PDS endpoint
+
const pdsEndpoint = await getPdsForDid(site.did);
+
if (!pdsEndpoint) {
+
stats.failed++;
+
processed++;
+
logger.error('PDS not found during backfill', null, { did: site.did });
+
console.log(`โŒ [${processed}/${sites.length}] Failed (no PDS): ${site.display_name || site.rkey}`);
+
return;
+
}
+
+
// Download and cache site
+
await downloadAndCacheSite(site.did, site.rkey, siteData.record, pdsEndpoint, siteData.cid);
+
stats.cached++;
+
processed++;
+
logger.info('Successfully cached site during backfill', { did: site.did, rkey: site.rkey });
+
console.log(`โœ… [${processed}/${sites.length}] Cached: ${site.display_name || site.rkey}`);
+
} catch (err) {
+
stats.failed++;
+
processed++;
+
logger.error('Failed to cache site during backfill', err, { did: site.did, rkey: site.rkey });
+
console.log(`โŒ [${processed}/${sites.length}] Failed: ${site.display_name || site.rkey}`);
+
}
+
})
+
);
+
}
+
+
stats.duration = Date.now() - startTime;
+
+
console.log(`
+
โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
+
โ•‘ CACHE BACKFILL COMPLETED โ•‘
+
โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
+
+
๐Ÿ“Š Total Sites: ${stats.total}
+
โœ… Cached: ${stats.cached}
+
โญ๏ธ Skipped: ${stats.skipped}
+
โŒ Failed: ${stats.failed}
+
โฑ๏ธ Duration: ${(stats.duration / 1000).toFixed(2)}s
+
`);
+
+
logger.info('Cache backfill completed', stats);
+
} catch (err) {
+
logger.error('Cache backfill failed', err);
+
console.error('โŒ Cache backfill failed:', err);
+
}
+
+
return stats;
+
}
+27 -86
hosting-service/src/lib/db.ts
···
import postgres from 'postgres';
+
import { createHash } from 'crypto';
const sql = postgres(
process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp',
···
verified: boolean;
}
-
// In-memory cache with TTL
-
interface CacheEntry<T> {
-
data: T;
-
expiry: number;
-
}
-
-
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
-
-
class SimpleCache<T> {
-
private cache = new Map<string, CacheEntry<T>>();
-
-
get(key: string): T | null {
-
const entry = this.cache.get(key);
-
if (!entry) return null;
-
if (Date.now() > entry.expiry) {
-
this.cache.delete(key);
-
return null;
-
}
-
-
return entry.data;
-
}
-
-
set(key: string, data: T): void {
-
this.cache.set(key, {
-
data,
-
expiry: Date.now() + CACHE_TTL_MS,
-
});
-
}
-
-
// Periodic cleanup to prevent memory leaks
-
cleanup(): void {
-
const now = Date.now();
-
for (const [key, entry] of this.cache.entries()) {
-
if (now > entry.expiry) {
-
this.cache.delete(key);
-
}
-
}
-
}
-
}
-
-
// Create cache instances
-
const wispDomainCache = new SimpleCache<DomainLookup | null>();
-
const customDomainCache = new SimpleCache<CustomDomainLookup | null>();
-
const customDomainHashCache = new SimpleCache<CustomDomainLookup | null>();
-
-
// Run cleanup every 5 minutes
-
setInterval(() => {
-
wispDomainCache.cleanup();
-
customDomainCache.cleanup();
-
customDomainHashCache.cleanup();
-
}, 5 * 60 * 1000);
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
const key = domain.toLowerCase();
-
// Check cache first
-
const cached = wispDomainCache.get(key);
-
if (cached !== null) {
-
return cached;
-
}
-
// Query database
const result = await sql<DomainLookup[]>`
SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1
`;
const data = result[0] || null;
-
-
// Store in cache
-
wispDomainCache.set(key, data);
return data;
}
···
export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> {
const key = domain.toLowerCase();
-
// Check cache first
-
const cached = customDomainCache.get(key);
-
if (cached !== null) {
-
return cached;
-
}
-
// Query database
const result = await sql<CustomDomainLookup[]>`
SELECT id, domain, did, rkey, verified FROM custom_domains
···
`;
const data = result[0] || null;
-
// Store in cache
-
customDomainCache.set(key, data);
-
return data;
}
export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> {
-
// Check cache first
-
const cached = customDomainHashCache.get(hash);
-
if (cached !== null) {
-
return cached;
-
}
-
// Query database
const result = await sql<CustomDomainLookup[]>`
SELECT id, domain, did, rkey, verified FROM custom_domains
WHERE id = ${hash} AND verified = true LIMIT 1
`;
const data = result[0] || null;
-
-
// Store in cache
-
customDomainHashCache.set(hash, data);
return data;
}
···
}
}
+
export interface SiteRecord {
+
did: string;
+
rkey: string;
+
display_name?: string;
+
}
+
+
export async function getAllSites(): Promise<SiteRecord[]> {
+
try {
+
const result = await sql<SiteRecord[]>`
+
SELECT did, rkey, display_name FROM sites
+
ORDER BY created_at DESC
+
`;
+
return result;
+
} catch (err) {
+
console.error('Failed to get all sites', err);
+
return [];
+
}
+
}
+
/**
* Generate a numeric lock ID from a string key
* PostgreSQL advisory locks use bigint (64-bit signed integer)
*/
function stringToLockId(key: string): bigint {
-
let hash = 0n;
-
for (let i = 0; i < key.length; i++) {
-
const char = BigInt(key.charCodeAt(i));
-
hash = ((hash << 5n) - hash + char) & 0x7FFFFFFFFFFFFFFFn; // Keep within signed int64 range
-
}
-
return hash;
+
const hash = createHash('sha256').update(key).digest('hex');
+
// Take first 16 hex characters (64 bits) and convert to bigint
+
const hashNum = BigInt('0x' + hash.substring(0, 16));
+
// Keep within signed int64 range
+
return hashNum & 0x7FFFFFFFFFFFFFFFn;
}
/**
···
const lockId = stringToLockId(key);
try {
-
const result = await sql`SELECT pg_try_advisory_lock(${lockId}) as acquired`;
+
const result = await sql`SELECT pg_try_advisory_lock(${Number(lockId)}) as acquired`;
return result[0]?.acquired === true;
} catch (err) {
console.error('Failed to acquire lock', { key, error: err });
···
const lockId = stringToLockId(key);
try {
-
await sql`SELECT pg_advisory_unlock(${lockId})`;
+
await sql`SELECT pg_advisory_unlock(${Number(lockId)})`;
} catch (err) {
console.error('Failed to release lock', { key, error: err });
}
+259 -219
hosting-service/src/lib/firehose.ts
···
-
import { existsSync, rmSync } from 'fs';
-
import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
-
import { upsertSite, tryAcquireLock, releaseLock } from './db';
-
import { safeFetch } from './safe-fetch';
-
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs';
-
import { Firehose } from '@atproto/sync';
-
import { IdResolver } from '@atproto/identity';
+
import { existsSync, rmSync } from 'fs'
+
import {
+
getPdsForDid,
+
downloadAndCacheSite,
+
extractBlobCid,
+
fetchSiteRecord
+
} from './utils'
+
import { upsertSite, tryAcquireLock, releaseLock } from './db'
+
import { safeFetch } from './safe-fetch'
+
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'
+
import { Firehose } from '@atproto/sync'
+
import { IdResolver } from '@atproto/identity'
-
const CACHE_DIR = './cache/sites';
+
const CACHE_DIR = './cache/sites'
export class FirehoseWorker {
-
private firehose: Firehose | null = null;
-
private idResolver: IdResolver;
-
private isShuttingDown = false;
-
private lastEventTime = Date.now();
+
private firehose: Firehose | null = null
+
private idResolver: IdResolver
+
private isShuttingDown = false
+
private lastEventTime = Date.now()
-
constructor(
-
private logger?: (msg: string, data?: Record<string, unknown>) => void,
-
) {
-
this.idResolver = new IdResolver();
-
}
+
constructor(
+
private logger?: (msg: string, data?: Record<string, unknown>) => void
+
) {
+
this.idResolver = new IdResolver()
+
}
-
private log(msg: string, data?: Record<string, unknown>) {
-
const log = this.logger || console.log;
-
log(`[FirehoseWorker] ${msg}`, data || {});
-
}
+
private log(msg: string, data?: Record<string, unknown>) {
+
const log = this.logger || console.log
+
log(`[FirehoseWorker] ${msg}`, data || {})
+
}
-
start() {
-
this.log('Starting firehose worker');
-
this.connect();
-
}
+
start() {
+
this.log('Starting firehose worker')
+
this.connect()
+
}
-
stop() {
-
this.log('Stopping firehose worker');
-
this.isShuttingDown = true;
+
stop() {
+
this.log('Stopping firehose worker')
+
this.isShuttingDown = true
-
if (this.firehose) {
-
this.firehose.destroy();
-
this.firehose = null;
-
}
-
}
+
if (this.firehose) {
+
this.firehose.destroy()
+
this.firehose = null
+
}
+
}
-
private connect() {
-
if (this.isShuttingDown) return;
+
private connect() {
+
if (this.isShuttingDown) return
-
this.log('Connecting to AT Protocol firehose');
+
this.log('Connecting to AT Protocol firehose')
-
this.firehose = new Firehose({
-
idResolver: this.idResolver,
-
service: 'wss://bsky.network',
-
filterCollections: ['place.wisp.fs'],
-
handleEvent: async (evt) => {
-
this.lastEventTime = Date.now();
+
this.firehose = new Firehose({
+
idResolver: this.idResolver,
+
service: 'wss://bsky.network',
+
filterCollections: ['place.wisp.fs'],
+
handleEvent: async (evt: any) => {
+
this.lastEventTime = Date.now()
-
// Watch for write events
-
if (evt.event === 'create' || evt.event === 'update') {
-
const record = evt.record;
+
// Watch for write events
+
if (evt.event === 'create' || evt.event === 'update') {
+
const record = evt.record
-
// If the write is a valid place.wisp.fs record
-
if (
-
evt.collection === 'place.wisp.fs' &&
-
isRecord(record) &&
-
validateRecord(record).success
-
) {
-
this.log('Received place.wisp.fs event', {
-
did: evt.did,
-
event: evt.event,
-
rkey: evt.rkey,
-
});
+
// If the write is a valid place.wisp.fs record
+
if (
+
evt.collection === 'place.wisp.fs' &&
+
isRecord(record) &&
+
validateRecord(record).success
+
) {
+
this.log('Received place.wisp.fs event', {
+
did: evt.did,
+
event: evt.event,
+
rkey: evt.rkey
+
})
-
try {
-
await this.handleCreateOrUpdate(evt.did, evt.rkey, record, evt.cid?.toString());
-
} catch (err) {
-
this.log('Error handling event', {
-
did: evt.did,
-
event: evt.event,
-
rkey: evt.rkey,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
}
-
}
-
} else if (evt.event === 'delete' && evt.collection === 'place.wisp.fs') {
-
this.log('Received delete event', {
-
did: evt.did,
-
rkey: evt.rkey,
-
});
+
try {
+
await this.handleCreateOrUpdate(
+
evt.did,
+
evt.rkey,
+
record,
+
evt.cid?.toString()
+
)
+
} catch (err) {
+
this.log('Error handling event', {
+
did: evt.did,
+
event: evt.event,
+
rkey: evt.rkey,
+
error:
+
err instanceof Error
+
? err.message
+
: String(err)
+
})
+
}
+
}
+
} else if (
+
evt.event === 'delete' &&
+
evt.collection === 'place.wisp.fs'
+
) {
+
this.log('Received delete event', {
+
did: evt.did,
+
rkey: evt.rkey
+
})
-
try {
-
await this.handleDelete(evt.did, evt.rkey);
-
} catch (err) {
-
this.log('Error handling delete', {
-
did: evt.did,
-
rkey: evt.rkey,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
}
-
}
-
},
-
onError: (err) => {
-
this.log('Firehose error', {
-
error: err instanceof Error ? err.message : String(err),
-
stack: err instanceof Error ? err.stack : undefined,
-
fullError: err,
-
});
-
console.error('Full firehose error:', err);
-
},
-
});
+
try {
+
await this.handleDelete(evt.did, evt.rkey)
+
} catch (err) {
+
this.log('Error handling delete', {
+
did: evt.did,
+
rkey: evt.rkey,
+
error:
+
err instanceof Error ? err.message : String(err)
+
})
+
}
+
}
+
},
+
onError: (err: any) => {
+
this.log('Firehose error', {
+
error: err instanceof Error ? err.message : String(err),
+
stack: err instanceof Error ? err.stack : undefined,
+
fullError: err
+
})
+
console.error('Full firehose error:', err)
+
}
+
})
-
this.firehose.start();
-
this.log('Firehose started');
-
}
+
this.firehose.start()
+
this.log('Firehose started')
+
}
-
private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) {
-
this.log('Processing create/update', { did, site });
+
private async handleCreateOrUpdate(
+
did: string,
+
site: string,
+
record: any,
+
eventCid?: string
+
) {
+
this.log('Processing create/update', { did, site })
-
// Record is already validated in handleEvent
-
const fsRecord = record;
+
// Record is already validated in handleEvent
+
const fsRecord = record
-
const pdsEndpoint = await getPdsForDid(did);
-
if (!pdsEndpoint) {
-
this.log('Could not resolve PDS for DID', { did });
-
return;
-
}
+
const pdsEndpoint = await getPdsForDid(did)
+
if (!pdsEndpoint) {
+
this.log('Could not resolve PDS for DID', { did })
+
return
+
}
-
this.log('Resolved PDS', { did, pdsEndpoint });
+
this.log('Resolved PDS', { did, pdsEndpoint })
-
// Verify record exists on PDS and fetch its CID
-
let verifiedCid: string;
-
try {
-
const result = await fetchSiteRecord(did, site);
+
// Verify record exists on PDS and fetch its CID
+
let verifiedCid: string
+
try {
+
const result = await fetchSiteRecord(did, site)
-
if (!result) {
-
this.log('Record not found on PDS, skipping cache', { did, site });
-
return;
-
}
+
if (!result) {
+
this.log('Record not found on PDS, skipping cache', {
+
did,
+
site
+
})
+
return
+
}
-
verifiedCid = result.cid;
+
verifiedCid = result.cid
-
// Verify event CID matches PDS CID (prevent cache poisoning)
-
if (eventCid && eventCid !== verifiedCid) {
-
this.log('CID mismatch detected - potential spoofed event', {
-
did,
-
site,
-
eventCid,
-
verifiedCid
-
});
-
return;
-
}
+
// Verify event CID matches PDS CID (prevent cache poisoning)
+
if (eventCid && eventCid !== verifiedCid) {
+
this.log('CID mismatch detected - potential spoofed event', {
+
did,
+
site,
+
eventCid,
+
verifiedCid
+
})
+
return
+
}
-
this.log('Record verified on PDS', { did, site, cid: verifiedCid });
-
} catch (err) {
-
this.log('Failed to verify record on PDS', {
-
did,
-
site,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
return;
-
}
+
this.log('Record verified on PDS', { did, site, cid: verifiedCid })
+
} catch (err) {
+
this.log('Failed to verify record on PDS', {
+
did,
+
site,
+
error: err instanceof Error ? err.message : String(err)
+
})
+
return
+
}
-
// Cache the record with verified CID (uses atomic swap internally)
-
// All instances cache locally for edge serving
-
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid);
+
// Cache the record with verified CID (uses atomic swap internally)
+
// All instances cache locally for edge serving
+
await downloadAndCacheSite(
+
did,
+
site,
+
fsRecord,
+
pdsEndpoint,
+
verifiedCid
+
)
-
// Acquire distributed lock only for database write to prevent duplicate writes
-
const lockKey = `db:upsert:${did}:${site}`;
-
const lockAcquired = await tryAcquireLock(lockKey);
+
// Acquire distributed lock only for database write to prevent duplicate writes
+
const lockKey = `db:upsert:${did}:${site}`
+
const lockAcquired = await tryAcquireLock(lockKey)
-
if (!lockAcquired) {
-
this.log('Another instance is writing to DB, skipping upsert', { did, site });
-
this.log('Successfully processed create/update (cached locally)', { did, site });
-
return;
-
}
+
if (!lockAcquired) {
+
this.log('Another instance is writing to DB, skipping upsert', {
+
did,
+
site
+
})
+
this.log('Successfully processed create/update (cached locally)', {
+
did,
+
site
+
})
+
return
+
}
-
try {
-
// Upsert site to database (only one instance does this)
-
await upsertSite(did, site, fsRecord.site);
-
this.log('Successfully processed create/update (cached + DB updated)', { did, site });
-
} finally {
-
// Always release lock, even if DB write fails
-
await releaseLock(lockKey);
-
}
-
}
+
try {
+
// Upsert site to database (only one instance does this)
+
await upsertSite(did, site, fsRecord.site)
+
this.log(
+
'Successfully processed create/update (cached + DB updated)',
+
{ did, site }
+
)
+
} finally {
+
// Always release lock, even if DB write fails
+
await releaseLock(lockKey)
+
}
+
}
-
private async handleDelete(did: string, site: string) {
-
this.log('Processing delete', { did, site });
+
private async handleDelete(did: string, site: string) {
+
this.log('Processing delete', { did, site })
-
// All instances should delete their local cache (no lock needed)
-
const pdsEndpoint = await getPdsForDid(did);
-
if (!pdsEndpoint) {
-
this.log('Could not resolve PDS for DID', { did });
-
return;
-
}
+
// All instances should delete their local cache (no lock needed)
+
const pdsEndpoint = await getPdsForDid(did)
+
if (!pdsEndpoint) {
+
this.log('Could not resolve PDS for DID', { did })
+
return
+
}
-
// Verify record is actually deleted from PDS
-
try {
-
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
-
const recordRes = await safeFetch(recordUrl);
+
// Verify record is actually deleted from PDS
+
try {
+
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`
+
const recordRes = await safeFetch(recordUrl)
-
if (recordRes.ok) {
-
this.log('Record still exists on PDS, not deleting cache', {
-
did,
-
site,
-
});
-
return;
-
}
+
if (recordRes.ok) {
+
this.log('Record still exists on PDS, not deleting cache', {
+
did,
+
site
+
})
+
return
+
}
-
this.log('Verified record is deleted from PDS', {
-
did,
-
site,
-
status: recordRes.status,
-
});
-
} catch (err) {
-
this.log('Error verifying deletion on PDS', {
-
did,
-
site,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
}
+
this.log('Verified record is deleted from PDS', {
+
did,
+
site,
+
status: recordRes.status
+
})
+
} catch (err) {
+
this.log('Error verifying deletion on PDS', {
+
did,
+
site,
+
error: err instanceof Error ? err.message : String(err)
+
})
+
}
-
// Delete cache
-
this.deleteCache(did, site);
+
// Delete cache
+
this.deleteCache(did, site)
-
this.log('Successfully processed delete', { did, site });
-
}
+
this.log('Successfully processed delete', { did, site })
+
}
-
private deleteCache(did: string, site: string) {
-
const cacheDir = `${CACHE_DIR}/${did}/${site}`;
+
private deleteCache(did: string, site: string) {
+
const cacheDir = `${CACHE_DIR}/${did}/${site}`
-
if (!existsSync(cacheDir)) {
-
this.log('Cache directory does not exist, nothing to delete', {
-
did,
-
site,
-
});
-
return;
-
}
+
if (!existsSync(cacheDir)) {
+
this.log('Cache directory does not exist, nothing to delete', {
+
did,
+
site
+
})
+
return
+
}
-
try {
-
rmSync(cacheDir, { recursive: true, force: true });
-
this.log('Cache deleted', { did, site, path: cacheDir });
-
} catch (err) {
-
this.log('Failed to delete cache', {
-
did,
-
site,
-
path: cacheDir,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
}
-
}
+
try {
+
rmSync(cacheDir, { recursive: true, force: true })
+
this.log('Cache deleted', { did, site, path: cacheDir })
+
} catch (err) {
+
this.log('Failed to delete cache', {
+
did,
+
site,
+
path: cacheDir,
+
error: err instanceof Error ? err.message : String(err)
+
})
+
}
+
}
-
getHealth() {
-
const isConnected = this.firehose !== null;
-
const timeSinceLastEvent = Date.now() - this.lastEventTime;
+
getHealth() {
+
const isConnected = this.firehose !== null
+
const timeSinceLastEvent = Date.now() - this.lastEventTime
-
return {
-
connected: isConnected,
-
lastEventTime: this.lastEventTime,
-
timeSinceLastEvent,
-
healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes
-
};
-
}
+
return {
+
connected: isConnected,
+
lastEventTime: this.lastEventTime,
+
timeSinceLastEvent,
+
healthy: isConnected && timeSinceLastEvent < 300000 // 5 minutes
+
}
+
}
}
+434 -84
hosting-service/src/lib/html-rewriter.test.ts
···
-
/**
-
* Simple tests for HTML path rewriter
-
* Run with: bun test
-
*/
+
import { describe, test, expect } from 'bun:test'
+
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter'
+
+
describe('rewriteHtmlPaths', () => {
+
const basePath = '/identifier/site/'
+
+
describe('absolute paths', () => {
+
test('rewrites absolute paths with leading slash', () => {
+
const html = '<img src="/image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('rewrites nested absolute paths', () => {
+
const html = '<link href="/css/style.css">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<link href="/identifier/site/css/style.css">')
+
})
+
})
+
+
describe('relative paths from root document', () => {
+
test('rewrites relative paths with ./ prefix', () => {
+
const html = '<img src="./image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('rewrites relative paths without prefix', () => {
+
const html = '<img src="image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('rewrites relative paths with ../ (should stay at root)', () => {
+
const html = '<img src="../image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
})
+
+
describe('relative paths from nested documents', () => {
+
test('rewrites relative path from nested document', () => {
+
const html = '<img src="./photo.jpg">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/index.html'
+
)
+
expect(result).toBe(
+
'<img src="/identifier/site/folder1/folder2/photo.jpg">'
+
)
+
})
+
+
test('rewrites plain filename from nested document', () => {
+
const html = '<script src="app.js"></script>'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/index.html'
+
)
+
expect(result).toBe(
+
'<script src="/identifier/site/folder1/folder2/app.js"></script>'
+
)
+
})
+
+
test('rewrites ../ to go up one level', () => {
+
const html = '<img src="../image.png">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/folder3/index.html'
+
)
+
expect(result).toBe(
+
'<img src="/identifier/site/folder1/folder2/image.png">'
+
)
+
})
+
+
test('rewrites multiple ../ to go up multiple levels', () => {
+
const html = '<link href="../../css/style.css">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/folder3/index.html'
+
)
+
expect(result).toBe(
+
'<link href="/identifier/site/folder1/css/style.css">'
+
)
+
})
+
+
test('rewrites ../ with additional path segments', () => {
+
const html = '<img src="../assets/logo.png">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'pages/about/index.html'
+
)
+
expect(result).toBe(
+
'<img src="/identifier/site/pages/assets/logo.png">'
+
)
+
})
+
+
test('handles complex nested relative paths', () => {
+
const html = '<script src="../../lib/vendor/jquery.js"></script>'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'pages/blog/post/index.html'
+
)
+
expect(result).toBe(
+
'<script src="/identifier/site/pages/lib/vendor/jquery.js"></script>'
+
)
+
})
+
+
test('handles ../ going past root (stays at root)', () => {
+
const html = '<img src="../../../image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
})
+
+
describe('external URLs and special schemes', () => {
+
test('does not rewrite http URLs', () => {
+
const html = '<img src="http://example.com/image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="http://example.com/image.png">')
+
})
-
import { test, expect } from 'bun:test';
-
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter';
+
test('does not rewrite https URLs', () => {
+
const html = '<link href="https://cdn.example.com/style.css">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<link href="https://cdn.example.com/style.css">'
+
)
+
})
-
test('rewriteHtmlPaths - rewrites absolute paths in src attributes', () => {
-
const html = '<img src="/logo.png">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<img src="/did:plc:123/mysite/logo.png">');
-
});
+
test('does not rewrite protocol-relative URLs', () => {
+
const html = '<script src="//cdn.example.com/script.js"></script>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<script src="//cdn.example.com/script.js"></script>'
+
)
+
})
-
test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => {
-
const html = '<link rel="stylesheet" href="/style.css">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<link rel="stylesheet" href="/did:plc:123/mysite/style.css">');
-
});
+
test('does not rewrite data URIs', () => {
+
const html =
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
+
)
+
})
-
test('rewriteHtmlPaths - preserves external URLs', () => {
-
const html = '<img src="https://example.com/logo.png">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<img src="https://example.com/logo.png">');
-
});
+
test('does not rewrite mailto links', () => {
+
const html = '<a href="mailto:test@example.com">Email</a>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<a href="mailto:test@example.com">Email</a>')
+
})
-
test('rewriteHtmlPaths - preserves protocol-relative URLs', () => {
-
const html = '<script src="//cdn.example.com/script.js"></script>';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<script src="//cdn.example.com/script.js"></script>');
-
});
+
test('does not rewrite tel links', () => {
+
const html = '<a href="tel:+1234567890">Call</a>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<a href="tel:+1234567890">Call</a>')
+
})
+
})
-
test('rewriteHtmlPaths - preserves data URIs', () => {
-
const html = '<img src="data:image/png;base64,abc123">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<img src="data:image/png;base64,abc123">');
-
});
+
describe('different HTML attributes', () => {
+
test('rewrites src attribute', () => {
+
const html = '<img src="/image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
-
test('rewriteHtmlPaths - preserves anchors', () => {
-
const html = '<a href="/#section">Jump</a>';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<a href="/#section">Jump</a>');
-
});
+
test('rewrites href attribute', () => {
+
const html = '<a href="/page.html">Link</a>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<a href="/identifier/site/page.html">Link</a>')
+
})
-
test('rewriteHtmlPaths - preserves relative paths', () => {
-
const html = '<img src="./logo.png">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<img src="./logo.png">');
-
});
+
test('rewrites action attribute', () => {
+
const html = '<form action="/submit"></form>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<form action="/identifier/site/submit"></form>')
+
})
-
test('rewriteHtmlPaths - handles single quotes', () => {
-
const html = "<img src='/logo.png'>";
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe("<img src='/did:plc:123/mysite/logo.png'>");
-
});
+
test('rewrites data attribute', () => {
+
const html = '<object data="/document.pdf"></object>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<object data="/identifier/site/document.pdf"></object>'
+
)
+
})
-
test('rewriteHtmlPaths - handles srcset', () => {
-
const html = '<img srcset="/logo.png 1x, /logo@2x.png 2x">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<img srcset="/did:plc:123/mysite/logo.png 1x, /did:plc:123/mysite/logo@2x.png 2x">');
-
});
+
test('rewrites poster attribute', () => {
+
const html = '<video poster="/thumbnail.jpg"></video>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<video poster="/identifier/site/thumbnail.jpg"></video>'
+
)
+
})
-
test('rewriteHtmlPaths - handles form actions', () => {
-
const html = '<form action="/submit"></form>';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<form action="/did:plc:123/mysite/submit"></form>');
-
});
+
test('rewrites srcset attribute with single URL', () => {
+
const html = '<img srcset="/image.png 1x">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img srcset="/identifier/site/image.png 1x">'
+
)
+
})
-
test('rewriteHtmlPaths - handles complex HTML', () => {
-
const html = `
+
test('rewrites srcset attribute with multiple URLs', () => {
+
const html = '<img srcset="/image-1x.png 1x, /image-2x.png 2x">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img srcset="/identifier/site/image-1x.png 1x, /identifier/site/image-2x.png 2x">'
+
)
+
})
+
+
test('rewrites srcset with width descriptors', () => {
+
const html = '<img srcset="/small.jpg 320w, /large.jpg 1024w">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img srcset="/identifier/site/small.jpg 320w, /identifier/site/large.jpg 1024w">'
+
)
+
})
+
+
test('rewrites srcset with relative paths from nested document', () => {
+
const html = '<img srcset="../img1.png 1x, ../img2.png 2x">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/index.html'
+
)
+
expect(result).toBe(
+
'<img srcset="/identifier/site/folder1/img1.png 1x, /identifier/site/folder1/img2.png 2x">'
+
)
+
})
+
})
+
+
describe('quote handling', () => {
+
test('handles double quotes', () => {
+
const html = '<img src="/image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('handles single quotes', () => {
+
const html = "<img src='/image.png'>"
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe("<img src='/identifier/site/image.png'>")
+
})
+
+
test('handles mixed quotes in same document', () => {
+
const html = '<img src="/img1.png"><link href=\'/style.css\'>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img src="/identifier/site/img1.png"><link href=\'/identifier/site/style.css\'>'
+
)
+
})
+
})
+
+
describe('multiple rewrites in same document', () => {
+
test('rewrites multiple attributes in complex HTML', () => {
+
const html = `
<!DOCTYPE html>
<html>
<head>
-
<link rel="stylesheet" href="/style.css">
-
<script src="/app.js"></script>
+
<link href="/css/style.css" rel="stylesheet">
+
<script src="/js/app.js"></script>
+
</head>
+
<body>
+
<img src="/images/logo.png" alt="Logo">
+
<a href="/about.html">About</a>
+
<form action="/submit">
+
<button type="submit">Submit</button>
+
</form>
+
</body>
+
</html>
+
`
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toContain('href="/identifier/site/css/style.css"')
+
expect(result).toContain('src="/identifier/site/js/app.js"')
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
+
expect(result).toContain('href="/identifier/site/about.html"')
+
expect(result).toContain('action="/identifier/site/submit"')
+
})
+
+
test('handles mix of relative and absolute paths', () => {
+
const html = `
+
<img src="/abs/image.png">
+
<img src="./rel/image.png">
+
<img src="../parent/image.png">
+
<img src="https://external.com/image.png">
+
`
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/page.html'
+
)
+
expect(result).toContain('src="/identifier/site/abs/image.png"')
+
expect(result).toContain(
+
'src="/identifier/site/folder1/folder2/rel/image.png"'
+
)
+
expect(result).toContain(
+
'src="/identifier/site/folder1/parent/image.png"'
+
)
+
expect(result).toContain('src="https://external.com/image.png"')
+
})
+
})
+
+
describe('edge cases', () => {
+
test('handles empty src attribute', () => {
+
const html = '<img src="">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="">')
+
})
+
+
test('handles basePath without trailing slash', () => {
+
const html = '<img src="/image.png">'
+
const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('handles basePath with trailing slash', () => {
+
const html = '<img src="/image.png">'
+
const result = rewriteHtmlPaths(
+
html,
+
'/identifier/site/',
+
'index.html'
+
)
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('handles whitespace around equals sign', () => {
+
const html = '<img src = "/image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('preserves query strings in URLs', () => {
+
const html = '<img src="/image.png?v=123">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png?v=123">')
+
})
+
+
test('preserves hash fragments in URLs', () => {
+
const html = '<a href="/page.html#section">Link</a>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<a href="/identifier/site/page.html#section">Link</a>'
+
)
+
})
+
+
test('handles paths with special characters', () => {
+
const html = '<img src="/folder-name/file_name.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img src="/identifier/site/folder-name/file_name.png">'
+
)
+
})
+
})
+
+
describe('real-world scenario', () => {
+
test('handles the example from the bug report', () => {
+
// HTML file at: /folder1/folder2/folder3/index.html
+
// Image at: /folder1/folder2/img.png
+
// Reference: src="../img.png"
+
const html = '<img src="../img.png">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/folder3/index.html'
+
)
+
expect(result).toBe(
+
'<img src="/identifier/site/folder1/folder2/img.png">'
+
)
+
})
+
+
test('handles deeply nested static site structure', () => {
+
// A typical static site with nested pages and shared assets
+
const html = `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<link href="../../css/style.css" rel="stylesheet">
+
<link href="../../css/theme.css" rel="stylesheet">
+
<script src="../../js/main.js"></script>
</head>
<body>
-
<img src="/images/logo.png" srcset="/images/logo.png 1x, /images/logo@2x.png 2x">
-
<a href="/about">About</a>
-
<a href="https://example.com">External</a>
-
<a href="#section">Anchor</a>
+
<img src="../../images/logo.png" alt="Logo">
+
<img src="./post-image.jpg" alt="Post">
+
<a href="../index.html">Back to Blog</a>
+
<a href="../../index.html">Home</a>
</body>
</html>
-
`.trim();
+
`
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'blog/posts/my-post.html'
+
)
+
+
// Assets two levels up
+
expect(result).toContain('href="/identifier/site/css/style.css"')
+
expect(result).toContain('href="/identifier/site/css/theme.css"')
+
expect(result).toContain('src="/identifier/site/js/main.js"')
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
+
+
// Same directory
+
expect(result).toContain(
+
'src="/identifier/site/blog/posts/post-image.jpg"'
+
)
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
+
// One level up
+
expect(result).toContain('href="/identifier/site/blog/index.html"')
-
expect(result).toContain('href="/did:plc:123/mysite/style.css"');
-
expect(result).toContain('src="/did:plc:123/mysite/app.js"');
-
expect(result).toContain('src="/did:plc:123/mysite/images/logo.png"');
-
expect(result).toContain('href="/did:plc:123/mysite/about"');
-
expect(result).toContain('href="https://example.com"'); // External preserved
-
expect(result).toContain('href="#section"'); // Anchor preserved
-
});
+
// Two levels up
+
expect(result).toContain('href="/identifier/site/index.html"')
+
})
+
})
+
})
-
test('isHtmlContent - detects HTML by extension', () => {
-
expect(isHtmlContent('index.html')).toBe(true);
-
expect(isHtmlContent('page.htm')).toBe(true);
-
expect(isHtmlContent('style.css')).toBe(false);
-
expect(isHtmlContent('script.js')).toBe(false);
-
});
+
describe('isHtmlContent', () => {
+
test('identifies HTML by content type', () => {
+
expect(isHtmlContent('file.txt', 'text/html')).toBe(true)
+
expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe(
+
true
+
)
+
})
-
test('isHtmlContent - detects HTML by content type', () => {
-
expect(isHtmlContent('index', 'text/html')).toBe(true);
-
expect(isHtmlContent('index', 'text/html; charset=utf-8')).toBe(true);
-
expect(isHtmlContent('index', 'application/json')).toBe(false);
-
});
+
test('identifies HTML by .html extension', () => {
+
expect(isHtmlContent('index.html')).toBe(true)
+
expect(isHtmlContent('page.html', undefined)).toBe(true)
+
expect(isHtmlContent('/path/to/file.html')).toBe(true)
+
})
+
+
test('identifies HTML by .htm extension', () => {
+
expect(isHtmlContent('index.htm')).toBe(true)
+
expect(isHtmlContent('page.htm', undefined)).toBe(true)
+
})
+
+
test('handles case-insensitive extensions', () => {
+
expect(isHtmlContent('INDEX.HTML')).toBe(true)
+
expect(isHtmlContent('page.HTM')).toBe(true)
+
expect(isHtmlContent('File.HtMl')).toBe(true)
+
})
+
+
test('returns false for non-HTML files', () => {
+
expect(isHtmlContent('script.js')).toBe(false)
+
expect(isHtmlContent('style.css')).toBe(false)
+
expect(isHtmlContent('image.png')).toBe(false)
+
expect(isHtmlContent('data.json')).toBe(false)
+
})
+
+
test('returns false for files with no extension', () => {
+
expect(isHtmlContent('README')).toBe(false)
+
expect(isHtmlContent('Makefile')).toBe(false)
+
})
+
})
+178 -104
hosting-service/src/lib/html-rewriter.ts
···
*/
const REWRITABLE_ATTRIBUTES = [
-
'src',
-
'href',
-
'action',
-
'data',
-
'poster',
-
'srcset',
-
] as const;
+
'src',
+
'href',
+
'action',
+
'data',
+
'poster',
+
'srcset'
+
] as const
/**
* Check if a path should be rewritten
*/
function shouldRewritePath(path: string): boolean {
-
// Don't rewrite empty paths
-
if (!path) return false;
+
// Don't rewrite empty paths
+
if (!path) return false
+
+
// Don't rewrite external URLs (http://, https://, //)
+
if (
+
path.startsWith('http://') ||
+
path.startsWith('https://') ||
+
path.startsWith('//')
+
) {
+
return false
+
}
+
+
// Don't rewrite data URIs or other schemes (except file paths)
+
if (
+
path.includes(':') &&
+
!path.startsWith('./') &&
+
!path.startsWith('../')
+
) {
+
return false
+
}
+
+
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
+
return true
+
}
-
// Don't rewrite external URLs (http://, https://, //)
-
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) {
-
return false;
-
}
+
/**
+
* Normalize a path by resolving . and .. segments
+
*/
+
function normalizePath(path: string): string {
+
const parts = path.split('/')
+
const result: string[] = []
-
// Don't rewrite data URIs or other schemes (except file paths)
-
if (path.includes(':') && !path.startsWith('./') && !path.startsWith('../')) {
-
return false;
-
}
+
for (const part of parts) {
+
if (part === '.' || part === '') {
+
// Skip current directory and empty parts (but keep leading empty for absolute paths)
+
if (part === '' && result.length === 0) {
+
result.push(part)
+
}
+
continue
+
}
+
if (part === '..') {
+
// Go up one directory (but not past root)
+
if (result.length > 0 && result[result.length - 1] !== '..') {
+
result.pop()
+
}
+
continue
+
}
+
result.push(part)
+
}
-
// Don't rewrite pure anchors
-
if (path.startsWith('#')) return false;
+
return result.join('/')
+
}
-
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
-
return true;
+
/**
+
* Get the directory path from a file path
+
* e.g., "folder1/folder2/file.html" -> "folder1/folder2/"
+
*/
+
function getDirectory(filepath: string): string {
+
const lastSlash = filepath.lastIndexOf('/')
+
if (lastSlash === -1) {
+
return ''
+
}
+
return filepath.substring(0, lastSlash + 1)
}
/**
* Rewrite a single path
*/
-
function rewritePath(path: string, basePath: string): string {
-
if (!shouldRewritePath(path)) {
-
return path;
-
}
+
function rewritePath(
+
path: string,
+
basePath: string,
+
documentPath: string
+
): string {
+
if (!shouldRewritePath(path)) {
+
return path
+
}
-
// Handle absolute paths: /file.js -> /base/file.js
-
if (path.startsWith('/')) {
-
return basePath + path.slice(1);
-
}
+
// Handle absolute paths: /file.js -> /base/file.js
+
if (path.startsWith('/')) {
+
return basePath + path.slice(1)
+
}
-
// Handle relative paths: ./file.js or ../file.js or file.js -> /base/file.js
-
// Strip leading ./ or ../ and just use the base path
-
let cleanPath = path;
-
if (cleanPath.startsWith('./')) {
-
cleanPath = cleanPath.slice(2);
-
} else if (cleanPath.startsWith('../')) {
-
// For sites.wisp.place, we can't go up from the site root, so just use base path
-
cleanPath = cleanPath.replace(/^(\.\.\/)+/, '');
-
}
+
// Handle relative paths by resolving against document directory
+
const documentDir = getDirectory(documentPath)
+
let resolvedPath: string
+
+
if (path.startsWith('./')) {
+
// ./file.js relative to current directory
+
resolvedPath = documentDir + path.slice(2)
+
} else if (path.startsWith('../')) {
+
// ../file.js relative to parent directory
+
resolvedPath = documentDir + path
+
} else {
+
// file.js (no prefix) - treat as relative to current directory
+
resolvedPath = documentDir + path
+
}
+
+
// Normalize the path to resolve .. and .
+
resolvedPath = normalizePath(resolvedPath)
-
return basePath + cleanPath;
+
return basePath + resolvedPath
}
/**
* Rewrite srcset attribute (can contain multiple URLs)
* Format: "url1 1x, url2 2x" or "url1 100w, url2 200w"
*/
-
function rewriteSrcset(srcset: string, basePath: string): string {
-
return srcset
-
.split(',')
-
.map(part => {
-
const trimmed = part.trim();
-
const spaceIndex = trimmed.indexOf(' ');
+
function rewriteSrcset(
+
srcset: string,
+
basePath: string,
+
documentPath: string
+
): string {
+
return srcset
+
.split(',')
+
.map((part) => {
+
const trimmed = part.trim()
+
const spaceIndex = trimmed.indexOf(' ')
-
if (spaceIndex === -1) {
-
// No descriptor, just URL
-
return rewritePath(trimmed, basePath);
-
}
+
if (spaceIndex === -1) {
+
// No descriptor, just URL
+
return rewritePath(trimmed, basePath, documentPath)
+
}
-
const url = trimmed.substring(0, spaceIndex);
-
const descriptor = trimmed.substring(spaceIndex);
-
return rewritePath(url, basePath) + descriptor;
-
})
-
.join(', ');
+
const url = trimmed.substring(0, spaceIndex)
+
const descriptor = trimmed.substring(spaceIndex)
+
return rewritePath(url, basePath, documentPath) + descriptor
+
})
+
.join(', ')
}
/**
-
* Rewrite absolute paths in HTML content
+
* Rewrite absolute and relative paths in HTML content
* Uses simple regex matching for safety (no full HTML parsing)
*/
-
export function rewriteHtmlPaths(html: string, basePath: string): string {
-
// Ensure base path ends with /
-
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/';
+
export function rewriteHtmlPaths(
+
html: string,
+
basePath: string,
+
documentPath: string
+
): string {
+
// Ensure base path ends with /
+
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/'
-
let rewritten = html;
+
let rewritten = html
-
// Rewrite each attribute type
-
// Use more specific patterns to prevent ReDoS attacks
-
for (const attr of REWRITABLE_ATTRIBUTES) {
-
if (attr === 'srcset') {
-
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
-
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
-
const srcsetRegex = new RegExp(
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
-
'gi'
-
);
-
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
-
const rewrittenValue = rewriteSrcset(value, normalizedBase);
-
return `${attr}="${rewrittenValue}"`;
-
});
-
} else {
-
// Regular attributes with quoted values
-
// Limit whitespace to prevent catastrophic backtracking
-
const doubleQuoteRegex = new RegExp(
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
-
'gi'
-
);
-
const singleQuoteRegex = new RegExp(
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
-
'gi'
-
);
+
// Rewrite each attribute type
+
// Use more specific patterns to prevent ReDoS attacks
+
for (const attr of REWRITABLE_ATTRIBUTES) {
+
if (attr === 'srcset') {
+
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
+
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
+
const srcsetRegex = new RegExp(
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
+
'gi'
+
)
+
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
+
const rewrittenValue = rewriteSrcset(
+
value,
+
normalizedBase,
+
documentPath
+
)
+
return `${attr}="${rewrittenValue}"`
+
})
+
} else {
+
// Regular attributes with quoted values
+
// Limit whitespace to prevent catastrophic backtracking
+
const doubleQuoteRegex = new RegExp(
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
+
'gi'
+
)
+
const singleQuoteRegex = new RegExp(
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
+
'gi'
+
)
-
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
-
const rewrittenValue = rewritePath(value, normalizedBase);
-
return `${attr}="${rewrittenValue}"`;
-
});
+
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
+
const rewrittenValue = rewritePath(
+
value,
+
normalizedBase,
+
documentPath
+
)
+
return `${attr}="${rewrittenValue}"`
+
})
-
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
-
const rewrittenValue = rewritePath(value, normalizedBase);
-
return `${attr}='${rewrittenValue}'`;
-
});
-
}
-
}
+
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
+
const rewrittenValue = rewritePath(
+
value,
+
normalizedBase,
+
documentPath
+
)
+
return `${attr}='${rewrittenValue}'`
+
})
+
}
+
}
-
return rewritten;
+
return rewritten
}
/**
* Check if content is HTML based on content or filename
*/
-
export function isHtmlContent(
-
filepath: string,
-
contentType?: string
-
): boolean {
-
if (contentType && contentType.includes('text/html')) {
-
return true;
-
}
+
export function isHtmlContent(filepath: string, contentType?: string): boolean {
+
if (contentType && contentType.includes('text/html')) {
+
return true
+
}
-
const ext = filepath.toLowerCase().split('.').pop();
-
return ext === 'html' || ext === 'htm';
+
const ext = filepath.toLowerCase().split('.').pop()
+
return ext === 'html' || ext === 'htm'
}
+36 -38
hosting-service/src/lib/observability.ts
···
// DIY Observability for Hosting Service
-
import type { Context } from 'elysia'
+
import type { Context } from 'hono'
// Types
export interface LogEntry {
···
// Rotate if needed
if (errors.size > MAX_ERRORS) {
const oldest = Array.from(errors.keys())[0]
-
errors.delete(oldest)
+
if (oldest !== undefined) {
+
errors.delete(oldest)
+
}
}
}
},
···
return {
totalRequests: filtered.length,
avgDuration: Math.round(totalDuration / filtered.length),
-
p50Duration: Math.round(p50),
-
p95Duration: Math.round(p95),
-
p99Duration: Math.round(p99),
+
p50Duration: Math.round(p50 ?? 0),
+
p95Duration: Math.round(p95 ?? 0),
+
p99Duration: Math.round(p99 ?? 0),
errorRate: (errors / filtered.length) * 100,
requestsPerMinute: Math.round(filtered.length / timeWindowMinutes)
}
···
}
}
-
// Elysia middleware for request timing
+
// Hono middleware for request timing
export function observabilityMiddleware(service: string) {
-
return {
-
beforeHandle: ({ request }: any) => {
-
(request as any).__startTime = Date.now()
-
},
-
afterHandle: ({ request, set }: any) => {
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
-
const url = new URL(request.url)
+
return async (c: Context, next: () => Promise<void>) => {
+
const startTime = Date.now()
+
+
await next()
+
+
const duration = Date.now() - startTime
+
const { pathname } = new URL(c.req.url)
-
metricsCollector.recordRequest(
-
url.pathname,
-
request.method,
-
set.status || 200,
-
duration,
-
service
-
)
-
},
-
onError: ({ request, error, set }: any) => {
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
-
const url = new URL(request.url)
+
metricsCollector.recordRequest(
+
pathname,
+
c.req.method,
+
c.res.status,
+
duration,
+
service
+
)
+
}
+
}
-
metricsCollector.recordRequest(
-
url.pathname,
-
request.method,
-
set.status || 500,
-
duration,
-
service
-
)
+
// Hono error handler
+
export function observabilityErrorHandler(service: string) {
+
return (err: Error, c: Context) => {
+
const { pathname } = new URL(c.req.url)
+
+
logCollector.error(
+
`Request failed: ${c.req.method} ${pathname}`,
+
service,
+
err,
+
{ statusCode: c.res.status || 500 }
+
)
-
logCollector.error(
-
`Request failed: ${request.method} ${url.pathname}`,
-
service,
-
error,
-
{ statusCode: set.status || 500 }
-
)
-
}
+
return c.text('Internal Server Error', 500)
}
}
+8 -3
hosting-service/src/lib/safe-fetch.ts
···
const FETCH_TIMEOUT = 120000; // 120 seconds
const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
+
const MAX_JSON_SIZE = 1024 * 1024; // 1MB
+
const MAX_BLOB_SIZE = 100 * 1024 * 1024; // 100MB
+
const MAX_REDIRECTS = 10;
function isBlockedHost(hostname: string): boolean {
const lowerHost = hostname.toLowerCase();
···
const response = await fetch(url, {
...options,
signal: controller.signal,
+
redirect: 'follow',
});
const contentLength = response.headers.get('content-length');
···
url: string,
options?: RequestInit & { maxSize?: number; timeout?: number }
): Promise<T> {
-
const maxJsonSize = options?.maxSize ?? 1024 * 1024; // 1MB default for JSON
+
const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE;
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
if (!response.ok) {
···
url: string,
options?: RequestInit & { maxSize?: number; timeout?: number }
): Promise<Uint8Array> {
-
const maxBlobSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
-
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize });
+
const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE;
+
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB;
+
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+169
hosting-service/src/lib/utils.test.ts
···
+
import { describe, test, expect } from 'bun:test'
+
import { sanitizePath, extractBlobCid } from './utils'
+
import { CID } from 'multiformats'
+
+
describe('sanitizePath', () => {
+
test('allows normal file paths', () => {
+
expect(sanitizePath('index.html')).toBe('index.html')
+
expect(sanitizePath('css/styles.css')).toBe('css/styles.css')
+
expect(sanitizePath('images/logo.png')).toBe('images/logo.png')
+
expect(sanitizePath('js/app.js')).toBe('js/app.js')
+
})
+
+
test('allows deeply nested paths', () => {
+
expect(sanitizePath('assets/images/icons/favicon.ico')).toBe('assets/images/icons/favicon.ico')
+
expect(sanitizePath('a/b/c/d/e/f.txt')).toBe('a/b/c/d/e/f.txt')
+
})
+
+
test('removes leading slashes', () => {
+
expect(sanitizePath('/index.html')).toBe('index.html')
+
expect(sanitizePath('//index.html')).toBe('index.html')
+
expect(sanitizePath('///index.html')).toBe('index.html')
+
expect(sanitizePath('/css/styles.css')).toBe('css/styles.css')
+
})
+
+
test('blocks parent directory traversal', () => {
+
expect(sanitizePath('../etc/passwd')).toBe('etc/passwd')
+
expect(sanitizePath('../../etc/passwd')).toBe('etc/passwd')
+
expect(sanitizePath('../../../etc/passwd')).toBe('etc/passwd')
+
expect(sanitizePath('css/../../../etc/passwd')).toBe('css/etc/passwd')
+
})
+
+
test('blocks directory traversal in middle of path', () => {
+
expect(sanitizePath('images/../../../etc/passwd')).toBe('images/etc/passwd')
+
// Note: sanitizePath only filters out ".." segments, doesn't resolve paths
+
expect(sanitizePath('a/b/../c')).toBe('a/b/c')
+
expect(sanitizePath('a/../b/../c')).toBe('a/b/c')
+
})
+
+
test('removes current directory references', () => {
+
expect(sanitizePath('./index.html')).toBe('index.html')
+
expect(sanitizePath('././index.html')).toBe('index.html')
+
expect(sanitizePath('css/./styles.css')).toBe('css/styles.css')
+
expect(sanitizePath('./css/./styles.css')).toBe('css/styles.css')
+
})
+
+
test('removes empty path segments', () => {
+
expect(sanitizePath('css//styles.css')).toBe('css/styles.css')
+
expect(sanitizePath('css///styles.css')).toBe('css/styles.css')
+
expect(sanitizePath('a//b//c')).toBe('a/b/c')
+
})
+
+
test('blocks null bytes', () => {
+
// Null bytes cause the entire segment to be filtered out
+
expect(sanitizePath('index.html\0.txt')).toBe('')
+
expect(sanitizePath('test\0')).toBe('')
+
// Null byte in middle segment
+
expect(sanitizePath('css/bad\0name/styles.css')).toBe('css/styles.css')
+
})
+
+
test('handles mixed attacks', () => {
+
expect(sanitizePath('/../../../etc/passwd')).toBe('etc/passwd')
+
expect(sanitizePath('/./././../etc/passwd')).toBe('etc/passwd')
+
expect(sanitizePath('//../../.\0./etc/passwd')).toBe('etc/passwd')
+
})
+
+
test('handles edge cases', () => {
+
expect(sanitizePath('')).toBe('')
+
expect(sanitizePath('/')).toBe('')
+
expect(sanitizePath('//')).toBe('')
+
expect(sanitizePath('.')).toBe('')
+
expect(sanitizePath('..')).toBe('')
+
expect(sanitizePath('../..')).toBe('')
+
})
+
+
test('preserves valid special characters in filenames', () => {
+
expect(sanitizePath('file-name.html')).toBe('file-name.html')
+
expect(sanitizePath('file_name.html')).toBe('file_name.html')
+
expect(sanitizePath('file.name.html')).toBe('file.name.html')
+
expect(sanitizePath('file (1).html')).toBe('file (1).html')
+
expect(sanitizePath('file@2x.png')).toBe('file@2x.png')
+
})
+
+
test('handles Unicode characters', () => {
+
expect(sanitizePath('ๆ–‡ไปถ.html')).toBe('ๆ–‡ไปถ.html')
+
expect(sanitizePath('ั„ะฐะนะป.html')).toBe('ั„ะฐะนะป.html')
+
expect(sanitizePath('ใƒ•ใ‚กใ‚คใƒซ.html')).toBe('ใƒ•ใ‚กใ‚คใƒซ.html')
+
})
+
})
+
+
describe('extractBlobCid', () => {
+
const TEST_CID = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
+
+
test('extracts CID from IPLD link', () => {
+
const blobRef = { $link: TEST_CID }
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
+
})
+
+
test('extracts CID from typed BlobRef with CID object', () => {
+
const cid = CID.parse(TEST_CID)
+
const blobRef = { ref: cid }
+
const result = extractBlobCid(blobRef)
+
expect(result).toBe(TEST_CID)
+
})
+
+
test('extracts CID from typed BlobRef with IPLD link', () => {
+
const blobRef = {
+
ref: { $link: TEST_CID }
+
}
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
+
})
+
+
test('extracts CID from untyped BlobRef', () => {
+
const blobRef = { cid: TEST_CID }
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
+
})
+
+
test('returns null for invalid blob ref', () => {
+
expect(extractBlobCid(null)).toBe(null)
+
expect(extractBlobCid(undefined)).toBe(null)
+
expect(extractBlobCid({})).toBe(null)
+
expect(extractBlobCid('not-an-object')).toBe(null)
+
expect(extractBlobCid(123)).toBe(null)
+
})
+
+
test('returns null for malformed objects', () => {
+
expect(extractBlobCid({ wrongKey: 'value' })).toBe(null)
+
expect(extractBlobCid({ ref: 'not-a-cid' })).toBe(null)
+
expect(extractBlobCid({ ref: {} })).toBe(null)
+
})
+
+
test('handles nested structures from AT Proto API', () => {
+
// Real structure from AT Proto
+
const blobRef = {
+
$type: 'blob',
+
ref: CID.parse(TEST_CID),
+
mimeType: 'text/html',
+
size: 1234
+
}
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
+
})
+
+
test('handles BlobRef with additional properties', () => {
+
const blobRef = {
+
ref: { $link: TEST_CID },
+
mimeType: 'image/png',
+
size: 5678,
+
someOtherField: 'value'
+
}
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
+
})
+
+
test('prioritizes checking IPLD link first', () => {
+
// Direct $link takes precedence
+
const directLink = { $link: TEST_CID }
+
expect(extractBlobCid(directLink)).toBe(TEST_CID)
+
})
+
+
test('handles CID v0 format', () => {
+
const cidV0 = 'QmZ4tDuvesekSs4qM5ZBKpXiZGun7S2CYtEZRB3DYXkjGx'
+
const blobRef = { $link: cidV0 }
+
expect(extractBlobCid(blobRef)).toBe(cidV0)
+
})
+
+
test('handles CID v1 format', () => {
+
const cidV1 = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'
+
const blobRef = { $link: cidV1 }
+
expect(extractBlobCid(blobRef)).toBe(cidV1)
+
})
+
})
+85 -8
hosting-service/src/lib/utils.ts
···
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
import { writeFile, readFile, rename } from 'fs/promises';
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
-
import { CID } from 'multiformats/cid';
+
import { CID } from 'multiformats';
-
const CACHE_DIR = './cache/sites';
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
interface CacheMetadata {
···
cachedAt: number;
did: string;
rkey: string;
+
}
+
+
/**
+
* Determines if a MIME type should benefit from gzip compression.
+
* Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG).
+
* Returns false for already-compressed formats (images, video, audio, PDFs).
+
*
+
*/
+
export function shouldCompressMimeType(mimeType: string | undefined): boolean {
+
if (!mimeType) return false;
+
+
const mime = mimeType.toLowerCase();
+
+
// Text-based web assets that benefit from compression
+
const compressibleTypes = [
+
'text/html',
+
'text/css',
+
'text/javascript',
+
'application/javascript',
+
'application/x-javascript',
+
'text/xml',
+
'application/xml',
+
'application/json',
+
'text/plain',
+
'image/svg+xml',
+
];
+
+
if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) {
+
return true;
+
}
+
+
// Already-compressed formats that should NOT be double-compressed
+
const alreadyCompressedPrefixes = [
+
'video/',
+
'audio/',
+
'image/',
+
'application/pdf',
+
'application/zip',
+
'application/gzip',
+
];
+
+
if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) {
+
return false;
+
}
+
+
// Default to not compressing for unknown types
+
return false;
}
interface IpldLink {
···
// Allow up to 100MB per file blob, with 2 minute timeout
let content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024, timeout: 120000 });
-
// If content is base64-encoded, decode it back to gzipped binary
-
if (base64 && encoding === 'gzip') {
-
// Convert Uint8Array to Buffer for proper string conversion
-
const buffer = Buffer.from(content);
-
const base64String = buffer.toString('utf-8');
+
console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`);
+
+
// If content is base64-encoded, decode it back to raw binary (gzipped or not)
+
if (base64) {
+
const originalSize = content.length;
+
// Decode base64 directly from raw bytes - no string conversion
+
// The blob contains base64-encoded text as raw bytes, decode it in-place
+
const textDecoder = new TextDecoder();
+
const base64String = textDecoder.decode(content);
content = Buffer.from(base64String, 'base64');
+
console.log(`[DEBUG] ${filePath}: decoded base64 from ${originalSize} bytes to ${content.length} bytes`);
+
+
// Check if it's actually gzipped by looking at magic bytes
+
if (content.length >= 2) {
+
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
+
console.log(`[DEBUG] ${filePath}: has gzip magic bytes: ${hasGzipMagic}`);
+
}
}
const cacheFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`;
···
mkdirSync(fileDir, { recursive: true });
}
+
// Use the shared function to determine if this should remain compressed
+
const shouldStayCompressed = shouldCompressMimeType(mimeType);
+
+
// Decompress files that shouldn't be stored compressed
+
if (encoding === 'gzip' && !shouldStayCompressed && content.length >= 2 &&
+
content[0] === 0x1f && content[1] === 0x8b) {
+
console.log(`[DEBUG] ${filePath}: decompressing non-compressible type (${mimeType}) before caching`);
+
try {
+
const { gunzipSync } = await import('zlib');
+
const decompressed = gunzipSync(content);
+
console.log(`[DEBUG] ${filePath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
+
content = decompressed;
+
// Clear the encoding flag since we're storing decompressed
+
encoding = undefined;
+
} catch (error) {
+
console.log(`[DEBUG] ${filePath}: failed to decompress, storing original gzipped content. Error:`, error);
+
}
+
}
+
await writeFile(cacheFile, content);
-
// Store metadata if file is compressed
+
// Store metadata only if file is still compressed
if (encoding === 'gzip' && mimeType) {
const metaFile = `${cacheFile}.meta`;
await writeFile(metaFile, JSON.stringify({ encoding, mimeType }));
+201 -151
hosting-service/src/server.ts
···
-
import { Elysia } from 'elysia';
-
import { node } from '@elysiajs/node'
-
import { opentelemetry } from '@elysiajs/opentelemetry';
+
import { Hono } from 'hono';
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
-
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils';
+
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils';
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
import { existsSync, readFileSync } from 'fs';
import { lookup } from 'mime-types';
-
import { logger, observabilityMiddleware, logCollector, errorTracker, metricsCollector } from './lib/observability';
+
import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability';
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
···
const content = readFileSync(cachedFile);
const metaFile = `${cachedFile}.meta`;
+
console.log(`[DEBUG SERVE] ${requestPath}: file size=${content.length} bytes, metaFile exists=${existsSync(metaFile)}`);
+
// Check if file has compression metadata
if (existsSync(metaFile)) {
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
+
console.log(`[DEBUG SERVE] ${requestPath}: meta=${JSON.stringify(meta)}`);
+
+
// Check actual content for gzip magic bytes
+
if (content.length >= 2) {
+
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
+
console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic}`);
+
}
+
if (meta.encoding === 'gzip' && meta.mimeType) {
-
// Serve gzipped content with proper headers
+
// Use shared function to determine if this should be served compressed
+
const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
+
+
if (!shouldServeCompressed) {
+
// This shouldn't happen if caching is working correctly, but handle it gracefully
+
console.log(`[DEBUG SERVE] ${requestPath}: decompressing file that shouldn't be compressed (${meta.mimeType})`);
+
const { gunzipSync } = await import('zlib');
+
const decompressed = gunzipSync(content);
+
console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
+
return new Response(decompressed, {
+
headers: {
+
'Content-Type': meta.mimeType,
+
},
+
});
+
}
+
+
// Serve gzipped content with proper headers (for HTML, CSS, JS, etc.)
+
console.log(`[DEBUG SERVE] ${requestPath}: serving as gzipped with Content-Encoding header`);
return new Response(content, {
headers: {
'Content-Type': meta.mimeType,
···
}
// Check if this is HTML content that needs rewriting
-
// Note: For gzipped HTML with path rewriting, we need to decompress, rewrite, and serve uncompressed
-
// This is a trade-off for the sites.wisp.place domain which needs path rewriting
+
// We decompress, rewrite paths, then recompress for efficient delivery
if (isHtmlContent(requestPath, mimeType)) {
let content: string;
if (isGzipped) {
···
} else {
content = readFileSync(cachedFile, 'utf-8');
}
-
const rewritten = rewriteHtmlPaths(content, basePath);
-
return new Response(rewritten, {
+
const rewritten = rewriteHtmlPaths(content, basePath, requestPath);
+
+
// Recompress the HTML for efficient delivery
+
const { gzipSync } = await import('zlib');
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
+
+
return new Response(recompressed, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
},
});
}
···
// Non-HTML files: serve gzipped content as-is with proper headers
const content = readFileSync(cachedFile);
if (isGzipped) {
+
// Use shared function to determine if this should be served compressed
+
const shouldServeCompressed = shouldCompressMimeType(mimeType);
+
+
if (!shouldServeCompressed) {
+
// This shouldn't happen if caching is working correctly, but handle it gracefully
+
const { gunzipSync } = await import('zlib');
+
const decompressed = gunzipSync(content);
+
return new Response(decompressed, {
+
headers: {
+
'Content-Type': mimeType,
+
},
+
});
+
}
+
return new Response(content, {
headers: {
'Content-Type': mimeType,
···
}
}
-
// HTML needs path rewriting, so decompress if needed
+
// HTML needs path rewriting, decompress, rewrite, then recompress
let content: string;
if (isGzipped) {
const { gunzipSync } = await import('zlib');
···
} else {
content = readFileSync(indexFile, 'utf-8');
}
-
const rewritten = rewriteHtmlPaths(content, basePath);
-
return new Response(rewritten, {
+
const indexPath = `${requestPath}/index.html`;
+
const rewritten = rewriteHtmlPaths(content, basePath, indexPath);
+
+
// Recompress the HTML for efficient delivery
+
const { gzipSync } = await import('zlib');
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
+
+
return new Response(recompressed, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
},
});
}
···
}
}
-
const app = new Elysia({ adapter: node() })
-
.use(opentelemetry())
-
.onBeforeHandle(observabilityMiddleware('hosting-service').beforeHandle)
-
.onAfterHandle(observabilityMiddleware('hosting-service').afterHandle)
-
.onError(observabilityMiddleware('hosting-service').onError)
-
.get('/*', async ({ request, set }) => {
-
const url = new URL(request.url);
-
const hostname = request.headers.get('host') || '';
-
const rawPath = url.pathname.replace(/^\//, '');
-
const path = sanitizePath(rawPath);
-
-
// Check if this is sites.wisp.place subdomain
-
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
-
// Sanitize the path FIRST to prevent path traversal
-
const sanitizedFullPath = sanitizePath(rawPath);
-
-
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
-
const pathParts = sanitizedFullPath.split('/');
-
if (pathParts.length < 2) {
-
set.status = 400;
-
return 'Invalid path format. Expected: /identifier/sitename/path';
-
}
-
-
const identifier = pathParts[0];
-
const site = pathParts[1];
-
const filePath = pathParts.slice(2).join('/');
+
const app = new Hono();
-
// Additional validation: identifier must be a valid DID or handle format
-
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
-
set.status = 400;
-
return 'Invalid identifier';
-
}
+
// Add observability middleware
+
app.use('*', observabilityMiddleware('hosting-service'));
-
// Validate site name (rkey)
-
if (!isValidRkey(site)) {
-
set.status = 400;
-
return 'Invalid site name';
-
}
+
// Error handler
+
app.onError(observabilityErrorHandler('hosting-service'));
-
// Resolve identifier to DID
-
const did = await resolveDid(identifier);
-
if (!did) {
-
set.status = 400;
-
return 'Invalid identifier';
-
}
+
// Main site serving route
+
app.get('/*', async (c) => {
+
const url = new URL(c.req.url);
+
const hostname = c.req.header('host') || '';
+
const rawPath = url.pathname.replace(/^\//, '');
+
const path = sanitizePath(rawPath);
-
// Ensure site is cached
-
const cached = await ensureSiteCached(did, site);
-
if (!cached) {
-
set.status = 404;
-
return 'Site not found';
-
}
+
// Check if this is sites.wisp.place subdomain
+
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
+
// Sanitize the path FIRST to prevent path traversal
+
const sanitizedFullPath = sanitizePath(rawPath);
-
// Serve with HTML path rewriting to handle absolute paths
-
const basePath = `/${identifier}/${site}/`;
-
return serveFromCacheWithRewrite(did, site, filePath, basePath);
+
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
+
const pathParts = sanitizedFullPath.split('/');
+
if (pathParts.length < 2) {
+
return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
}
-
// Check if this is a DNS hash subdomain
-
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
-
if (dnsMatch) {
-
const hash = dnsMatch[1];
-
const baseDomain = dnsMatch[2];
-
-
if (baseDomain !== BASE_HOST) {
-
set.status = 400;
-
return 'Invalid base domain';
-
}
-
-
const customDomain = await getCustomDomainByHash(hash);
-
if (!customDomain) {
-
set.status = 404;
-
return 'Custom domain not found or not verified';
-
}
-
-
if (!customDomain.rkey) {
-
set.status = 404;
-
return 'Domain not mapped to a site';
-
}
+
const identifier = pathParts[0];
+
const site = pathParts[1];
+
const filePath = pathParts.slice(2).join('/');
-
const rkey = customDomain.rkey;
-
if (!isValidRkey(rkey)) {
-
set.status = 500;
-
return 'Invalid site configuration';
-
}
+
// Additional validation: identifier must be a valid DID or handle format
+
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
+
return c.text('Invalid identifier', 400);
+
}
-
const cached = await ensureSiteCached(customDomain.did, rkey);
-
if (!cached) {
-
set.status = 404;
-
return 'Site not found';
-
}
+
// Validate site parameter exists
+
if (!site) {
+
return c.text('Site name required', 400);
+
}
-
return serveFromCache(customDomain.did, rkey, path);
+
// Validate site name (rkey)
+
if (!isValidRkey(site)) {
+
return c.text('Invalid site name', 400);
}
-
// Route 2: Registered subdomains - /*.wisp.place/*
-
if (hostname.endsWith(`.${BASE_HOST}`)) {
-
const subdomain = hostname.replace(`.${BASE_HOST}`, '');
+
// Resolve identifier to DID
+
const did = await resolveDid(identifier);
+
if (!did) {
+
return c.text('Invalid identifier', 400);
+
}
-
const domainInfo = await getWispDomain(hostname);
-
if (!domainInfo) {
-
set.status = 404;
-
return 'Subdomain not registered';
-
}
+
// Ensure site is cached
+
const cached = await ensureSiteCached(did, site);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
-
if (!domainInfo.rkey) {
-
set.status = 404;
-
return 'Domain not mapped to a site';
-
}
+
// Serve with HTML path rewriting to handle absolute paths
+
const basePath = `/${identifier}/${site}/`;
+
return serveFromCacheWithRewrite(did, site, filePath, basePath);
+
}
-
const rkey = domainInfo.rkey;
-
if (!isValidRkey(rkey)) {
-
set.status = 500;
-
return 'Invalid site configuration';
-
}
+
// Check if this is a DNS hash subdomain
+
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
+
if (dnsMatch) {
+
const hash = dnsMatch[1];
+
const baseDomain = dnsMatch[2];
-
const cached = await ensureSiteCached(domainInfo.did, rkey);
-
if (!cached) {
-
set.status = 404;
-
return 'Site not found';
-
}
+
if (!hash) {
+
return c.text('Invalid DNS hash', 400);
+
}
-
return serveFromCache(domainInfo.did, rkey, path);
+
if (baseDomain !== BASE_HOST) {
+
return c.text('Invalid base domain', 400);
}
-
// Route 1: Custom domains - /*
-
const customDomain = await getCustomDomain(hostname);
+
const customDomain = await getCustomDomainByHash(hash);
if (!customDomain) {
-
set.status = 404;
-
return 'Custom domain not found or not verified';
+
return c.text('Custom domain not found or not verified', 404);
}
if (!customDomain.rkey) {
-
set.status = 404;
-
return 'Domain not mapped to a site';
+
return c.text('Domain not mapped to a site', 404);
}
const rkey = customDomain.rkey;
if (!isValidRkey(rkey)) {
-
set.status = 500;
-
return 'Invalid site configuration';
+
return c.text('Invalid site configuration', 500);
}
const cached = await ensureSiteCached(customDomain.did, rkey);
if (!cached) {
-
set.status = 404;
-
return 'Site not found';
+
return c.text('Site not found', 404);
}
return serveFromCache(customDomain.did, rkey, path);
-
})
-
// Internal observability endpoints (for admin panel)
-
.get('/__internal__/observability/logs', ({ query }) => {
-
const filter: any = {};
-
if (query.level) filter.level = query.level;
-
if (query.service) filter.service = query.service;
-
if (query.search) filter.search = query.search;
-
if (query.eventType) filter.eventType = query.eventType;
-
if (query.limit) filter.limit = parseInt(query.limit as string);
-
return { logs: logCollector.getLogs(filter) };
-
})
-
.get('/__internal__/observability/errors', ({ query }) => {
-
const filter: any = {};
-
if (query.service) filter.service = query.service;
-
if (query.limit) filter.limit = parseInt(query.limit as string);
-
return { errors: errorTracker.getErrors(filter) };
-
})
-
.get('/__internal__/observability/metrics', ({ query }) => {
-
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
-
const stats = metricsCollector.getStats('hosting-service', timeWindow);
-
return { stats, timeWindow };
-
});
+
}
+
+
// Route 2: Registered subdomains - /*.wisp.place/*
+
if (hostname.endsWith(`.${BASE_HOST}`)) {
+
const domainInfo = await getWispDomain(hostname);
+
if (!domainInfo) {
+
return c.text('Subdomain not registered', 404);
+
}
+
+
if (!domainInfo.rkey) {
+
return c.text('Domain not mapped to a site', 404);
+
}
+
+
const rkey = domainInfo.rkey;
+
if (!isValidRkey(rkey)) {
+
return c.text('Invalid site configuration', 500);
+
}
+
+
const cached = await ensureSiteCached(domainInfo.did, rkey);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
return serveFromCache(domainInfo.did, rkey, path);
+
}
+
+
// Route 1: Custom domains - /*
+
const customDomain = await getCustomDomain(hostname);
+
if (!customDomain) {
+
return c.text('Custom domain not found or not verified', 404);
+
}
+
+
if (!customDomain.rkey) {
+
return c.text('Domain not mapped to a site', 404);
+
}
+
+
const rkey = customDomain.rkey;
+
if (!isValidRkey(rkey)) {
+
return c.text('Invalid site configuration', 500);
+
}
+
+
const cached = await ensureSiteCached(customDomain.did, rkey);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
return serveFromCache(customDomain.did, rkey, path);
+
});
+
+
// Internal observability endpoints (for admin panel)
+
app.get('/__internal__/observability/logs', (c) => {
+
const query = c.req.query();
+
const filter: any = {};
+
if (query.level) filter.level = query.level;
+
if (query.service) filter.service = query.service;
+
if (query.search) filter.search = query.search;
+
if (query.eventType) filter.eventType = query.eventType;
+
if (query.limit) filter.limit = parseInt(query.limit as string);
+
return c.json({ logs: logCollector.getLogs(filter) });
+
});
+
+
app.get('/__internal__/observability/errors', (c) => {
+
const query = c.req.query();
+
const filter: any = {};
+
if (query.service) filter.service = query.service;
+
if (query.limit) filter.limit = parseInt(query.limit as string);
+
return c.json({ errors: errorTracker.getErrors(filter) });
+
});
+
+
app.get('/__internal__/observability/metrics', (c) => {
+
const query = c.req.query();
+
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
+
const stats = metricsCollector.getStats('hosting-service', timeWindow);
+
return c.json({ stats, timeWindow });
+
});
export default app;
+28
hosting-service/tsconfig.json
···
+
{
+
"compilerOptions": {
+
/* Base Options */
+
"esModuleInterop": true,
+
"skipLibCheck": true,
+
"target": "es2022",
+
"allowJs": true,
+
"resolveJsonModule": true,
+
"moduleDetection": "force",
+
"isolatedModules": true,
+
"verbatimModuleSyntax": true,
+
+
/* Strictness */
+
"strict": true,
+
"noUncheckedIndexedAccess": true,
+
"noImplicitOverride": true,
+
"forceConsistentCasingInFileNames": true,
+
+
/* Transpiling with TypeScript */
+
"module": "ESNext",
+
"moduleResolution": "bundler",
+
"outDir": "dist",
+
"sourceMap": true,
+
+
/* Code doesn't run in DOM */
+
"lib": ["es2022"],
+
}
+
}
+2 -1
package.json
···
"name": "elysia-static",
"version": "1.0.50",
"scripts": {
-
"test": "echo \"Error: no test specified\" && exit 1",
+
"test": "bun test",
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"build": "bun build --compile --target bun --outfile server src/index.ts"
···
"lucide-react": "^0.546.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+
"react-shiki": "^0.9.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "4",
"tw-animate-css": "^1.4.0",
+23
public/components/ui/code-block.tsx
···
+
import ShikiHighlighter from 'react-shiki'
+
+
interface CodeBlockProps {
+
code: string
+
language?: string
+
className?: string
+
}
+
+
export function CodeBlock({ code, language = 'bash', className = '' }: CodeBlockProps) {
+
return (
+
<ShikiHighlighter
+
language={language}
+
theme={{
+
light: 'catppuccin-latte',
+
dark: 'catppuccin-mocha',
+
}}
+
defaultColor="light-dark()"
+
className={className}
+
>
+
{code.trim()}
+
</ShikiHighlighter>
+
)
+
}
+1 -1
public/components/ui/radio-group.tsx
···
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
-
"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",
+
"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",
className
)}
{...props}
+2 -2
public/components/ui/tabs.tsx
···
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
-
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
+
"bg-muted dark:bg-muted/80 text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
···
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
-
"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",
+
"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",
className
)}
{...props}
+268 -19
public/editor/editor.tsx
···
Settings
} from 'lucide-react'
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
+
import { CodeBlock } from '@public/components/ui/code-block'
import Layout from '@public/layouts'
···
</div>
<Tabs defaultValue="sites" className="space-y-6 w-full">
-
<TabsList className="grid w-full grid-cols-3 max-w-md">
+
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="sites">Sites</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="upload">Upload</TabsTrigger>
+
<TabsTrigger value="cli">CLI</TabsTrigger>
</TabsList>
{/* Sites Tab */}
···
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
-
<RadioGroup
-
value={siteMode}
-
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
-
disabled={isUploading}
-
>
-
<div className="flex items-center space-x-2">
-
<RadioGroupItem value="existing" id="existing" />
-
<Label htmlFor="existing" className="cursor-pointer">
-
Update existing site
-
</Label>
-
</div>
-
<div className="flex items-center space-x-2">
-
<RadioGroupItem value="new" id="new" />
-
<Label htmlFor="new" className="cursor-pointer">
-
Create new site
-
</Label>
-
</div>
-
</RadioGroup>
+
<div className="p-4 bg-muted/50 rounded-lg">
+
<RadioGroup
+
value={siteMode}
+
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
+
disabled={isUploading}
+
>
+
<div className="flex items-center space-x-2">
+
<RadioGroupItem value="existing" id="existing" />
+
<Label htmlFor="existing" className="cursor-pointer">
+
Update existing site
+
</Label>
+
</div>
+
<div className="flex items-center space-x-2">
+
<RadioGroupItem value="new" id="new" />
+
<Label htmlFor="new" className="cursor-pointer">
+
Create new site
+
</Label>
+
</div>
+
</RadioGroup>
+
</div>
{siteMode === 'existing' ? (
<div className="space-y-2">
···
</>
)}
</Button>
+
</CardContent>
+
</Card>
+
</TabsContent>
+
+
{/* CLI Tab */}
+
<TabsContent value="cli" className="space-y-4 min-h-[400px]">
+
<Card>
+
<CardHeader>
+
<div className="flex items-center gap-2 mb-2">
+
<CardTitle>Wisp CLI Tool</CardTitle>
+
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
+
<Badge variant="outline" className="text-xs">Alpha</Badge>
+
</div>
+
<CardDescription>
+
Deploy static sites directly from your terminal
+
</CardDescription>
+
</CardHeader>
+
<CardContent className="space-y-6">
+
<div className="prose prose-sm max-w-none dark:prose-invert">
+
<p className="text-sm text-muted-foreground">
+
The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account.
+
Authenticate with app password or OAuth and deploy from CI/CD pipelines.
+
</p>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">Download CLI</h3>
+
<div className="grid gap-2">
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
+
<a
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between mb-2"
+
>
+
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<div className="text-xs text-muted-foreground">
+
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
+
</div>
+
</div>
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
+
<a
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between mb-2"
+
>
+
<span className="font-mono text-sm">Linux (ARM64)</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<div className="text-xs text-muted-foreground">
+
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
+
</div>
+
</div>
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
+
<a
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between mb-2"
+
>
+
<span className="font-mono text-sm">Linux (x86_64)</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<div className="text-xs text-muted-foreground">
+
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
+
</div>
+
</div>
+
</div>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">Basic Usage</h3>
+
<CodeBlock
+
code={`# Download and make executable
+
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
+
chmod +x wisp-cli-macos-arm64
+
+
# Deploy your site (will use OAuth)
+
./wisp-cli-macos-arm64 your-handle.bsky.social \\
+
--path ./dist \\
+
--site my-site
+
+
# Your site will be available at:
+
# https://sites.wisp.place/your-handle/my-site`}
+
language="bash"
+
/>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
+
<p className="text-xs text-muted-foreground">
+
Deploy automatically on every push using{' '}
+
<a
+
href="https://blog.tangled.org/ci"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-accent hover:underline"
+
>
+
Tangled Spindle
+
</a>
+
</p>
+
+
<div className="space-y-4">
+
<div>
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
+
<span>Example 1: Simple Asset Publishing</span>
+
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
+
</h4>
+
<CodeBlock
+
code={`when:
+
- event: ['push']
+
branch: ['main']
+
- event: ['manual']
+
+
engine: 'nixery'
+
+
clone:
+
skip: false
+
depth: 1
+
+
dependencies:
+
nixpkgs:
+
- coreutils
+
- curl
+
+
environment:
+
SITE_PATH: '.' # Copy entire repo
+
SITE_NAME: 'myWebbedSite'
+
WISP_HANDLE: 'your-handle.bsky.social'
+
+
steps:
+
- name: deploy assets to wisp
+
command: |
+
# Download Wisp CLI
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
+
chmod +x wisp-cli
+
+
# Deploy to Wisp
+
./wisp-cli \\
+
"$WISP_HANDLE" \\
+
--path "$SITE_PATH" \\
+
--site "$SITE_NAME" \\
+
--password "$WISP_APP_PASSWORD"
+
+
# Output
+
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
+
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
+
`}
+
language="yaml"
+
/>
+
</div>
+
+
<div>
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
+
<span>Example 2: React/Vite Build & Deploy</span>
+
<Badge variant="secondary" className="text-xs">Full Build</Badge>
+
</h4>
+
<CodeBlock
+
code={`when:
+
- event: ['push']
+
branch: ['main']
+
- event: ['manual']
+
+
engine: 'nixery'
+
+
clone:
+
skip: false
+
depth: 1
+
submodules: false
+
+
dependencies:
+
nixpkgs:
+
- nodejs
+
- coreutils
+
- curl
+
github:NixOS/nixpkgs/nixpkgs-unstable:
+
- bun
+
+
environment:
+
SITE_PATH: 'dist'
+
SITE_NAME: 'my-react-site'
+
WISP_HANDLE: 'your-handle.bsky.social'
+
+
steps:
+
- name: build site
+
command: |
+
# necessary to ensure bun is in PATH
+
export PATH="$HOME/.nix-profile/bin:$PATH"
+
+
bun install --frozen-lockfile
+
+
# build with vite, run directly to get around env issues
+
bun node_modules/.bin/vite build
+
+
- name: deploy to wisp
+
command: |
+
# Download Wisp CLI
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
+
chmod +x wisp-cli
+
+
# Deploy to Wisp
+
./wisp-cli \\
+
"$WISP_HANDLE" \\
+
--path "$SITE_PATH" \\
+
--site "$SITE_NAME" \\
+
--password "$WISP_APP_PASSWORD"`}
+
language="yaml"
+
/>
+
</div>
+
</div>
+
+
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
+
<p className="text-xs text-muted-foreground">
+
<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.
+
Generate an app password from your AT Protocol account settings.
+
</p>
+
</div>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">Learn More</h3>
+
<div className="grid gap-2">
+
<a
+
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
+
>
+
<span className="text-sm">Source Code</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<a
+
href="https://blog.tangled.org/ci"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
+
>
+
<span className="text-sm">Tangled Spindle CI/CD</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
</div>
+
</div>
</CardContent>
</Card>
</TabsContent>
+1
public/editor/index.html
···
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elysia Static</title>
+
<link rel="icon" type="image/x-icon" href="../favicon.ico">
</head>
<body>
<div id="elysia"></div>
public/favicon.ico

This is a binary file and will not be displayed.

+14
public/favicon.svg
···
+
<!--?xml version="1.0" encoding="utf-8"?-->
+
<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">
+
<!-- black background -->
+
<rect width="64" height="64" fill="#000000"></rect>
+
+
<!-- outer faint glow -->
+
<circle cx="32" cy="32" r="14" fill="none" stroke="#7CF5D8" stroke-opacity="0.35" stroke-width="2.6"></circle>
+
+
<!-- bright halo -->
+
<circle cx="32" cy="32" r="10" fill="none" stroke="#CFF8EE" stroke-opacity="0.95" stroke-width="2.4"></circle>
+
+
<!-- bright core -->
+
<circle cx="32" cy="32" r="4" fill="#FFFFFF"></circle>
+
</svg>
+1
public/index.html
···
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elysia Static</title>
+
<link rel="icon" type="image/x-icon" href="./favicon.ico">
</head>
<body>
<div id="elysia"></div>
+24
public/layouts/index.tsx
···
import type { PropsWithChildren } from 'react'
+
import { useEffect } from 'react'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import clsx from 'clsx'
···
}
export default function Layout({ children, className }: LayoutProps) {
+
useEffect(() => {
+
// Function to update dark mode based on system preference
+
const updateDarkMode = (e: MediaQueryList | MediaQueryListEvent) => {
+
if (e.matches) {
+
document.documentElement.classList.add('dark')
+
} else {
+
document.documentElement.classList.remove('dark')
+
}
+
}
+
+
// Create media query
+
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
+
+
// Set initial value
+
updateDarkMode(darkModeQuery)
+
+
// Listen for changes
+
darkModeQuery.addEventListener('change', updateDarkMode)
+
+
// Cleanup
+
return () => darkModeQuery.removeEventListener('change', updateDarkMode)
+
}, [])
+
return (
<QueryClientProvider client={client}>
<div
+62 -36
public/styles/global.css
···
@import "tailwindcss";
@import "tw-animate-css";
-
@custom-variant dark (&:is(.dark *));
+
@custom-variant dark (@media (prefers-color-scheme: dark));
:root {
+
color-scheme: light;
+
/* Warm beige background inspired by Sunset design #E9DDD8 */
--background: oklch(0.90 0.012 35);
/* Very dark brown text for strong contrast #2A2420 */
···
}
.dark {
-
/* #413C58 - violet background for dark mode */
-
--background: oklch(0.28 0.04 285);
-
/* #F2E7C9 - parchment text */
-
--foreground: oklch(0.93 0.03 85);
+
color-scheme: dark;
-
--card: oklch(0.32 0.04 285);
-
--card-foreground: oklch(0.93 0.03 85);
+
/* Slate violet background - #2C2C2C with violet tint */
+
--background: oklch(0.23 0.015 285);
+
/* Light gray text - #E4E4E4 */
+
--foreground: oklch(0.90 0.005 285);
-
--popover: oklch(0.32 0.04 285);
-
--popover-foreground: oklch(0.93 0.03 85);
+
/* Slightly lighter slate for cards */
+
--card: oklch(0.28 0.015 285);
+
--card-foreground: oklch(0.90 0.005 285);
-
/* #FFAAD2 - pink primary in dark mode */
-
--primary: oklch(0.78 0.15 345);
-
--primary-foreground: oklch(0.32 0.04 285);
+
--popover: oklch(0.28 0.015 285);
+
--popover-foreground: oklch(0.90 0.005 285);
-
--accent: oklch(0.78 0.15 345);
-
--accent-foreground: oklch(0.32 0.04 285);
+
/* Lavender buttons - #B39CD0 */
+
--primary: oklch(0.70 0.10 295);
+
--primary-foreground: oklch(0.23 0.015 285);
-
--secondary: oklch(0.56 0.08 220);
-
--secondary-foreground: oklch(0.93 0.03 85);
+
/* Soft pink accent - #FFC1CC */
+
--accent: oklch(0.85 0.08 5);
+
--accent-foreground: oklch(0.23 0.015 285);
-
--muted: oklch(0.38 0.03 285);
-
--muted-foreground: oklch(0.75 0.02 85);
+
/* Light cyan secondary - #A8DADC */
+
--secondary: oklch(0.82 0.05 200);
+
--secondary-foreground: oklch(0.23 0.015 285);
-
--border: oklch(0.42 0.03 285);
-
--input: oklch(0.42 0.03 285);
-
--ring: oklch(0.78 0.15 345);
+
/* Muted slate areas */
+
--muted: oklch(0.33 0.015 285);
+
--muted-foreground: oklch(0.72 0.01 285);
-
--destructive: oklch(0.577 0.245 27.325);
-
--destructive-foreground: oklch(0.985 0 0);
+
/* Subtle borders */
+
--border: oklch(0.38 0.02 285);
+
--input: oklch(0.30 0.015 285);
+
--ring: oklch(0.70 0.10 295);
-
--chart-1: oklch(0.78 0.15 345);
-
--chart-2: oklch(0.93 0.03 85);
-
--chart-3: oklch(0.56 0.08 220);
-
--chart-4: oklch(0.85 0.02 130);
-
--chart-5: oklch(0.32 0.04 285);
-
--sidebar: oklch(0.205 0 0);
-
--sidebar-foreground: oklch(0.985 0 0);
-
--sidebar-primary: oklch(0.488 0.243 264.376);
-
--sidebar-primary-foreground: oklch(0.985 0 0);
-
--sidebar-accent: oklch(0.269 0 0);
-
--sidebar-accent-foreground: oklch(0.985 0 0);
-
--sidebar-border: oklch(0.269 0 0);
-
--sidebar-ring: oklch(0.439 0 0);
+
/* Warm destructive color */
+
--destructive: oklch(0.60 0.22 27);
+
--destructive-foreground: oklch(0.98 0.01 85);
+
+
/* Chart colors using the accent palette */
+
--chart-1: oklch(0.85 0.08 5);
+
--chart-2: oklch(0.82 0.05 200);
+
--chart-3: oklch(0.70 0.10 295);
+
--chart-4: oklch(0.75 0.08 340);
+
--chart-5: oklch(0.65 0.08 180);
+
+
/* Sidebar slate */
+
--sidebar: oklch(0.20 0.015 285);
+
--sidebar-foreground: oklch(0.90 0.005 285);
+
--sidebar-primary: oklch(0.70 0.10 295);
+
--sidebar-primary-foreground: oklch(0.20 0.015 285);
+
--sidebar-accent: oklch(0.28 0.015 285);
+
--sidebar-accent-foreground: oklch(0.90 0.005 285);
+
--sidebar-border: oklch(0.32 0.02 285);
+
--sidebar-ring: oklch(0.70 0.10 295);
}
@theme inline {
···
.arrow-animate {
animation: arrow-bounce 1.5s ease-in-out infinite;
}
+
+
/* Shiki syntax highlighting styles */
+
.shiki-wrapper {
+
border-radius: 0.5rem;
+
padding: 1rem;
+
overflow-x: auto;
+
border: 1px solid hsl(var(--border));
+
}
+
+
.shiki-wrapper pre {
+
margin: 0 !important;
+
padding: 0 !important;
+
}
+16 -8
src/index.ts
···
import { Elysia } from 'elysia'
+
import type { Context } from 'elysia'
import { cors } from '@elysiajs/cors'
-
import { openapi, fromTypes } from '@elysiajs/openapi'
import { staticPlugin } from '@elysiajs/static'
import type { Config } from './lib/types'
···
dnsVerifier.start()
logger.info('DNS Verifier Started - checking custom domains every 10 minutes')
-
export const app = new Elysia()
-
.use(openapi({
-
references: fromTypes()
-
}))
+
export const app = new Elysia({
+
serve: {
+
maxPayloadLength: 1024 * 1024 * 128 * 3,
+
development: Bun.env.NODE_ENV !== 'production' ? true : false,
+
id: Bun.env.NODE_ENV !== 'production' ? undefined : null,
+
}
+
})
// Observability middleware
.onBeforeHandle(observabilityMiddleware('main-app').beforeHandle)
-
.onAfterHandle((ctx) => {
+
.onAfterHandle((ctx: Context) => {
observabilityMiddleware('main-app').afterHandle(ctx)
// Security headers middleware
const { set } = ctx
···
prefix: '/'
})
)
-
.get('/client-metadata.json', (c) => {
+
.get('/client-metadata.json', () => {
return createClientMetadata(config)
})
-
.get('/jwks.json', async (c) => {
+
.get('/jwks.json', async () => {
const keys = await getCurrentKeys()
if (!keys.length) return { keys: [] }
···
error: error instanceof Error ? error.message : String(error)
}
}
+
})
+
.get('/.well-known/atproto-did', ({ set }) => {
+
// Return plain text DID for AT Protocol domain verification
+
set.headers['Content-Type'] = 'text/plain'
+
return 'did:plc:7puq73yz2hkvbcpdhnsze2qw'
})
.use(cors({
origin: config.domain,
+81
src/lib/csrf.test.ts
···
+
import { describe, test, expect } from 'bun:test'
+
import { verifyRequestOrigin } from './csrf'
+
+
describe('verifyRequestOrigin', () => {
+
test('should accept matching origin and host', () => {
+
expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true)
+
expect(verifyRequestOrigin('http://localhost:8000', ['localhost:8000'])).toBe(true)
+
expect(verifyRequestOrigin('https://app.example.com', ['app.example.com'])).toBe(true)
+
})
+
+
test('should accept origin matching one of multiple allowed hosts', () => {
+
const allowedHosts = ['example.com', 'app.example.com', 'localhost:8000']
+
expect(verifyRequestOrigin('https://example.com', allowedHosts)).toBe(true)
+
expect(verifyRequestOrigin('https://app.example.com', allowedHosts)).toBe(true)
+
expect(verifyRequestOrigin('http://localhost:8000', allowedHosts)).toBe(true)
+
})
+
+
test('should reject non-matching origin', () => {
+
expect(verifyRequestOrigin('https://evil.com', ['example.com'])).toBe(false)
+
expect(verifyRequestOrigin('https://fake-example.com', ['example.com'])).toBe(false)
+
expect(verifyRequestOrigin('https://example.com.evil.com', ['example.com'])).toBe(false)
+
})
+
+
test('should reject empty origin', () => {
+
expect(verifyRequestOrigin('', ['example.com'])).toBe(false)
+
})
+
+
test('should reject invalid URL format', () => {
+
expect(verifyRequestOrigin('not-a-url', ['example.com'])).toBe(false)
+
expect(verifyRequestOrigin('javascript:alert(1)', ['example.com'])).toBe(false)
+
expect(verifyRequestOrigin('file:///etc/passwd', ['example.com'])).toBe(false)
+
})
+
+
test('should handle different protocols correctly', () => {
+
// Same host, different protocols should match (we only check host)
+
expect(verifyRequestOrigin('http://example.com', ['example.com'])).toBe(true)
+
expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true)
+
})
+
+
test('should handle port numbers correctly', () => {
+
expect(verifyRequestOrigin('http://localhost:3000', ['localhost:3000'])).toBe(true)
+
expect(verifyRequestOrigin('http://localhost:3000', ['localhost:8000'])).toBe(false)
+
expect(verifyRequestOrigin('http://localhost', ['localhost'])).toBe(true)
+
})
+
+
test('should handle subdomains correctly', () => {
+
expect(verifyRequestOrigin('https://sub.example.com', ['sub.example.com'])).toBe(true)
+
expect(verifyRequestOrigin('https://sub.example.com', ['example.com'])).toBe(false)
+
})
+
+
test('should handle case sensitivity (exact match required)', () => {
+
// URL host is automatically lowercased by URL parser
+
expect(verifyRequestOrigin('https://EXAMPLE.COM', ['example.com'])).toBe(true)
+
expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true)
+
// But allowed hosts are case-sensitive
+
expect(verifyRequestOrigin('https://example.com', ['EXAMPLE.COM'])).toBe(false)
+
})
+
+
test('should handle trailing slashes in origin', () => {
+
expect(verifyRequestOrigin('https://example.com/', ['example.com'])).toBe(true)
+
})
+
+
test('should handle paths in origin (host extraction)', () => {
+
expect(verifyRequestOrigin('https://example.com/path/to/page', ['example.com'])).toBe(true)
+
expect(verifyRequestOrigin('https://evil.com/example.com', ['example.com'])).toBe(false)
+
})
+
+
test('should reject when allowed hosts is empty', () => {
+
expect(verifyRequestOrigin('https://example.com', [])).toBe(false)
+
})
+
+
test('should handle IPv4 addresses', () => {
+
expect(verifyRequestOrigin('http://127.0.0.1:8000', ['127.0.0.1:8000'])).toBe(true)
+
expect(verifyRequestOrigin('http://192.168.1.1', ['192.168.1.1'])).toBe(true)
+
})
+
+
test('should handle IPv6 addresses', () => {
+
expect(verifyRequestOrigin('http://[::1]:8000', ['[::1]:8000'])).toBe(true)
+
expect(verifyRequestOrigin('http://[2001:db8::1]', ['[2001:db8::1]'])).toBe(true)
+
})
+
})
+35 -15
src/lib/dns-verification-worker.ts
···
};
try {
-
// Get all verified custom domains
-
const domains = await db`
-
SELECT id, domain, did FROM custom_domains WHERE verified = true
+
// Get all custom domains (both verified and pending)
+
const domains = await db<Array<{
+
id: string;
+
domain: string;
+
did: string;
+
verified: boolean;
+
}>>`
+
SELECT id, domain, did, verified FROM custom_domains
`;
if (!domains || domains.length === 0) {
-
this.log('No verified custom domains to check');
+
this.log('No custom domains to check');
this.lastRunTime = Date.now();
return;
}
-
this.log(`Checking ${domains.length} verified custom domains`);
+
const verifiedCount = domains.filter(d => d.verified).length;
+
const pendingCount = domains.filter(d => !d.verified).length;
+
this.log(`Checking ${domains.length} custom domains (${verifiedCount} verified, ${pendingCount} pending)`);
// Verify each domain
for (const row of domains) {
runStats.totalChecked++;
-
const { id, domain, did } = row;
+
const { id, domain, did, verified: wasVerified } = row;
try {
// Extract hash from id (SHA256 of did:domain)
···
const result = await verifyCustomDomain(domain, did, expectedHash);
if (result.verified) {
-
// Update last_verified_at timestamp
+
// Update verified status and last_verified_at timestamp
await db`
UPDATE custom_domains
-
SET last_verified_at = EXTRACT(EPOCH FROM NOW())
+
SET verified = true,
+
last_verified_at = EXTRACT(EPOCH FROM NOW())
WHERE id = ${id}
`;
runStats.verified++;
-
this.log(`Domain verified: ${domain}`, { did });
+
if (!wasVerified) {
+
this.log(`Domain newly verified: ${domain}`, { did });
+
} else {
+
this.log(`Domain re-verified: ${domain}`, { did });
+
}
} else {
-
// Mark domain as unverified
+
// Mark domain as unverified or keep it pending
await db`
UPDATE custom_domains
SET verified = false,
···
WHERE id = ${id}
`;
runStats.failed++;
-
this.log(`Domain verification failed: ${domain}`, {
-
did,
-
error: result.error,
-
found: result.found,
-
});
+
if (wasVerified) {
+
this.log(`Domain verification failed (was verified): ${domain}`, {
+
did,
+
error: result.error,
+
found: result.found,
+
});
+
} else {
+
this.log(`Domain still pending: ${domain}`, {
+
did,
+
error: result.error,
+
found: result.found,
+
});
+
}
}
} catch (error) {
runStats.errors++;
+10 -6
src/lib/observability.ts
···
service
)
-
logCollector.error(
-
`Request failed: ${request.method} ${url.pathname}`,
-
service,
-
error,
-
{ statusCode: set.status || 500 }
-
)
+
// Don't log 404 errors
+
const statusCode = set.status || 500
+
if (statusCode !== 404) {
+
logCollector.error(
+
`Request failed: ${request.method} ${url.pathname}`,
+
service,
+
error,
+
{ statusCode }
+
)
+
}
}
}
}
+639
src/lib/wisp-utils.test.ts
···
+
import { describe, test, expect } from 'bun:test'
+
import {
+
shouldCompressFile,
+
compressFile,
+
processUploadedFiles,
+
createManifest,
+
updateFileBlobs,
+
type UploadedFile,
+
type FileUploadResult,
+
} from './wisp-utils'
+
import type { Directory } from '../lexicons/types/place/wisp/fs'
+
import { gunzipSync } from 'zlib'
+
import { BlobRef } from '@atproto/api'
+
import { CID } from 'multiformats/cid'
+
+
// Helper function to create a valid CID for testing
+
// Using a real valid CID from actual AT Protocol usage
+
const TEST_CID_STRING = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
+
+
function createMockBlobRef(mimeType: string, size: number): BlobRef {
+
// Create a properly formatted CID
+
const cid = CID.parse(TEST_CID_STRING)
+
return new BlobRef(cid, mimeType, size)
+
}
+
+
describe('shouldCompressFile', () => {
+
test('should compress HTML files', () => {
+
expect(shouldCompressFile('text/html')).toBe(true)
+
expect(shouldCompressFile('text/html; charset=utf-8')).toBe(true)
+
})
+
+
test('should compress CSS files', () => {
+
expect(shouldCompressFile('text/css')).toBe(true)
+
})
+
+
test('should compress JavaScript files', () => {
+
expect(shouldCompressFile('text/javascript')).toBe(true)
+
expect(shouldCompressFile('application/javascript')).toBe(true)
+
expect(shouldCompressFile('application/x-javascript')).toBe(true)
+
})
+
+
test('should compress JSON files', () => {
+
expect(shouldCompressFile('application/json')).toBe(true)
+
})
+
+
test('should compress SVG files', () => {
+
expect(shouldCompressFile('image/svg+xml')).toBe(true)
+
})
+
+
test('should compress XML files', () => {
+
expect(shouldCompressFile('text/xml')).toBe(true)
+
expect(shouldCompressFile('application/xml')).toBe(true)
+
})
+
+
test('should compress plain text files', () => {
+
expect(shouldCompressFile('text/plain')).toBe(true)
+
})
+
+
test('should NOT compress images', () => {
+
expect(shouldCompressFile('image/png')).toBe(false)
+
expect(shouldCompressFile('image/jpeg')).toBe(false)
+
expect(shouldCompressFile('image/jpg')).toBe(false)
+
expect(shouldCompressFile('image/gif')).toBe(false)
+
expect(shouldCompressFile('image/webp')).toBe(false)
+
})
+
+
test('should NOT compress videos', () => {
+
expect(shouldCompressFile('video/mp4')).toBe(false)
+
expect(shouldCompressFile('video/webm')).toBe(false)
+
})
+
+
test('should NOT compress already compressed formats', () => {
+
expect(shouldCompressFile('application/zip')).toBe(false)
+
expect(shouldCompressFile('application/gzip')).toBe(false)
+
expect(shouldCompressFile('application/pdf')).toBe(false)
+
})
+
+
test('should NOT compress fonts', () => {
+
expect(shouldCompressFile('font/woff')).toBe(false)
+
expect(shouldCompressFile('font/woff2')).toBe(false)
+
expect(shouldCompressFile('font/ttf')).toBe(false)
+
})
+
})
+
+
describe('compressFile', () => {
+
test('should compress text content', () => {
+
const content = Buffer.from('Hello, World! '.repeat(100))
+
const compressed = compressFile(content)
+
+
expect(compressed.length).toBeLessThan(content.length)
+
+
// Verify we can decompress it back
+
const decompressed = gunzipSync(compressed)
+
expect(decompressed.toString()).toBe(content.toString())
+
})
+
+
test('should compress HTML content significantly', () => {
+
const html = `
+
<!DOCTYPE html>
+
<html>
+
<head><title>Test</title></head>
+
<body>
+
${'<p>Hello World!</p>\n'.repeat(50)}
+
</body>
+
</html>
+
`
+
const content = Buffer.from(html)
+
const compressed = compressFile(content)
+
+
expect(compressed.length).toBeLessThan(content.length)
+
+
// Verify decompression
+
const decompressed = gunzipSync(compressed)
+
expect(decompressed.toString()).toBe(html)
+
})
+
+
test('should handle empty content', () => {
+
const content = Buffer.from('')
+
const compressed = compressFile(content)
+
const decompressed = gunzipSync(compressed)
+
expect(decompressed.toString()).toBe('')
+
})
+
+
test('should produce deterministic compression', () => {
+
const content = Buffer.from('Test content')
+
const compressed1 = compressFile(content)
+
const compressed2 = compressFile(content)
+
+
expect(compressed1.toString('base64')).toBe(compressed2.toString('base64'))
+
})
+
})
+
+
describe('processUploadedFiles', () => {
+
test('should process single root-level file', () => {
+
const files: UploadedFile[] = [
+
{
+
name: 'index.html',
+
content: Buffer.from('<html></html>'),
+
mimeType: 'text/html',
+
size: 13,
+
},
+
]
+
+
const result = processUploadedFiles(files)
+
+
expect(result.fileCount).toBe(1)
+
expect(result.directory.type).toBe('directory')
+
expect(result.directory.entries).toHaveLength(1)
+
expect(result.directory.entries[0].name).toBe('index.html')
+
+
const node = result.directory.entries[0].node
+
expect('blob' in node).toBe(true) // It's a file node
+
})
+
+
test('should process multiple root-level files', () => {
+
const files: UploadedFile[] = [
+
{
+
name: 'index.html',
+
content: Buffer.from('<html></html>'),
+
mimeType: 'text/html',
+
size: 13,
+
},
+
{
+
name: 'styles.css',
+
content: Buffer.from('body {}'),
+
mimeType: 'text/css',
+
size: 7,
+
},
+
{
+
name: 'script.js',
+
content: Buffer.from('console.log("hi")'),
+
mimeType: 'application/javascript',
+
size: 17,
+
},
+
]
+
+
const result = processUploadedFiles(files)
+
+
expect(result.fileCount).toBe(3)
+
expect(result.directory.entries).toHaveLength(3)
+
+
const names = result.directory.entries.map(e => e.name)
+
expect(names).toContain('index.html')
+
expect(names).toContain('styles.css')
+
expect(names).toContain('script.js')
+
})
+
+
test('should process files with subdirectories', () => {
+
const files: UploadedFile[] = [
+
{
+
name: 'dist/index.html',
+
content: Buffer.from('<html></html>'),
+
mimeType: 'text/html',
+
size: 13,
+
},
+
{
+
name: 'dist/css/styles.css',
+
content: Buffer.from('body {}'),
+
mimeType: 'text/css',
+
size: 7,
+
},
+
{
+
name: 'dist/js/app.js',
+
content: Buffer.from('console.log()'),
+
mimeType: 'application/javascript',
+
size: 13,
+
},
+
]
+
+
const result = processUploadedFiles(files)
+
+
expect(result.fileCount).toBe(3)
+
expect(result.directory.entries).toHaveLength(3) // index.html, css/, js/
+
+
// Check root has index.html (after base folder removal)
+
const indexEntry = result.directory.entries.find(e => e.name === 'index.html')
+
expect(indexEntry).toBeDefined()
+
+
// Check css directory exists
+
const cssDir = result.directory.entries.find(e => e.name === 'css')
+
expect(cssDir).toBeDefined()
+
expect('entries' in cssDir!.node).toBe(true)
+
+
if ('entries' in cssDir!.node) {
+
expect(cssDir!.node.entries).toHaveLength(1)
+
expect(cssDir!.node.entries[0].name).toBe('styles.css')
+
}
+
+
// Check js directory exists
+
const jsDir = result.directory.entries.find(e => e.name === 'js')
+
expect(jsDir).toBeDefined()
+
expect('entries' in jsDir!.node).toBe(true)
+
})
+
+
test('should handle deeply nested subdirectories', () => {
+
const files: UploadedFile[] = [
+
{
+
name: 'dist/deep/nested/folder/file.txt',
+
content: Buffer.from('content'),
+
mimeType: 'text/plain',
+
size: 7,
+
},
+
]
+
+
const result = processUploadedFiles(files)
+
+
expect(result.fileCount).toBe(1)
+
+
// Navigate through the directory structure (base folder removed)
+
const deepDir = result.directory.entries.find(e => e.name === 'deep')
+
expect(deepDir).toBeDefined()
+
expect('entries' in deepDir!.node).toBe(true)
+
+
if ('entries' in deepDir!.node) {
+
const nestedDir = deepDir!.node.entries.find(e => e.name === 'nested')
+
expect(nestedDir).toBeDefined()
+
+
if (nestedDir && 'entries' in nestedDir.node) {
+
const folderDir = nestedDir.node.entries.find(e => e.name === 'folder')
+
expect(folderDir).toBeDefined()
+
+
if (folderDir && 'entries' in folderDir.node) {
+
expect(folderDir.node.entries).toHaveLength(1)
+
expect(folderDir.node.entries[0].name).toBe('file.txt')
+
}
+
}
+
}
+
})
+
+
test('should remove base folder name from paths', () => {
+
const files: UploadedFile[] = [
+
{
+
name: 'dist/index.html',
+
content: Buffer.from('<html></html>'),
+
mimeType: 'text/html',
+
size: 13,
+
},
+
{
+
name: 'dist/css/styles.css',
+
content: Buffer.from('body {}'),
+
mimeType: 'text/css',
+
size: 7,
+
},
+
]
+
+
const result = processUploadedFiles(files)
+
+
// After removing 'dist/', we should have index.html and css/ at root
+
expect(result.directory.entries.find(e => e.name === 'index.html')).toBeDefined()
+
expect(result.directory.entries.find(e => e.name === 'css')).toBeDefined()
+
expect(result.directory.entries.find(e => e.name === 'dist')).toBeUndefined()
+
})
+
+
test('should handle empty file list', () => {
+
const files: UploadedFile[] = []
+
const result = processUploadedFiles(files)
+
+
expect(result.fileCount).toBe(0)
+
expect(result.directory.entries).toHaveLength(0)
+
})
+
+
test('should handle multiple files in same subdirectory', () => {
+
const files: UploadedFile[] = [
+
{
+
name: 'dist/assets/image1.png',
+
content: Buffer.from('png1'),
+
mimeType: 'image/png',
+
size: 4,
+
},
+
{
+
name: 'dist/assets/image2.png',
+
content: Buffer.from('png2'),
+
mimeType: 'image/png',
+
size: 4,
+
},
+
]
+
+
const result = processUploadedFiles(files)
+
+
expect(result.fileCount).toBe(2)
+
+
const assetsDir = result.directory.entries.find(e => e.name === 'assets')
+
expect(assetsDir).toBeDefined()
+
+
if ('entries' in assetsDir!.node) {
+
expect(assetsDir!.node.entries).toHaveLength(2)
+
const names = assetsDir!.node.entries.map(e => e.name)
+
expect(names).toContain('image1.png')
+
expect(names).toContain('image2.png')
+
}
+
})
+
})
+
+
describe('createManifest', () => {
+
test('should create valid manifest', () => {
+
const root: Directory = {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries: [],
+
}
+
+
const manifest = createManifest('example.com', root, 0)
+
+
expect(manifest.$type).toBe('place.wisp.fs')
+
expect(manifest.site).toBe('example.com')
+
expect(manifest.root).toBe(root)
+
expect(manifest.fileCount).toBe(0)
+
expect(manifest.createdAt).toBeDefined()
+
+
// Verify it's a valid ISO date string
+
const date = new Date(manifest.createdAt)
+
expect(date.toISOString()).toBe(manifest.createdAt)
+
})
+
+
test('should create manifest with file count', () => {
+
const root: Directory = {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries: [],
+
}
+
+
const manifest = createManifest('test-site', root, 42)
+
+
expect(manifest.fileCount).toBe(42)
+
expect(manifest.site).toBe('test-site')
+
})
+
+
test('should create manifest with populated directory', () => {
+
const mockBlob = createMockBlobRef('text/html', 100)
+
+
const root: Directory = {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries: [
+
{
+
name: 'index.html',
+
node: {
+
$type: 'place.wisp.fs#file',
+
type: 'file',
+
blob: mockBlob,
+
},
+
},
+
],
+
}
+
+
const manifest = createManifest('populated-site', root, 1)
+
+
expect(manifest).toBeDefined()
+
expect(manifest.site).toBe('populated-site')
+
expect(manifest.root.entries).toHaveLength(1)
+
})
+
})
+
+
describe('updateFileBlobs', () => {
+
test('should update single file blob at root', () => {
+
const directory: Directory = {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries: [
+
{
+
name: 'index.html',
+
node: {
+
$type: 'place.wisp.fs#file',
+
type: 'file',
+
blob: undefined as any,
+
},
+
},
+
],
+
}
+
+
const mockBlob = createMockBlobRef('text/html', 100)
+
const uploadResults: FileUploadResult[] = [
+
{
+
hash: TEST_CID_STRING,
+
blobRef: mockBlob,
+
mimeType: 'text/html',
+
},
+
]
+
+
const filePaths = ['index.html']
+
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
+
+
expect(updated.entries).toHaveLength(1)
+
const fileNode = updated.entries[0].node
+
+
if ('blob' in fileNode) {
+
expect(fileNode.blob).toBeDefined()
+
expect(fileNode.blob.mimeType).toBe('text/html')
+
expect(fileNode.blob.size).toBe(100)
+
} else {
+
throw new Error('Expected file node')
+
}
+
})
+
+
test('should update files in nested directories', () => {
+
const directory: Directory = {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries: [
+
{
+
name: 'css',
+
node: {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries: [
+
{
+
name: 'styles.css',
+
node: {
+
$type: 'place.wisp.fs#file',
+
type: 'file',
+
blob: undefined as any,
+
},
+
},
+
],
+
},
+
},
+
],
+
}
+
+
const mockBlob = createMockBlobRef('text/css', 50)
+
const uploadResults: FileUploadResult[] = [
+
{
+
hash: TEST_CID_STRING,
+
blobRef: mockBlob,
+
mimeType: 'text/css',
+
encoding: 'gzip',
+
},
+
]
+
+
const filePaths = ['css/styles.css']
+
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
+
+
const cssDir = updated.entries[0]
+
expect(cssDir.name).toBe('css')
+
+
if ('entries' in cssDir.node) {
+
const cssFile = cssDir.node.entries[0]
+
expect(cssFile.name).toBe('styles.css')
+
+
if ('blob' in cssFile.node) {
+
expect(cssFile.node.blob.mimeType).toBe('text/css')
+
if ('encoding' in cssFile.node) {
+
expect(cssFile.node.encoding).toBe('gzip')
+
}
+
} else {
+
throw new Error('Expected file node')
+
}
+
} else {
+
throw new Error('Expected directory node')
+
}
+
})
+
+
test('should handle normalized paths with base folder removed', () => {
+
const directory: Directory = {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries: [
+
{
+
name: 'index.html',
+
node: {
+
$type: 'place.wisp.fs#file',
+
type: 'file',
+
blob: undefined as any,
+
},
+
},
+
],
+
}
+
+
const mockBlob = createMockBlobRef('text/html', 100)
+
const uploadResults: FileUploadResult[] = [
+
{
+
hash: TEST_CID_STRING,
+
blobRef: mockBlob,
+
},
+
]
+
+
// Path includes base folder that should be normalized
+
const filePaths = ['dist/index.html']
+
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
+
+
const fileNode = updated.entries[0].node
+
if ('blob' in fileNode) {
+
expect(fileNode.blob).toBeDefined()
+
} else {
+
throw new Error('Expected file node')
+
}
+
})
+
+
test('should preserve file metadata (encoding, mimeType, base64)', () => {
+
const directory: Directory = {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries: [
+
{
+
name: 'data.json',
+
node: {
+
$type: 'place.wisp.fs#file',
+
type: 'file',
+
blob: undefined as any,
+
},
+
},
+
],
+
}
+
+
const mockBlob = createMockBlobRef('application/json', 200)
+
const uploadResults: FileUploadResult[] = [
+
{
+
hash: TEST_CID_STRING,
+
blobRef: mockBlob,
+
mimeType: 'application/json',
+
encoding: 'gzip',
+
base64: true,
+
},
+
]
+
+
const filePaths = ['data.json']
+
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
+
+
const fileNode = updated.entries[0].node
+
if ('blob' in fileNode && 'mimeType' in fileNode && 'encoding' in fileNode && 'base64' in fileNode) {
+
expect(fileNode.mimeType).toBe('application/json')
+
expect(fileNode.encoding).toBe('gzip')
+
expect(fileNode.base64).toBe(true)
+
} else {
+
throw new Error('Expected file node with metadata')
+
}
+
})
+
+
test('should handle multiple files at different directory levels', () => {
+
const directory: Directory = {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries: [
+
{
+
name: 'index.html',
+
node: {
+
$type: 'place.wisp.fs#file',
+
type: 'file',
+
blob: undefined as any,
+
},
+
},
+
{
+
name: 'assets',
+
node: {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries: [
+
{
+
name: 'logo.svg',
+
node: {
+
$type: 'place.wisp.fs#file',
+
type: 'file',
+
blob: undefined as any,
+
},
+
},
+
],
+
},
+
},
+
],
+
}
+
+
const htmlBlob = createMockBlobRef('text/html', 100)
+
const svgBlob = createMockBlobRef('image/svg+xml', 500)
+
+
const uploadResults: FileUploadResult[] = [
+
{
+
hash: TEST_CID_STRING,
+
blobRef: htmlBlob,
+
},
+
{
+
hash: TEST_CID_STRING,
+
blobRef: svgBlob,
+
},
+
]
+
+
const filePaths = ['index.html', 'assets/logo.svg']
+
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
+
+
// Check root file
+
const indexNode = updated.entries[0].node
+
if ('blob' in indexNode) {
+
expect(indexNode.blob.mimeType).toBe('text/html')
+
}
+
+
// Check nested file
+
const assetsDir = updated.entries[1]
+
if ('entries' in assetsDir.node) {
+
const logoNode = assetsDir.node.entries[0].node
+
if ('blob' in logoNode) {
+
expect(logoNode.blob.mimeType).toBe('image/svg+xml')
+
}
+
}
+
})
+
})